Add Minecraft 26.x support and version Java artifacts

This commit is contained in:
2026-10-07 13:18:31 +05:00
parent 48b34db6f1
commit dac8650ac1
50 changed files with 813 additions and 162 deletions

View File

@@ -2,7 +2,11 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { unzipSync } from "fflate";
export const assetsRoot = ".cache/minecraft/1.21.11";
const completionMarker = `${assetsRoot}/.minegeo-assets.json`;
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",
@@ -14,12 +18,14 @@ const requiredAssets = [
"textures/block/stone.png",
"textures/block/water_still.png",
];
async function assetsReady() {
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/v2" ||
marker.minecraftVersion !== "1.21.11" ||
marker.minecraftVersion !== minecraftVersion ||
!marker.files
)
return false;
@@ -39,10 +45,12 @@ async function assetsReady() {
return false;
}
}
export async function prepareAssets() {
if (await assetsReady()) return;
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 1.21.11 models and textures for terrain rendering…",
`Preparing Minecraft ${minecraftVersion} models and textures for terrain rendering…`,
);
const manifest = await (
await fetch(
@@ -50,13 +58,14 @@ export async function prepareAssets() {
{ signal: AbortSignal.timeout(60000) },
)
).json();
const entry = manifest.versions.find((v) => v.id === "1.21.11");
if (!entry) throw new Error("Official Minecraft 1.21.11 metadata missing");
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-1.21.11.jar";
const jarPath = `.cache/minecraft/client-${minecraftVersion}.jar`;
let jar;
try {
jar = await readFile(jarPath);
@@ -101,16 +110,17 @@ export async function prepareAssets() {
completionMarker,
JSON.stringify({
format: "minegeo-assets/v2",
minecraftVersion: "1.21.11",
minecraftVersion,
clientSha1: version.downloads.client.sha1,
files: Object.keys(entries).length,
}),
);
if (!(await assetsReady()))
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;
}

View File

@@ -1,11 +1,21 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdir, copyFile } from "node:fs/promises";
import { mkdir, copyFile, readFile } from "node:fs/promises";
import path from "node:path";
const windows = process.platform === "win32";
const command = windows ? "gradlew.bat" : "sh";
const targets = process.argv.slice(2);
if (!targets.some((target) => !target.startsWith("-")))
targets.unshift(":plugin:build", ":fabric:build");
targets.unshift(
":plugin:build",
":fabric:build",
":fabric261:build",
":fabric262:build",
);
const version = (await readFile("minecraft/build.gradle", "utf8")).match(
/version = '([^']+)'/,
)?.[1];
if (!version) throw new Error("Minecraft tools release version is missing");
await new Promise((resolve, reject) => {
const child = spawn(command, windows ? targets : ["./gradlew", ...targets], {
cwd: "minecraft",
@@ -18,15 +28,28 @@ await new Promise((resolve, reject) => {
code === 0 ? resolve() : reject(new Error(`Gradle exited ${code}`)),
);
});
for (const name of ["plugin", "fabric"]) {
const directory = `public/tools/${name === "fabric" ? "mod" : "plugin"}`;
const file = `minegeo-${name}-1.21.11.jar`,
path = `minecraft/${name}/build/libs/${file}`;
if (existsSync(path)) {
for (const [name, kind, minecraftVersion] of [
["plugin", "plugin", "1.21.11"],
["fabric", "fabric", "1.21.11"],
["fabric261", "fabric", "26.1"],
["fabric262", "fabric", "26.2"],
]) {
const directory = `public/tools/${kind === "fabric" ? "mod" : "plugin"}`;
const file = `minegeo-${kind}-${minecraftVersion}.jar`;
const buildRoot = process.env.MINEGEO_JAVA_BUILD_ROOT
? path.join(process.env.MINEGEO_JAVA_BUILD_ROOT, name)
: `minecraft/${name}/build`;
const artifact = path.join(buildRoot, "libs", file);
if (existsSync(artifact)) {
await mkdir(directory, { recursive: true });
await copyFile(path, `${directory}/${file}`);
await copyFile(artifact, `${directory}/${file}`);
const releaseFile =
kind === "plugin"
? `minegeo-plugin-${version}.jar`
: `minegeo-fabric-${minecraftVersion}-${version}.jar`;
await copyFile(artifact, `${directory}/${releaseFile}`);
console.log(
`Updated ${directory}/${file}. Include this JAR in your next commit.`,
`Updated ${directory}/${releaseFile} and its compatibility download.`,
);
}
}

View File

@@ -57,7 +57,8 @@ if (
throw new Error(
"This draft contains accepted contributions. Upload a new capture instead of reprocessing mixed media.",
);
if (dataset.source === "plugin") await prepareAssets();
if (dataset.source === "plugin")
await prepareAssets(input.manifest.minecraftVersion);
// This maintenance command runs with the worker stopped. Compare-and-set
// guards reject intervening publication, ownership of a job, or another repair.

View File

@@ -0,0 +1,205 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { mkdir, readFile, writeFile, copyFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { gunzipSync } from "node:zlib";
import path from "node:path";
import { stripVTControlCharacters } from "node:util";
// Disposable servers listen only on loopback. No fixture uploads or tokens are
// sent to MineGeo. Keep exports/logs locally as compatibility evidence.
const root = path.resolve(".cache/minecraft-compat");
const java = process.env.JAVA_HOME
? path.join(
process.env.JAVA_HOME,
"bin",
process.platform === "win32" ? "java.exe" : "java",
)
: "java";
async function serverJar(version) {
await mkdir(root, { recursive: true });
const file = `${root}/paper-${version}.jar`;
if (version === "1.21.11" && !existsSync(file))
await copyFile(".cache/paper-proof/paper.jar", file);
if (existsSync(file)) return file;
const response = await fetch(
`https://fill.papermc.io/v3/projects/paper/versions/${version}/builds`,
{
headers: {
"User-Agent": "MineGeo/0.1.3 local compatibility verification",
},
},
);
assert.equal(response.status, 200);
const builds = await response.json();
const download = (builds.find((b) => b.channel === "STABLE") ?? builds[0])
.downloads["server:default"];
const bytes = Buffer.from(await (await fetch(download.url)).arrayBuffer());
assert.equal(
createHash("sha256").update(bytes).digest("hex"),
download.checksums.sha256,
);
await writeFile(file, bytes);
return file;
}
function monitor(child, label) {
let output = "";
const waiters = new Set();
let ended = false;
const record = (bytes) => {
output += stripVTControlCharacters(bytes.toString());
for (const w of waiters)
if (w.pattern.test(output.slice(w.start))) {
clearTimeout(w.timer);
waiters.delete(w);
w.resolve();
}
};
child.stdout.on("data", record);
child.stderr.on("data", record);
const exit = new Promise((resolve) =>
child.on("exit", (code) => {
ended = true;
for (const w of waiters) {
clearTimeout(w.timer);
w.reject(new Error(`${label} exited ${code}: ${output.slice(-5000)}`));
}
waiters.clear();
resolve(code);
}),
);
return {
get output() {
return output;
},
exit,
wait(pattern, start = output.length, timeout = 180000) {
return new Promise((resolve, reject) => {
if (pattern.test(output.slice(start))) return resolve();
if (ended)
return reject(
new Error(`${label} already exited: ${output.slice(-5000)}`),
);
const w = {
pattern,
start,
resolve,
reject,
timer: setTimeout(() => {
waiters.delete(w);
reject(
new Error(
`${label} timed out for ${pattern}: ${output.slice(-5000)}`,
),
);
}, timeout),
};
waiters.add(w);
});
},
async command(command, pattern) {
const ready = this.wait(pattern);
child.stdin.write(command + "\n");
await ready;
},
};
}
if (process.argv.includes("--fabric"))
throw new Error(
"This check runs PaperMC servers only; Minecraft clients are disabled.",
);
const selected = process.argv
.find((arg) => arg.startsWith("--version="))
?.split("=")[1];
const matrix = ["1.21.11", "26.1.2", "26.2"].filter(
(v) => !selected || v === selected,
);
for (const version of matrix) {
const directory = `${root}/plugin-${version}-${Date.now()}`;
await mkdir(`${directory}/plugins`, { recursive: true });
await copyFile(await serverJar(version), `${directory}/paper.jar`);
await copyFile(
"public/tools/plugin/minegeo-plugin-0.1.3.jar",
`${directory}/plugins/minegeo.jar`,
);
await writeFile(`${directory}/eula.txt`, "eula=true\n");
await writeFile(
`${directory}/server.properties`,
[
"server-ip=127.0.0.1",
`server-port=25582`,
"online-mode=false",
"level-type=minecraft:flat",
'generator-settings={"layers":[{"block":"minecraft:bedrock","height":1},{"block":"minecraft:dirt","height":2},{"block":"minecraft:grass_block","height":1}],"biome":"minecraft:plains"}',
"generate-structures=false",
"view-distance=3",
"simulation-distance=2",
"max-players=1",
"spawn-protection=0",
].join("\n") + "\n",
);
const server = spawn(java, ["-Xmx1536M", "-jar", "paper.jar", "--nogui"], {
cwd: directory,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
const logs = monitor(server, `Paper ${version}`);
try {
await logs.wait(/Done \(/, 0);
console.log(`Paper ${version} started.`);
await logs.command("version MineGeo", /MineGeo version 0\.1\.3/);
await logs.command(
"minegeo website",
/MineGeo website: https:\/\/minegeo\.vercel\.app/,
);
await logs.command(
"minegeo website http://127.0.0.1:9999",
/website is fixed to https:\/\/minegeo\.vercel\.app/,
);
await logs.command(
"minegeo server play.compat.example",
/Capture server address saved/,
);
await logs.command("minegeo area world -16 -16 16 16", /area selected/);
await logs.command("forceload add -16 -16 15 15", /Marked|already marked/);
await new Promise((r) => setTimeout(r, 5000));
await logs.command(
"minegeo export Compatibility World",
/Export complete:/,
);
const relative = (
await readFile(`${directory}/plugins/MineGeo/latest-export.txt`, "utf8")
).trim();
const manifest = JSON.parse(
await readFile(
`${directory}/plugins/MineGeo/${relative}/manifest.json`,
"utf8",
),
);
assert.equal(manifest.minecraftVersion, version);
assert.equal(manifest.viewpoints.length, 24);
const terrain = JSON.parse(
gunzipSync(
await readFile(
`${directory}/plugins/MineGeo/${relative}/terrain.json.gz`,
),
).toString(),
);
assert.ok(terrain.blocks.length > 0);
await logs.command(
"minegeo upload",
/Create a token in the website dashboard/,
);
console.log(
`PLUGIN ${version} PASSED: fixed endpoint, commands, real export, correct version, ${terrain.blocks.length} blocks.`,
);
} finally {
server.stdin.write("stop\n");
const timeout = setTimeout(() => server.kill(), 20000);
await logs.exit;
clearTimeout(timeout);
await writeFile(`${directory}/server.log`, logs.output);
}
}

View File

@@ -1,10 +1,12 @@
import { createServer } from "node:http";
import { connect } from "node:net";
import { spawn } from "node:child_process";
import { spawn, execFile } from "node:child_process";
import { mkdir, copyFile, writeFile, readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { gunzipSync } from "node:zlib";
import assert from "node:assert/strict";
import path from "node:path";
import { promisify } from "node:util";
import { datasetSchema } from "../shared/protocol";
// Disposable, loopback-only Paper test. No game client is launched.
@@ -102,8 +104,50 @@ const api = createServer(async (req, res) => {
await new Promise<void>((r) => api.listen(0, "127.0.0.1", r));
await writeFile(
`${directory}/plugins/MineGeo/config.yml`,
`website: 'http://127.0.0.1:${(api.address() as any).port}'\ntoken: 'fixture-token'\nserver-name: 'Regression world'\nserver-address: 'play.regression.example.com'\nchunks-per-tick: 2\nviewpoints: 24\nmax-area-size: 2048\n`,
`website: 'http://127.0.0.1:${(api.address() as any).port}'\ntoken: ''\nserver-name: 'Regression world'\nserver-address: 'play.regression.example.com'\nchunks-per-tick: 2\nviewpoints: 24\nmax-area-size: 2048\n`,
);
// Exercise the shared upload transport against the loopback mock separately.
// The deployed plugin always uses its hardcoded production endpoint.
const execute = promisify(execFile);
let exportDir: string;
const pluginJar = path.resolve("public/tools/plugin/minegeo-plugin-0.1.3.jar");
await execute("javac", [
"-cp",
pluginJar,
"-d",
directory,
"tests/java/UploadSmoke.java",
]);
await writeFile(
directory + "/fixture-auth.json",
JSON.stringify({ token: "fixture-token" }),
);
const upload = async (
target: string | null,
expected: RegExp,
fails = false,
) => {
const args = [
"-cp",
directory + path.delimiter + pluginJar,
"UploadSmoke",
"http://127.0.0.1:" + (api.address() as any).port,
exportDir,
directory + "/fixture-auth.json",
];
if (target) args.push(target);
let output = "",
code = 0;
try {
const result = await execute("java", args, { windowsHide: true });
output = result.stdout + result.stderr;
} catch (error: any) {
output = error.stdout + error.stderr;
code = error.code;
}
assert.equal(code === 0, !fails, output);
assert.match(output, expected);
};
const child = spawn("java", ["-Xmx1536M", "-jar", "paper.jar", "--nogui"], {
cwd: directory,
stdio: ["pipe", "pipe", "pipe"],
@@ -165,46 +209,53 @@ const command = async (text: string, pattern: RegExp) => {
child.stdin.write(text + "\n");
await pending;
};
const rconCommand = (text: string, expected: RegExp) => new Promise<void>((resolve, reject) => {
const socket = connect(25581, "127.0.0.1");
let buffer = Buffer.alloc(0);
let authenticated = false;
const timeout = setTimeout(() => finish(new Error(`RCON timed out waiting for ${expected}`)), 10000);
const finish = (error?: Error) => {
clearTimeout(timeout);
socket.destroy();
if (error) reject(error);
else resolve();
};
const packet = (id: number, type: number, body: string) => {
const payload = Buffer.from(body);
const frame = Buffer.alloc(payload.length + 14);
frame.writeInt32LE(payload.length + 10, 0);
frame.writeInt32LE(id, 4);
frame.writeInt32LE(type, 8);
payload.copy(frame, 12);
return frame;
};
socket.on("connect", () => socket.write(packet(1, 3, "minegeo-fixture")));
socket.on("error", finish);
socket.on("data", (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
while (buffer.length >= 4 && buffer.length >= buffer.readInt32LE(0) + 4) {
const length = buffer.readInt32LE(0);
const frame = buffer.subarray(0, length + 4);
buffer = buffer.subarray(length + 4);
const id = frame.readInt32LE(4);
const type = frame.readInt32LE(8);
if (id === -1) return finish(new Error("RCON authentication failed"));
if (id === 1 && type === 2 && !authenticated) {
authenticated = true;
socket.write(packet(2, 2, text));
} else if (id === 2 && expected.test(frame.toString("utf8", 12, length + 2))) {
return finish();
const rconCommand = (text: string, expected: RegExp) =>
new Promise<void>((resolve, reject) => {
const socket = connect(25581, "127.0.0.1");
let buffer = Buffer.alloc(0);
let authenticated = false;
const timeout = setTimeout(
() => finish(new Error(`RCON timed out waiting for ${expected}`)),
10000,
);
const finish = (error?: Error) => {
clearTimeout(timeout);
socket.destroy();
if (error) reject(error);
else resolve();
};
const packet = (id: number, type: number, body: string) => {
const payload = Buffer.from(body);
const frame = Buffer.alloc(payload.length + 14);
frame.writeInt32LE(payload.length + 10, 0);
frame.writeInt32LE(id, 4);
frame.writeInt32LE(type, 8);
payload.copy(frame, 12);
return frame;
};
socket.on("connect", () => socket.write(packet(1, 3, "minegeo-fixture")));
socket.on("error", finish);
socket.on("data", (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
while (buffer.length >= 4 && buffer.length >= buffer.readInt32LE(0) + 4) {
const length = buffer.readInt32LE(0);
const frame = buffer.subarray(0, length + 4);
buffer = buffer.subarray(length + 4);
const id = frame.readInt32LE(4);
const type = frame.readInt32LE(8);
if (id === -1) return finish(new Error("RCON authentication failed"));
if (id === 1 && type === 2 && !authenticated) {
authenticated = true;
socket.write(packet(2, 2, text));
} else if (
id === 2 &&
expected.test(frame.toString("utf8", 12, length + 2))
) {
return finish();
}
}
}
});
});
});
try {
await wait(/Done \(/, 0, 180000);
await command("minegeo help", /MineGeo commands:/);
@@ -227,7 +278,7 @@ try {
let relative = (
await readFile(`${directory}/plugins/MineGeo/latest-export.txt`, "utf8")
).trim();
const exportDir = `${directory}/plugins/MineGeo/${relative}`;
exportDir = `${directory}/plugins/MineGeo/${relative}`;
const local = datasetSchema.parse(
JSON.parse(await readFile(`${exportDir}/manifest.json`, "utf8")),
);
@@ -243,44 +294,46 @@ try {
(p) => p.x >= -32 && p.x < 32 && p.z >= -32 && p.z < 32,
),
);
await command("minegeo upload", /Upload failed: Upload paused/);
await command("minegeo upload", /Upload complete\. Open/);
await upload(null, /Upload paused/, true);
await upload(null, /queueing panorama processing|Already uploaded/);
assert.equal(creates, 1);
assert.equal(finalizes, 1);
assert.equal(heads, 1);
await command("minegeo upload", /Upload complete\. Open/);
await upload(null, /queueing panorama processing|Already uploaded/);
assert.equal(finalizes, 1);
received = Buffer.alloc(0);
expectedServer = "wrong.example.com";
await command(
"minegeo contribute 00000000-0000-4000-8000-000000000024",
await upload(
"00000000-0000-4000-8000-000000000024",
/Wrong capture server/,
true,
);
assert.equal(creates, 1, "Mismatch must stop before creating a draft");
expectedServer = "PLAY.REGRESSION.EXAMPLE.COM.:25565";
await command(
"minegeo contribute 00000000-0000-4000-8000-000000000024",
/Upload complete\. Open/,
await upload(
"00000000-0000-4000-8000-000000000024",
/queueing panorama processing|Already uploaded/,
);
assert.equal(manifest.contributionTo, "00000000-0000-4000-8000-000000000024");
assert.equal(manifest.viewpoints.length, 12);
assert.equal(creates, 2);
expectedServer = "wrong.example.com";
await command(
"minegeo contribute 00000000-0000-4000-8000-000000000024",
await upload(
"00000000-0000-4000-8000-000000000024",
/Wrong capture server/,
true,
);
assert.equal(creates, 2, "A cached selection must recheck the server");
expectedServer = null;
await command(
"minegeo contribute 00000000-0000-4000-8000-000000000024",
/Upload complete\. Open/,
await upload(
"00000000-0000-4000-8000-000000000024",
/queueing panorama processing|Already uploaded/,
);
assert.equal(creates, 2, "Cleared server permits an idempotent retry");
assert.equal(finalizes, 2);
await command(
"minegeo contribute 00000000-0000-4000-8000-000000000024",
/Upload complete\. Open/,
await upload(
"00000000-0000-4000-8000-000000000024",
/queueing panorama processing|Already uploaded/,
);
assert.equal(creates, 2);
await command("minegeo export Random Positions", /Export complete:/);
@@ -321,7 +374,7 @@ try {
"repeated upload is idempotent",
"cancellation preserves completed export",
"24 random positions change on the next export",
"contribution command caps at 12 and retries idempotently",
"shared upload client caps contributions at 12 and retries idempotently",
"wrong server blocked before upload, including cached retry",
"owner override/default port and cleared address supported",
],

View File

@@ -7,7 +7,8 @@ import { assertTerrainImage } from "../renderer/image-validation.mjs";
// Exercise the product's rendering worker without launching a Minecraft server
// or driving the website. A cached real export can also be supplied as argv[2].
const output = ".cache/renderer-regression";
let minecraftVersion = process.argv[3] || "1.21.11";
const output = `.cache/renderer-regression-${minecraftVersion}`;
const snapshot = {
bounds: { minX: -24, minZ: -24, maxX: 24, maxZ: 24 },
coverage: [],
@@ -69,11 +70,12 @@ let points = [
[0.5, 18.5],
].map(([x, z], i) => ({ id: `p${i}`, x, y: 66.62, z }));
let source = "grass, dirt, water, tree and building regression fixture";
if (process.argv[2]) {
if (process.argv[2] && process.argv[2] !== "--fixture") {
const directory = process.argv[2];
const manifest = JSON.parse(
await readFile(`${directory}/manifest.json`, "utf8"),
);
minecraftVersion = manifest.minecraftVersion;
terrain = JSON.parse(
gunzipSync(await readFile(`${directory}/terrain.json.gz`)).toString(),
);
@@ -81,7 +83,10 @@ if (process.argv[2]) {
source = "cached Paper export (no server started)";
}
await mkdir(output, { recursive: true });
const report = await renderSnapshot(terrain, points, output, { size: 256 });
const report = await renderSnapshot(terrain, points, output, {
size: 256,
minecraftVersion,
});
assert.ok(report.meshes > 0, "terrain must produce meshes");
assert.ok(report.emittedFaces > 0, "terrain must produce faces");
assert.ok(report.hiddenFaces > 0, "interior terrain faces must be culled");
@@ -106,12 +111,16 @@ assert.ok(
"blocks without explicit default properties must appear on the aerial map",
);
const mapPixel = (x, z) => {
const pixelX = Math.floor(((x - snapshot.bounds.minX + 0.5) / 48) * mapInfo.width);
const pixelY = Math.floor(((z - snapshot.bounds.minZ + 0.5) / 48) * mapInfo.height);
const pixelX = Math.floor(
((x - snapshot.bounds.minX + 0.5) / 48) * mapInfo.width,
);
const pixelY = Math.floor(
((z - snapshot.bounds.minZ + 0.5) / 48) * mapInfo.height,
);
const offset = (pixelY * mapInfo.width + pixelX) * mapInfo.channels;
return mapPixels.subarray(offset, offset + 3);
};
if (!process.argv[2]) {
if (!process.argv[2] || process.argv[2] === "--fixture") {
const shadedGrass = mapPixel(10, 9);
const sunlitGrass = mapPixel(10, -9);
assert.ok(