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"; export function assetsDirectory(version = "1.21.11") { if (!/^(?:1\.21\.11|26\.[12](?:\.\d+)*)$/.test(version)) throw new Error("Unsupported Minecraft asset version"); return `.cache/minecraft/${version}`; } const requiredAssets = [ "blockstates/grass_block.json", "blockstates/stone.json", "models/block/block.json", "models/block/cube.json", "models/block/cube_all.json", "textures/block/grass_block_top.png", "textures/block/dirt.png", "textures/block/stone.png", "textures/block/water_still.png", ]; async function assetsReady(minecraftVersion) { const assetsRoot = assetsDirectory(minecraftVersion); const completionMarker = `${assetsRoot}/.minegeo-assets.json`; try { const marker = JSON.parse(await readFile(completionMarker, "utf8")); if ( marker.format !== "minegeo-assets/v3" || marker.minecraftVersion !== minecraftVersion || !marker.files || !Array.isArray(marker.entries) || marker.entries.length !== marker.files ) return false; // 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()); else if ( bytes.length < 8 || bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a" ) return false; } return true; } catch { return false; } } export async function prepareAssets(minecraftVersion = "1.21.11") { const assetsRoot = assetsDirectory(minecraftVersion); const completionMarker = `${assetsRoot}/.minegeo-assets.json`; if (await assetsReady(minecraftVersion)) return assetsRoot; console.log( `Preparing Minecraft ${minecraftVersion} models and textures for terrain rendering…`, ); const manifest = await ( await fetch( "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json", { signal: AbortSignal.timeout(60000) }, ) ).json(); const entry = manifest.versions.find((v) => v.id === minecraftVersion); if (!entry) throw new Error(`Official Minecraft ${minecraftVersion} metadata missing`); const version = await ( await fetch(entry.url, { signal: AbortSignal.timeout(60000) }) ).json(); await mkdir(".cache/minecraft", { recursive: true }); const jarPath = `.cache/minecraft/client-${minecraftVersion}.jar`; let jar; try { jar = await readFile(jarPath); } catch { /* No cached client yet. */ } if ( !jar || createHash("sha1").update(jar).digest("hex") !== version.downloads.client.sha1 ) { const response = await fetch(version.downloads.client.url, { signal: AbortSignal.timeout(120000), }); if (!response.ok) throw new Error( `Minecraft asset download failed: HTTP ${response.status}`, ); jar = Buffer.from(await response.arrayBuffer()); if ( createHash("sha1").update(jar).digest("hex") !== version.downloads.client.sha1 ) throw new Error( "Minecraft client checksum mismatch; downloaded assets were not saved", ); await writeFile(jarPath, jar); } const entries = unzipSync(jar, { filter: (f) => /^assets\/minecraft\/(models|blockstates|textures)\//.test(f.name) && /\.(json|png|mcmeta)$/.test(f.name), }); 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/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))) throw new Error( "Minecraft rendering assets are incomplete or corrupt. Run npm run setup to repair them.", ); console.log( `Prepared ${Object.keys(entries).length} verified vanilla model/texture assets.`, ); return assetsRoot; }