harden blockstate.js

This commit is contained in:
2026-07-18 23:37:39 -04:00
parent e2b0b6ab7b
commit a0b28c4aaf
2 changed files with 188 additions and 130 deletions

186
src/blockstate.js Normal file
View File

@@ -0,0 +1,186 @@
/**
* A dictionary representing the exact properties of a block in the world.
* All property values must be normalized to strings (e.g., { "facing": "north", "waterlogged": "false" }).
* @typedef {Object<string, string>} BlockStateMap
*/
/**
* A Minecraft block model application definition.
* @typedef {Object} BlockModel
* @property {string} model - The namespace ID of the model (e.g., "minecraft:block/stone").
* @property {number} [x] - X-axis rotation in degrees (defaults to 0).
* @property {number} [y] - Y-axis rotation in degrees (defaults to 0).
* @property {boolean} [uvlock] - Whether to lock UV coordinates during rotation (defaults to false).
* @property {number} [weight] - Selection weight for randomized model arrays (defaults to 1).
*/
/**
* A JIT-compiled function that resolves a block state and a spatial hash into an array of models.
* @typedef {function(BlockStateMap, number): BlockModel[]} ResolverFunction
*/
/**
* Renders a standard comma-separated variant key into a boolean JavaScript condition.
* @param {string} key - The variant key (e.g., "facing=north,half=top").
* @returns {string} The compiled JavaScript condition.
*/
function renderVariantCondition(key) {
return key.split(',')
.map(it => it.split("="))
.map(([n, v]) => `state[${JSON.stringify(n)}] === ${JSON.stringify(v)}`)
.join(" && ");
}
/**
* Renders a multipart 'when' condition (including OR/AND arrays) into a boolean JavaScript condition.
* @param {Object} [when] - The 'when' condition object from a multipart definition.
* @returns {string} The compiled JavaScript condition.
*/
function renderWhenCondition(when) {
if (!when) return "true";
if (when["OR"]) {
return when["OR"]
.map(it => `(${renderWhenCondition(it)})`)
.join(" || ");
}
if (when["AND"]) {
return when["AND"]
.map(it => `(${renderWhenCondition(it)})`)
.join(" && ");
}
if (Object.entries(when).length === 0) return 'true';
return Object.entries(when)
.map(([key, vals]) => vals.toString().split('|')
.map(val => `state[${JSON.stringify(key)}] === ${JSON.stringify(val)}`)
.join(' || '))
.map(it => `(${it})`)
.join(' && ');
}
/**
* Renders a model application definition (single or weighted array) into a JavaScript return string.
* @param {BlockModel|BlockModel[]} data - The 'apply' property of a blockstate variant or multipart.
* @returns {string} The compiled JavaScript return value.
*/
function renderApply(data) {
if (Array.isArray(data)) {
const parts = [];
let total = 0.0;
for (const {weight = 1.0} of data) total += weight;
let accum = 0.0;
for (const elem of data) {
const {weight = 1.0} = elem;
accum += weight;
if (accum === total) {
parts.push(JSON.stringify(elem));
} else {
parts.push(`h < ${accum} ? ${JSON.stringify(elem)} : `);
}
}
return `(h => ${parts.join('')})(Math.abs(hash) % ${total})`;
} else {
return JSON.stringify(data);
}
}
/**
* Compiles a raw Minecraft blockstate JSON object into the source code for a resolver function.
* @param {Object} data - The parsed blockstate JSON (containing either 'variants' or 'multipart').
* @returns {string} The raw JavaScript function body.
*/
function renderBlockstateResolver(data) {
const {variants, multipart} = data;
if (variants) {
let cases = [];
for (const [key, apply] of Object.entries(variants)) {
if (!key || key === "normal") continue;
cases.push(`if (${(renderVariantCondition(key))}) return [${(renderApply(apply))}];`);
}
let fallback = variants[""] ?? variants["normal"] ?? {"model": ":invalid"};
cases.push(`return [${renderApply(fallback)}];`);
return cases.join('else ');
} else if (multipart) {
const parts = [];
parts.push('const parts = [];');
for (const {when, apply} of multipart) {
parts.push(`if (${renderWhenCondition(when)}) parts.push(${renderApply(apply)});`);
}
parts.push("return parts;");
return parts.join('');
} else {
console.warn('invalid blockstate', data);
return 'return [{"model": ":invalid"}];';
}
}
/**
* Manages the fetching, compilation, and caching of JIT-compiled blockstate resolvers.
*/
export class BlockstateResolver {
constructor() {
/** @type {Map<string, ResolverFunction>} */
this.resolvers = new Map();
// Define default fallback resolvers
this.resolvers.set(':pending', (state, hash) => [{"model": ":pending"}]);
this.resolvers.set(':invalid', (state, hash) => [{"model": ":invalid"}]);
/** @type {Map<string, Promise<void>>} */
this.pending = new Map();
/**
* Callback fired when a pending blockstate finishes downloading and compiling.
* @type {((id: string, fn: ResolverFunction) => void) | undefined}
*/
this.onResolve = undefined;
}
/**
* Retrieves the compiled resolver for a given block ID.
* Triggers a network fetch and returns a `:pending` fallback if the resolver is not yet cached.
* @param {string} id - The block identifier (e.g., "minecraft:stone" or "stone").
* @returns {ResolverFunction} The JIT-compiled resolver function.
*/
getResolver(id) {
id = id.includes(':') ? id : `minecraft:${id}`;
if (this.resolvers.has(id)) {
return this.resolvers.get(id);
}
if (!this.pending.has(id)) {
const [space, name] = id.split(':');
this.pending.set(id, fetch(`assets/${space}/blockstates/${name}.json`)
.then(res => res.json())
.then(data => {
const source = renderBlockstateResolver(data);
/** @type {ResolverFunction} */
const resolver = new Function(`return function (state, hash) { ${source} }`)();
this.resolvers.set(id, resolver);
this.pending.delete(id);
if (this.onResolve) {
this.onResolve(id, resolver);
}
})
.catch(reason => {
this.resolvers.set(id, this.resolvers.get(':invalid'));
this.pending.delete(id);
console.log(`failed to resolve blockstate ${id}: ${reason}`);
})
);
}
return this.resolvers.get(':pending');
}
}

