New modding system
This commit is contained in:
+201
@@ -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) };
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// checks between mods: dependencies, load order, and ids one mod uses from another. pure, so the build, check-mods
|
||||
// and the server (on .bmod files) all use it
|
||||
import type { BlockJson, ItemJson, OreJson, RecipeJson } from "./mod_data.ts";
|
||||
import { BUILTIN_MODELS, type ModelJson } from "./block_models.ts";
|
||||
|
||||
export interface ModReport {
|
||||
id: string;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface LoadedMod {
|
||||
id: string;
|
||||
dir: string;
|
||||
report: ModReport;
|
||||
manifest?: Record<string, unknown>;
|
||||
blocks: { file: string; json: BlockJson }[];
|
||||
models: { file: string; json: ModelJson }[];
|
||||
items: { file: string; json: ItemJson }[];
|
||||
recipes: { file: string; json: RecipeJson }[];
|
||||
ores: { file: string; json: OreJson }[];
|
||||
textures: string[];
|
||||
// absolute paths by texture id
|
||||
texture_files: Map<string, string>;
|
||||
}
|
||||
|
||||
// dependencies before the mods that need them, otherwise alphabetical
|
||||
export function load_order(mods: LoadedMod[]): LoadedMod[] {
|
||||
const by_id = new Map(mods.map((mod) => [mod.id, mod]));
|
||||
const ordered: LoadedMod[] = [];
|
||||
const state = new Map<string, "visiting" | "done">();
|
||||
|
||||
const visit = (mod: LoadedMod, path: string[]) => {
|
||||
if (state.get(mod.id) === "done") return;
|
||||
if (state.get(mod.id) === "visiting") {
|
||||
throw new Error(`mods depend on each other in a circle: ${[...path, mod.id].join(" -> ")}`);
|
||||
}
|
||||
state.set(mod.id, "visiting");
|
||||
const dependencies = ((mod.manifest?.dependencies ?? []) as { id: string }[]).map((d) => d.id).sort();
|
||||
for (const dependency of dependencies) {
|
||||
const other = by_id.get(dependency);
|
||||
if (other) visit(other, [...path, mod.id]);
|
||||
}
|
||||
state.set(mod.id, "done");
|
||||
ordered.push(mod);
|
||||
};
|
||||
|
||||
for (const mod of [...mods].sort((a, b) => a.id.localeCompare(b.id))) {
|
||||
visit(mod, []);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
// engine_textures are the engine: texture ids, for telling whether a texture a mod uses exists
|
||||
export function check_references(mods: LoadedMod[], engine_textures: Iterable<string>) {
|
||||
const block_owners = new Map<string, string[]>();
|
||||
const item_owners = new Map<string, string[]>();
|
||||
const model_owners = new Map<string, string[]>();
|
||||
const add = (map: Map<string, string[]>, id: string, mod: string) => map.set(id, [...(map.get(id) ?? []), mod]);
|
||||
|
||||
const textures = new Set<string>(engine_textures);
|
||||
|
||||
for (const mod of mods) {
|
||||
for (const { json } of mod.blocks) {
|
||||
add(block_owners, json.id, mod.id);
|
||||
if (json.item !== false) add(item_owners, json.id, mod.id);
|
||||
}
|
||||
for (const { json } of mod.items) add(item_owners, json.id, mod.id);
|
||||
for (const { json } of mod.models) add(model_owners, json.id, mod.id);
|
||||
for (const texture of mod.textures) textures.add(texture);
|
||||
}
|
||||
|
||||
const mod_ids = new Set(mods.map((mod) => mod.id));
|
||||
|
||||
for (const mod of mods) {
|
||||
const { errors, warnings } = mod.report;
|
||||
const dependencies = new Set(
|
||||
((mod.manifest?.dependencies ?? []) as { id: string }[]).map((dep) => dep.id),
|
||||
);
|
||||
for (const dep of dependencies) {
|
||||
if (!mod_ids.has(dep)) errors.push(`manifest.json: depends on "${dep}", which isn't installed`);
|
||||
}
|
||||
|
||||
const uses = (file: string, id: string, what: "block" | "item" | "texture" | "model") => {
|
||||
const namespace = id.split(":")[0];
|
||||
if (namespace !== mod.id && namespace !== "engine" && !dependencies.has(namespace)) {
|
||||
warnings.push(`${file}: uses ${id} but doesn't list "${namespace}" in dependencies`);
|
||||
}
|
||||
if (what === "model") {
|
||||
if (!BUILTIN_MODELS[id] && !model_owners.has(id)) errors.push(`${file}: model ${id} doesn't exist`);
|
||||
} else if (what === "texture") {
|
||||
if (!textures.has(id)) warnings.push(`${file}: texture ${id} doesn't exist, it will show as missing`);
|
||||
} else if (!(what === "block" ? block_owners : item_owners).has(id)) {
|
||||
errors.push(`${file}: ${what} ${id} doesn't exist`);
|
||||
}
|
||||
};
|
||||
|
||||
for (const { file, json } of mod.blocks) {
|
||||
if ((block_owners.get(json.id)?.length ?? 0) > 1) {
|
||||
errors.push(`${file}: block ${json.id} is also defined by ${block_owners.get(json.id)!.join(", ")}`);
|
||||
}
|
||||
for (const variant of [json, ...Object.values(json.variants ?? {})]) {
|
||||
if (variant.model) uses(file, variant.model, "model");
|
||||
const textures = variant.textures ?? {};
|
||||
for (const texture of typeof textures === "string" ? [textures] : Object.values(textures)) {
|
||||
uses(file, texture, "texture");
|
||||
}
|
||||
}
|
||||
if (json.drops) uses(file, json.drops, "item");
|
||||
}
|
||||
for (const { file, json } of mod.models) {
|
||||
if ((model_owners.get(json.id)?.length ?? 0) > 1 || BUILTIN_MODELS[json.id]) {
|
||||
errors.push(`${file}: model ${json.id} is defined more than once`);
|
||||
}
|
||||
for (const texture of Object.values(json.textures ?? {})) {
|
||||
if (!texture.startsWith("#")) uses(file, texture, "texture");
|
||||
}
|
||||
for (const element of json.elements) {
|
||||
for (const face of Object.values(element.faces)) {
|
||||
if (face && !face.texture.startsWith("#")) uses(file, face.texture, "texture");
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const { file, json } of mod.items) {
|
||||
if ((item_owners.get(json.id)?.length ?? 0) > 1) {
|
||||
errors.push(`${file}: item ${json.id} is also defined by ${item_owners.get(json.id)!.join(", ")}`);
|
||||
}
|
||||
uses(file, json.texture, "texture");
|
||||
if (json.places) uses(file, json.places, "block");
|
||||
}
|
||||
for (const { file, json } of mod.recipes) {
|
||||
switch (json.type) {
|
||||
case "shaped":
|
||||
for (const id of Object.values(json.key)) uses(file, id, "item");
|
||||
uses(file, json.result.id, "item");
|
||||
break;
|
||||
case "furnace":
|
||||
uses(file, json.input, "item");
|
||||
uses(file, json.output.id, "item");
|
||||
break;
|
||||
case "fuel":
|
||||
uses(file, json.item, "item");
|
||||
break;
|
||||
case "smithing":
|
||||
uses(file, json.tool, "item");
|
||||
uses(file, json.material.id, "item");
|
||||
if (json.addition) uses(file, json.addition, "item");
|
||||
uses(file, json.result, "item");
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const { file, json } of mod.ores) {
|
||||
uses(file, json.id, "block");
|
||||
uses(file, json.replaces, "block");
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-19
@@ -17,33 +17,22 @@ import type { ModelJson } from "./block_models.ts";
|
||||
export interface ModData {
|
||||
blocks: BlockJson[];
|
||||
// block models, see common/block_models.ts
|
||||
models?: ModelJson[];
|
||||
models: ModelJson[];
|
||||
items: ItemJson[];
|
||||
recipes: RecipeJson[];
|
||||
ores: OreJson[];
|
||||
}
|
||||
|
||||
// a mod as the server lists it to clients, in load order. paths are relative to the server's root
|
||||
// a mod as the server lists it to clients, in load order: the .bmod players download, without its server script
|
||||
export interface ModListing {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
// short hash of everything public, the folder it's served from
|
||||
hash: string;
|
||||
data: string;
|
||||
client?: string;
|
||||
worldgen?: string;
|
||||
// the manifest's credits file, markdown, shown on the credits screen
|
||||
credits?: string;
|
||||
// sha-256 in hex of each file, clients check these before using them
|
||||
sha256: { data: string; client?: string; worldgen?: string; credits?: string };
|
||||
}
|
||||
|
||||
// the texture atlas with every mod's textures, built by the server
|
||||
export interface AtlasListing {
|
||||
png: string;
|
||||
json: string;
|
||||
sha256: { png: string; json: string };
|
||||
// sha-256 in hex of the .bmod, clients check it before using anything in it
|
||||
sha256: string;
|
||||
// where it's served from, relative to the server's root. named by its hash, so it never changes
|
||||
file: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export class RecipeBook {
|
||||
@@ -70,7 +59,7 @@ export function register_mod_data(mods: { id: string; data: ModData }[]): Recipe
|
||||
throw new ModLoadError(id, message);
|
||||
};
|
||||
try {
|
||||
for (const model of data.models ?? []) {
|
||||
for (const model of data.models) {
|
||||
EverythingRegistry.register<ModelJson>("models", model.id, model);
|
||||
}
|
||||
for (const json of data.blocks) {
|
||||
|
||||
+3
-4
@@ -1,13 +1,13 @@
|
||||
// messages sent between the client and the server, as json over a websocket
|
||||
import type { Faces } from "./constants.ts";
|
||||
import type { ItemData } from "./inventory.ts";
|
||||
import type { AtlasListing, ModListing } from "./mod_loader.ts";
|
||||
import type { ModListing } from "./mod_loader.ts";
|
||||
|
||||
export const AIR_ID = "bworld:air";
|
||||
|
||||
// bump when a client and server of different versions can't play together.
|
||||
// the server rejects a different version before the client downloads anything
|
||||
export const PROTOCOL_VERSION = 3;
|
||||
export const PROTOCOL_VERSION = 4;
|
||||
|
||||
export interface PlayerInfo {
|
||||
id: string;
|
||||
@@ -83,8 +83,7 @@ export type ServerMessage =
|
||||
type: "welcome";
|
||||
protocol: number;
|
||||
seed: string;
|
||||
atlas: AtlasListing;
|
||||
// in load order, the client loads them before joining the world
|
||||
// in load order, the client downloads and loads them before joining the world
|
||||
mods: ModListing[];
|
||||
}
|
||||
// the connection is closed right after
|
||||
|
||||
Reference in New Issue
Block a user