Verify and restore the complete Minecraft asset cache
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 8m10s
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 8m10s
This commit is contained in:
@@ -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)))
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user