Files
bworld/common/bmod.ts
T
2026-09-26 17:33:07 -03:00

202 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// .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) };
}