Verify and restore the complete Minecraft asset cache
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 8m10s

This commit is contained in:
2026-10-27 21:07:56 +05:00
parent 1fc59d4ceb
commit ed4d264801
2 changed files with 70 additions and 14 deletions

View File

@@ -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 { createHash } from "node:crypto";
import { unzipSync } from "fflate"; import { unzipSync } from "fflate";
export const assetsRoot = ".cache/minecraft/1.21.11"; export const assetsRoot = ".cache/minecraft/1.21.11";
@@ -24,13 +24,38 @@ async function assetsReady(minecraftVersion) {
try { try {
const marker = JSON.parse(await readFile(completionMarker, "utf8")); const marker = JSON.parse(await readFile(completionMarker, "utf8"));
if ( if (
marker.format !== "minegeo-assets/v2" || marker.format !== "minegeo-assets/v3" ||
marker.minecraftVersion !== minecraftVersion || marker.minecraftVersion !== minecraftVersion ||
!marker.files !marker.files ||
!Array.isArray(marker.entries) ||
marker.entries.length !== marker.files
) )
return false; return false;
// A blockstate alone is insufficient: interrupted setup can leave models // Verify the complete extracted inventory, not just common blocks. A
// and textures absent. The marker is only written after all extraction. // 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) { for (const asset of requiredAssets) {
const bytes = await readFile(`${assetsRoot}/assets/minecraft/${asset}`); const bytes = await readFile(`${assetsRoot}/assets/minecraft/${asset}`);
if (asset.endsWith(".json")) JSON.parse(bytes.toString()); if (asset.endsWith(".json")) JSON.parse(bytes.toString());
@@ -96,23 +121,41 @@ export async function prepareAssets(minecraftVersion = "1.21.11") {
} }
const entries = unzipSync(jar, { const entries = unzipSync(jar, {
filter: (f) => filter: (f) =>
/^assets\/minecraft\/(models|blockstates|textures\/block)\//.test( /^assets\/minecraft\/(models|blockstates|textures)\//.test(f.name) &&
f.name, /\.(json|png|mcmeta)$/.test(f.name),
) && /\.(json|png|mcmeta)$/.test(f.name),
}); });
for (const [name, bytes] of Object.entries(entries)) { const files = Object.entries(entries);
const path = `${assetsRoot}/${name}`; const directories = new Set(
await mkdir(path.slice(0, path.lastIndexOf("/")), { recursive: true }); files.map(
await writeFile(path, bytes); ([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. // Completion is recorded last, so a killed extraction is repaired next time.
await writeFile( await writeFile(
completionMarker, completionMarker,
JSON.stringify({ JSON.stringify({
format: "minegeo-assets/v2", format: "minegeo-assets/v3",
minecraftVersion, minecraftVersion,
clientSha1: version.downloads.client.sha1, clientSha1: version.downloads.client.sha1,
files: Object.keys(entries).length, files: Object.keys(entries).length,
entries: Object.entries(entries).map(([name, bytes]) => [
name,
bytes.length,
]),
}), }),
); );
if (!(await assetsReady(minecraftVersion))) if (!(await assetsReady(minecraftVersion)))

View File

@@ -23,6 +23,8 @@ const files = {
"textures/block/dirt.png": png, "textures/block/dirt.png": png,
"textures/block/stone.png": png, "textures/block/stone.png": png,
"textures/block/water_still.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( "textures/block/water_still.png.mcmeta": Buffer.from(
'{"animation":{"frametime":2}}', '{"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); 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) => { test("a corrupted cached client is downloaded again", async (t) => {
const requests = await isolatedCache(t); const requests = await isolatedCache(t);
await mkdir(".cache/minecraft", { recursive: true }); await mkdir(".cache/minecraft", { recursive: true });