New modding system

This commit is contained in:
2026-09-26 17:33:07 -03:00
parent 86d5c39eab
commit 243ff52063
25 changed files with 1089 additions and 512 deletions
+201
View File
@@ -0,0 +1,201 @@
// .bmod files: a built mod in one zip, what servers load from server_mods/ and send to players. see "Mod files" in
// MODS.md. the same code reads them on the server and in the browser, so it only works on bytes
import { unzipSync, zipSync } from "fflate";
import type { ModelJson } from "./block_models.ts";
import {
type BlockJson,
ID_PATTERN,
type ItemJson,
type ManifestJson,
type OreJson,
type RecipeJson,
validate_block,
validate_item,
validate_manifest,
validate_model,
validate_ore,
validate_recipe,
} from "./mod_data.ts";
import type { ModData } from "./mod_loader.ts";
export const BMOD_EXTENSION = ".bmod";
// what a .bmod can hold at most unzipped, so a small file can't unzip into gigabytes
const MAX_FILES = 4096;
const MAX_UNZIPPED_BYTES = 64 * 1024 * 1024;
// written into every entry, so packing the same mod twice gives the same bytes and the same hash
const FIXED_TIME = new Date(Date.UTC(2020, 0, 1));
export type ScriptSide = "server" | "client" | "worldgen";
export const SCRIPT_SIDES: ScriptSide[] = ["server", "client", "worldgen"];
export interface Bmod {
manifest: ManifestJson;
data: ModData;
// bundled javascript, one module per side
scripts: Partial<Record<ScriptSide, string>>;
// png bytes by texture id, like copper_tools:copper_block
textures: Map<string, Uint8Array>;
credits?: string;
}
export class BmodError extends Error {
constructor(file: string, problems: string[]) {
super(`${file}: ${problems.join("; ")}`);
this.name = "BmodError";
}
}
// the zip's layout:
// manifest.json the mod's manifest, with "scripts" and "credits" pointing into the zip
// data.json every block, model, item, recipe and ore
// scripts/<side>.js
// textures/<name>.png the texture <mod id>:<name>
// credits.md
export function write_bmod(bmod: Bmod): Uint8Array<ArrayBuffer> {
const text = (value: string) => new TextEncoder().encode(value);
const manifest: ManifestJson = { ...bmod.manifest, scripts: {} };
delete manifest.credits;
const files: Record<string, Uint8Array> = {};
for (const side of SCRIPT_SIDES) {
const code = bmod.scripts[side];
if (code !== undefined) {
files[`scripts/${side}.js`] = text(code);
manifest.scripts![side] = `scripts/${side}.js`;
}
}
if (Object.keys(manifest.scripts!).length === 0) delete manifest.scripts;
if (bmod.credits !== undefined) {
files["credits.md"] = text(bmod.credits);
manifest.credits = "credits.md";
}
for (const [id, png] of bmod.textures) {
files[`textures/${id.split(":")[1]}.png`] = png;
}
files["manifest.json"] = text(JSON.stringify(manifest, null, "\t"));
files["data.json"] = text(JSON.stringify(bmod.data));
// sorted, so the order doesn't depend on how the files were collected
const sorted: Record<string, Uint8Array> = {};
for (const name of Object.keys(files).sort()) sorted[name] = files[name];
return zipSync(sorted, { mtime: FIXED_TIME, level: 9 }) as Uint8Array<ArrayBuffer>;
}
// opens and checks a .bmod. file is only for error messages. throws BmodError listing what's wrong
export function read_bmod(bytes: Uint8Array, file: string): Bmod {
let files: Record<string, Uint8Array>;
let count = 0;
let total = 0;
try {
files = unzipSync(bytes, {
filter(entry) {
count += 1;
total += entry.originalSize;
if (count > MAX_FILES || total > MAX_UNZIPPED_BYTES) {
throw new Error(`unzips to more than ${MAX_FILES} files or ${MAX_UNZIPPED_BYTES >> 20} MB`);
}
return !entry.name.endsWith("/");
},
});
} catch (e) {
throw new BmodError(file, [`isn't a readable .bmod: ${(e as Error).message}`]);
}
const problems: string[] = [];
const json = (name: string): unknown => {
const content = files[name];
if (!content) {
problems.push(`${name} is missing`);
return undefined;
}
try {
return JSON.parse(new TextDecoder().decode(content));
} catch (e) {
problems.push(`${name} isn't valid json: ${(e as Error).message}`);
return undefined;
}
};
const text = (name: string) => files[name] && new TextDecoder().decode(files[name]);
const manifest = json("manifest.json") as ManifestJson | undefined;
const raw_data = json("data.json") as Partial<ModData> | undefined;
if (!manifest || !raw_data) throw new BmodError(file, problems);
for (const problem of validate_manifest(manifest, manifest.id)) problems.push(`manifest.json: ${problem}`);
if (problems.length > 0) throw new BmodError(file, problems);
const mod = manifest.id;
const data: ModData = {
blocks: checked(raw_data.blocks, "blocks", validate_block, problems) as BlockJson[],
models: checked(raw_data.models, "models", validate_model, problems) as ModelJson[],
items: checked(raw_data.items, "items", validate_item, problems) as ItemJson[],
recipes: checked(raw_data.recipes, "recipes", validate_recipe, problems) as RecipeJson[],
ores: checked(raw_data.ores, "ores", validate_ore, problems) as OreJson[],
};
// a mod only registers ids in its own namespace
for (const { id } of [...data.blocks, ...data.models, ...data.items]) {
if (typeof id === "string" && id.split(":")[0] !== mod) problems.push(`${id} isn't in this mod's namespace`);
}
const scripts: Bmod["scripts"] = {};
for (const [side, path] of Object.entries(manifest.scripts ?? {})) {
const code = text(path);
if (!SCRIPT_SIDES.includes(side as ScriptSide) || code === undefined) {
problems.push(`manifest.json: scripts.${side} ${path} isn't in the file`);
} else {
scripts[side as ScriptSide] = code;
}
}
const credits = manifest.credits === undefined ? undefined : text(manifest.credits);
if (manifest.credits !== undefined && credits === undefined) {
problems.push(`manifest.json: credits ${manifest.credits} isn't in the file`);
}
const textures = new Map<string, Uint8Array>();
for (const [name, content] of Object.entries(files)) {
const match = name.match(/^textures\/(.+)\.png$/);
if (!match) continue;
const id = `${mod}:${match[1]}`;
const size = png_size(content);
if (!ID_PATTERN.test(id)) {
problems.push(`${name}: texture names must be a-z, 0-9 and _`);
} else if (!size) {
problems.push(`${name}: isn't a png`);
} else if (size.width !== 16 || size.height !== 16) {
problems.push(`${name}: is ${size.width}×${size.height}, textures must be 16×16`);
} else {
textures.set(id, content);
}
}
if (problems.length > 0) throw new BmodError(file, problems);
return { manifest, data, scripts, textures, credits };
}
// the same mod with its server script taken out, what players get
export function client_copy(bmod: Bmod): Bmod {
const scripts = { ...bmod.scripts };
delete scripts.server;
return { ...bmod, scripts };
}
function checked(list: unknown, name: string, validate: (json: unknown) => string[], problems: string[]): unknown[] {
if (list === undefined) return [];
if (!Array.isArray(list)) {
problems.push(`data.json: ${name} must be a list`);
return [];
}
list.forEach((entry, i) => {
for (const problem of validate(entry)) problems.push(`data.json: ${name}[${i}]: ${problem}`);
});
return list;
}
// reads the size out of the png header instead of decoding the image
export function png_size(bytes: Uint8Array): { width: number; height: number } | undefined {
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
if (bytes.length < 24 || !signature.every((b, i) => bytes[i] === b)) return undefined;
const view = new DataView(bytes.buffer, bytes.byteOffset);
return { width: view.getUint32(16), height: view.getUint32(20) };
}