View File

@@ -1,3 +1,5 @@
import {BlockstateResolver} from "./blockstate";
export class Renderer {
constructor() {
this.canvas = document.createElement('canvas');
@@ -7,136 +9,6 @@ export class Renderer {
}
}
function renderVariantCondition(key) {
return key.split(',')
.map(it => it.split("="))
.map(([n, v]) => `state[${JSON.stringify(n)}] === ${JSON.stringify(v)}`)
.join(" && ");
}
function renderWhenCondition(when) {
if (!when) return "true";
if (when["OR"]) {
return when["OR"]
.map(it => `(${renderWhenCondition(it)})`)
.join(" || ");
}
if (when["AND"]) {
return when["AND"]
.map(it => `(${renderWhenCondition(it)})`)
.join(" && ")
}
if (Object.entries(when).length === 0) return 'true';
return Object.entries(when)
.map(([key, vals]) => vals.toString().split('|')
.map(val => `state[${JSON.stringify(key)}] === ${JSON.stringify(val)}`)
.join(' || '))
.map(it => `(${it})`)
.join(' && ');
}
function renderApply(data) {
if (Array.isArray(data)) {
const parts = [];
let total = 0.0;
for (const {weight = 1.0} of data) total += weight;
let accum = 0.0;
for (const elem of data) {
const {weight = 1.0} = elem;
accum += weight;
if (accum === total) {
parts.push(JSON.stringify(elem));
} else {
parts.push(`h < ${accum} ? ${JSON.stringify(elem)} : `);
}
}
return `(h => ${parts.join('')})(Math.abs(hash) % ${total})`;
} else {
return JSON.stringify(data);
}
}
/**
* @param {Object} data
* @returns {String}
*/
function renderBlockstateResolver(data) {
const {variants, multipart} = data;
if (variants) {
let cases = [];
for (const [key, apply] of Object.entries(variants)) {
if (!key || key === "normal") continue;
cases.push(`if (${(renderVariantCondition(key))}) return [${(renderApply(apply))}];`);
}
let fallback = variants[""] ?? variants["normal"] ?? {"model": ":invalid"};
cases.push(`return [${renderApply(fallback)}]`)
return cases.join('else ');
} else if (multipart) {
const parts = [];
parts.push('const parts = [];')
for (const {when, apply} of multipart) {
parts.push(`if (${renderWhenCondition(when)}) parts.push(${renderApply(apply)});`);
}
parts.push("return parts;");
return parts.join('');
} else {
console.warn('invalid blockstate', data);
return 'return [{"model": ":invalid"}]';
}
}
export class BlockstateResolver {
constructor() {
this.resolvers = new Map();
this.resolvers.set(':pending', (state, hash) => [{"model": ":pending"}]);
this.resolvers.set(':invalid', (state, hash) => [{"model": ":invalid"}]);
this.pending = new Map();
this.onResolve = undefined;
}
getResolver(id) {
id = id.includes(':') ? id : `minecraft:${id}`;
if (this.resolvers.has(id)) {
return this.resolvers.get(id);
}
if (!this.pending.has(id)) {
const [space, name] = id.split(':');
this.pending.set(id, fetch(`assets/${space}/blockstates/${name}.json`)
.then(res => res.json())
.then(data => {
const source = renderBlockstateResolver(data);
const resolver = new Function(`return function (state, hash) { ${source} }`)();
this.resolvers.set(id, resolver);
this.pending.delete(id);
if (this.onResolve) {
this.onResolve(id, resolver);
}
})
.catch(reason => {
this.resolvers.set(id, this.resolvers.get(':invalid'));
this.pending.delete(id);
console.log(`failed to resolve blockstate ${id}: ${reason}`)
})
)
}
return this.resolvers.get(':pending');
}
}
/**
* @param {String} src
* @returns {Promise<void>}
*/
async function parse(src) {
const lines = src.replace(/#.*?(?:\n|$)/, '\n').trim().split(/(?:\n[ \t]*)+/);