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
+16 -170
View File
@@ -1,11 +1,8 @@
// deno task check-mods
// validates every mod in mods/: manifests, data files, textures, references between them, and typechecks scripts
import {
BlockJson,
FORMAT_VERSION,
ItemJson,
OreJson,
RecipeJson,
validate_block,
validate_item,
validate_manifest,
@@ -13,32 +10,22 @@ import {
validate_ore,
validate_recipe,
} from "$/common/mod_data.ts";
import { BUILTIN_MODELS, type ModelJson } from "$/common/block_models.ts";
import { check_references, load_order, type LoadedMod, type ModReport } from "$/common/mod_check.ts";
import { png_size } from "$/common/bmod.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>;
}
export { load_order, type LoadedMod, type ModReport };
// the engine's own textures, named engine:<file>
export const ENGINE_TEXTURE_DIR = "assets/sprites/textures";
// engine:missing is drawn by code, the rest are files
export function engine_texture_ids(): string[] {
return [
"engine:missing",
...walk(ENGINE_TEXTURE_DIR, ".png").map((file) => `engine:${file.replace(/\.png$/, "")}`),
];
}
// loads and checks every mod without typechecking, which is slow. the build uses this
export function load_and_check(mods_dir = "mods"): LoadedMod[] {
const mods: LoadedMod[] = [];
@@ -47,37 +34,10 @@ export function load_and_check(mods_dir = "mods"): LoadedMod[] {
mods.push(load_mod(mods_dir, entry.name));
}
}
check_references(mods);
check_references(mods, engine_texture_ids());
return mods;
}
// 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;
}
export async function check_mods(mods_dir = "mods", options = { typecheck: true }): Promise<ModReport[]> {
const mods = load_and_check(mods_dir);
try {
@@ -177,7 +137,7 @@ function load_mod(mods_dir: string, id: string): LoadedMod {
const texture_id = `${id}:${file.replace(/\.png$/, "").replaceAll("/", "_")}`;
mod.textures.push(texture_id);
mod.texture_files.set(texture_id, `${dir}/textures/${file}`);
const size = png_size(`${dir}/textures/${file}`);
const size = png_size(Deno.readFileSync(`${dir}/textures/${file}`));
if (!size) {
error(`textures/${file}: isn't a png`);
} else if (size.width !== 16 || size.height !== 16) {
@@ -188,121 +148,16 @@ function load_mod(mods_dir: string, id: string): LoadedMod {
return mod;
}
function check_references(mods: LoadedMod[]) {
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>();
for (const file of walk(ENGINE_TEXTURE_DIR, ".png")) {
textures.add(`engine:${file.replace(/\.png$/, "")}`);
}
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");
}
}
}
async function typecheck_scripts(mod: LoadedMod) {
const scripts = Object.values((mod.manifest?.scripts ?? {}) as Record<string, string>)
.filter((path) => typeof path === "string" && exists(`${mod.dir}/${path}`))
.map((path) => `${mod.dir}/${path}`);
if (scripts.length === 0) return;
// a mod with its own deno.json is its own deno project, see pack_mod.ts
const config = ["deno.json", "deno.jsonc"].map((name) => `${mod.dir}/${name}`).find(exists) ?? "deno.json";
const output = await new Deno.Command(Deno.execPath(), {
args: ["check", "--config", "deno.json", ...scripts],
args: ["check", "--config", config, ...scripts],
stdout: "piped",
stderr: "piped",
env: { NO_COLOR: "1" },
@@ -361,15 +216,6 @@ function exists(path: string) {
}
}
// reads the size out of the png header instead of decoding the image
function png_size(path: string): { width: number; height: number } | undefined {
const bytes = Deno.readFileSync(path);
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) };
}
function is_object(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+101
View File
@@ -0,0 +1,101 @@
// deno task pack-mod mods/copper_tools [copper_tools.bmod]
// builds a mod folder into a .bmod: its data merged into data.json, each script bundled into one module, and its
// textures and credits. deno task build does this for every mod in mods/, see "Mod files" in MODS.md
import { basename, dirname, resolve } from "@std/path";
import { type Bmod, BMOD_EXTENSION, SCRIPT_SIDES, type ScriptSide, write_bmod } from "$/common/bmod.ts";
import type { ManifestJson } from "$/common/mod_data.ts";
import { load_and_check, type LoadedMod } from "./check_mods.ts";
// what each script is bundled for: the server runs in deno, clients in browsers, worldgen in both
const PLATFORMS: Record<ScriptSide, "deno" | "browser"> = { server: "deno", client: "browser", worldgen: "browser" };
// a mod that passed check-mods, as .bmod bytes
export async function pack_mod(mod: LoadedMod): Promise<Uint8Array<ArrayBuffer>> {
const manifest = mod.manifest as unknown as ManifestJson;
const scripts: Bmod["scripts"] = {};
for (const side of SCRIPT_SIDES) {
const entry = manifest.scripts?.[side];
if (entry) scripts[side] = await bundle_script(mod.dir, `${mod.dir}/${entry}`, PLATFORMS[side]);
}
const textures = new Map<string, Uint8Array>();
for (const [id, path] of [...mod.texture_files].sort(([a], [b]) => a.localeCompare(b))) {
textures.set(id, Deno.readFileSync(path));
}
return write_bmod({
manifest,
data: {
blocks: mod.blocks.map((b) => b.json),
models: mod.models.map((m) => m.json),
items: mod.items.map((i) => i.json),
recipes: mod.recipes.map((r) => r.json),
ores: mod.ores.map((o) => o.json),
},
scripts,
textures,
credits: manifest.credits ? Deno.readTextFileSync(`${mod.dir}/${manifest.credits}`) : undefined,
});
}
// a mod is a deno project: with its own deno.json its scripts are bundled with it (its imports, npm packages and
// so on), otherwise with the deno.json of the folder the build runs in
async function bundle_script(mod_dir: string, entry: string, platform: "deno" | "browser"): Promise<string> {
const config = ["deno.json", "deno.jsonc"].map((name) => `${mod_dir}/${name}`).find(exists);
if (!config) {
const result = await Deno.bundle({ entrypoints: [entry], platform, write: false, minify: false });
if (!result.success || !result.outputFiles?.length) {
throw new Error(`Couldn't bundle ${entry}:\n${result.errors.map((e) => e.text).join("\n")}`);
}
return result.outputFiles[0].text();
}
const output = await Deno.makeTempFile({ suffix: ".js" });
try {
const result = await new Deno.Command(Deno.execPath(), {
args: ["bundle", "--config", config, "--platform", platform, "--output", output, entry],
stdout: "piped",
stderr: "piped",
env: { NO_COLOR: "1" },
}).output();
if (!result.success) {
throw new Error(`Couldn't bundle ${entry}:\n${new TextDecoder().decode(result.stderr).trim()}`);
}
return Deno.readTextFileSync(output);
} finally {
Deno.removeSync(output);
}
}
function exists(path: string) {
try {
Deno.statSync(path);
return true;
} catch {
return false;
}
}
if (import.meta.main) {
const [dir, out] = Deno.args;
if (!dir) {
console.error("usage: deno task pack-mod <mod folder> [output.bmod]");
Deno.exit(1);
}
// its siblings are checked too, so references to mods it depends on can be checked
const folder = resolve(dir);
const mod = load_and_check(dirname(folder)).find((m) => resolve(m.dir) === folder);
if (!mod) {
console.error(`${dir} isn't a mod folder`);
Deno.exit(1);
}
if (mod.report.errors.length > 0) {
console.error(`${mod.id} has errors (deno task check-mods for details):`);
for (const error of mod.report.errors) console.error(` ${error}`);
Deno.exit(1);
}
const file = out ?? `${basename(folder)}${BMOD_EXTENSION}`;
const bytes = await pack_mod(mod);
Deno.writeFileSync(file, bytes);
console.log(`Packed ${mod.id} into ${file} (${(bytes.length / 1024).toFixed(1)} KB)`);
}