diff --git a/scripts/assets.mjs b/scripts/assets.mjs index c7aebc0..8aa2d7f 100644 --- a/scripts/assets.mjs +++ b/scripts/assets.mjs @@ -1,4 +1,4 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; import { unzipSync } from "fflate"; export const assetsRoot = ".cache/minecraft/1.21.11"; @@ -24,13 +24,38 @@ async function assetsReady(minecraftVersion) { try { const marker = JSON.parse(await readFile(completionMarker, "utf8")); if ( - marker.format !== "minegeo-assets/v2" || + marker.format !== "minegeo-assets/v3" || marker.minecraftVersion !== minecraftVersion || - !marker.files + !marker.files || + !Array.isArray(marker.entries) || + marker.entries.length !== marker.files ) return false; - // A blockstate alone is insufficient: interrupted setup can leave models - // and textures absent. The marker is only written after all extraction. + // Verify the complete extracted inventory, not just common blocks. A + // partial cache otherwise silently drops newer or less common textures. + let next = 0; + await Promise.all( + Array.from({ length: 16 }, async () => { + while (next < marker.entries.length) { + const entry = marker.entries[next++]; + if ( + !Array.isArray(entry) || + entry.length !== 2 || + typeof entry[0] !== "string" || + !/^assets\/minecraft\/(models|blockstates|textures)\/[a-z0-9_./-]+\.(json|png|mcmeta)$/.test( + entry[0], + ) || + entry[0].includes("..") || + !Number.isSafeInteger(entry[1]) || + entry[1] < 1 + ) + throw new Error("Invalid asset inventory"); + const info = await stat(`${assetsRoot}/${entry[0]}`); + if (!info.isFile() || info.size !== entry[1]) + throw new Error("Missing asset"); + } + }), + ); for (const asset of requiredAssets) { const bytes = await readFile(`${assetsRoot}/assets/minecraft/${asset}`); if (asset.endsWith(".json")) JSON.parse(bytes.toString()); @@ -96,23 +121,41 @@ export async function prepareAssets(minecraftVersion = "1.21.11") { } const entries = unzipSync(jar, { filter: (f) => - /^assets\/minecraft\/(models|blockstates|textures\/block)\//.test( - f.name, - ) && /\.(json|png|mcmeta)$/.test(f.name), + /^assets\/minecraft\/(models|blockstates|textures)\//.test(f.name) && + /\.(json|png|mcmeta)$/.test(f.name), }); - for (const [name, bytes] of Object.entries(entries)) { - const path = `${assetsRoot}/${name}`; - await mkdir(path.slice(0, path.lastIndexOf("/")), { recursive: true }); - await writeFile(path, bytes); - } + const files = Object.entries(entries); + const directories = new Set( + files.map( + ([name]) => `${assetsRoot}/${name.slice(0, name.lastIndexOf("/"))}`, + ), + ); + await Promise.all( + [...directories].map((path) => mkdir(path, { recursive: true })), + ); + let nextFile = 0; + const writes = await Promise.allSettled( + Array.from({ length: Math.min(16, files.length) }, async () => { + while (nextFile < files.length) { + const [name, bytes] = files[nextFile++]; + await writeFile(`${assetsRoot}/${name}`, bytes); + } + }), + ); + const failedWrite = writes.find((result) => result.status === "rejected"); + if (failedWrite?.status === "rejected") throw failedWrite.reason; // Completion is recorded last, so a killed extraction is repaired next time. await writeFile( completionMarker, JSON.stringify({ - format: "minegeo-assets/v2", + format: "minegeo-assets/v3", minecraftVersion, clientSha1: version.downloads.client.sha1, files: Object.keys(entries).length, + entries: Object.entries(entries).map(([name, bytes]) => [ + name, + bytes.length, + ]), }), ); if (!(await assetsReady(minecraftVersion))) diff --git a/tests/renderer-assets.test.ts b/tests/renderer-assets.test.ts index 401d3eb..0f44b75 100644 --- a/tests/renderer-assets.test.ts +++ b/tests/renderer-assets.test.ts @@ -23,6 +23,8 @@ const files = { "textures/block/dirt.png": png, "textures/block/stone.png": png, "textures/block/water_still.png": png, + "textures/block/newer_fixture.png": png, + "textures/entity/newer_fixture.png": png, "textures/block/water_still.png.mcmeta": Buffer.from( '{"animation":{"frametime":2}}', ), @@ -145,6 +147,17 @@ test("a complete marker with a missing texture is repaired from the verified cac assert.equal(requests.filter((url) => url.endsWith("client.jar")).length, 0); }); +test("missing non-core 26.2 textures are restored from the verified client", async (t) => { + const requests = await isolatedCache(t, false, "26.2"); + const directory = await prepareAssets("26.2"); + const path = `${directory}/assets/minecraft/textures/block/newer_fixture.png`; + await rm(path); + requests.length = 0; + await prepareAssets("26.2"); + assert.deepEqual(await readFile(path), png); + assert.equal(requests.filter((url) => url.endsWith("client.jar")).length, 0); +}); + test("a corrupted cached client is downloaded again", async (t) => { const requests = await isolatedCache(t); await mkdir(".cache/minecraft", { recursive: true });