// .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>; // png bytes by texture id, like copper_tools:copper_block textures: Map; 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/.js // textures/.png the texture : // credits.md export function write_bmod(bmod: Bmod): Uint8Array { const text = (value: string) => new TextEncoder().encode(value); const manifest: ManifestJson = { ...bmod.manifest, scripts: {} }; delete manifest.credits; const files: Record = {}; 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 = {}; for (const name of Object.keys(files).sort()) sorted[name] = files[name]; return zipSync(sorted, { mtime: FIXED_TIME, level: 9 }) as Uint8Array; } // 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; 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 | 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(); 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) }; }