Phase 13: real non-cube block models via blockstate/model JSON resolution

Adds worker/src/models.rs (parent-chain blockstate/model resolution,
texture-variable substitution reusing Phase 12's atlas keys), routes
non-cube blocks through new per-element mesh emission in mesh.rs while
leaving full-cube blocks on the existing cube mesher, and threads a
per-server ModelContext (vanilla worker-wide + modded per-job) through
main.rs. Modded model JSON is stored in a new block_models Postgres
table and read alongside the existing (previously write-only)
block_registry table. Fixes a pre-existing face-culling bug as a side
effect of excluding non-cube voxels from the cube mesher's input.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt
This commit is contained in:
2026-08-10 01:19:13 +02:00
parent 6ad8051eae
commit a7b69bd038
13 changed files with 1069 additions and 15 deletions
+8
View File
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS "block_models" (
"server_id" uuid NOT NULL REFERENCES "servers"("id") ON DELETE CASCADE,
"kind" text NOT NULL,
"name" text NOT NULL,
"json" text NOT NULL,
"updated_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("server_id", "kind", "name")
);
+23
View File
@@ -194,6 +194,29 @@ export const blockTextures = pgTable(
(table) => [primaryKey({ columns: [table.serverId, table.name] })],
);
// Phase 13: raw blockstate/model JSON text the mod extracted off its own classloader, mirroring
// `blockRegistry`/`blockTextures`'s "written by the api from the mod's WS connection, read later
// by the worker" shape — stored as plain text (not MinIO) since these are small JSON snippets, not
// binary blobs, matching `blockRegistry`'s own text-in-Postgres precedent. `kind` distinguishes a
// blockstate (`name` = the block's own registry name, e.g. `"thaumcraft:blockcustomplant"`) from a
// model (`name` = the model's own resource location, e.g. `"thaumcraft:block/customplant"`, as
// referenced by a blockstate variant's `"model"` field or another model's `"parent"`) since both
// are keyed by resource-location strings that could otherwise collide. See worker/src/models.rs's
// `ModelRegistry` for how these get parsed and resolved into real element geometry.
export const blockModels = pgTable(
"block_models",
{
serverId: uuid("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
kind: text("kind").notNull(),
name: text("name").notNull(),
json: text("json").notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [primaryKey({ columns: [table.serverId, table.kind, table.name] })],
);
// Metadata pointer to a rendered tile PNG in MinIO — the binary itself never touches Postgres.
// One row per (server, dimension, zoom, tileX, tileZ); zoom is always 0 until Phase 2 adds
// multi-resolution tiles.
+62
View File
@@ -0,0 +1,62 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { and, eq } from "drizzle-orm";
import { db } from "./db/client";
import { blockModels } from "./db/schema";
import { storeBlockModels } from "./models";
import { createTestServer, deleteTestServer } from "./test-helpers";
describe("storeBlockModels", () => {
let server: { id: string };
beforeAll(async () => {
server = await createTestServer("block-models");
});
afterAll(async () => {
await deleteTestServer(server.id);
});
test("upserts blockstate and model entries, keyed by (serverId, kind, name)", async () => {
await storeBlockModels(server.id, [
{ kind: "blockstate", name: "thaumcraft:blockcustomplant", json: '{"variants":{}}' },
{ kind: "model", name: "thaumcraft:block/customplant", json: '{"elements":[]}' },
]);
const rows = await db.select().from(blockModels).where(eq(blockModels.serverId, server.id));
expect(rows.length).toBe(2);
expect(rows.find((r) => r.kind === "blockstate")?.name).toBe("thaumcraft:blockcustomplant");
expect(rows.find((r) => r.kind === "model")?.json).toBe('{"elements":[]}');
});
test("a blockstate and a model can share the same name without colliding (kind is part of the key)", async () => {
await storeBlockModels(server.id, [
{ kind: "blockstate", name: "thaumcraft:foo", json: '{"a":1}' },
{ kind: "model", name: "thaumcraft:foo", json: '{"b":2}' },
]);
const rows = await db
.select()
.from(blockModels)
.where(and(eq(blockModels.serverId, server.id), eq(blockModels.name, "thaumcraft:foo")));
expect(rows.length).toBe(2);
});
test("re-sending the same (kind, name) with new JSON overwrites, not duplicates", async () => {
await storeBlockModels(server.id, [{ kind: "model", name: "thaumcraft:block/customplant", json: '{"v":1}' }]);
await storeBlockModels(server.id, [{ kind: "model", name: "thaumcraft:block/customplant", json: '{"v":2}' }]);
const rows = await db
.select()
.from(blockModels)
.where(
and(
eq(blockModels.serverId, server.id),
eq(blockModels.kind, "model"),
eq(blockModels.name, "thaumcraft:block/customplant"),
),
);
expect(rows.length).toBe(1);
expect(rows[0]!.json).toBe('{"v":2}');
});
test("an empty list is a no-op, not an error", async () => {
await expect(storeBlockModels(server.id, [])).resolves.toBeUndefined();
});
});
+27
View File
@@ -0,0 +1,27 @@
import { sql } from "drizzle-orm";
import { db } from "./db/client";
import { blockModels } from "./db/schema";
export interface BlockModelEntry {
kind: "blockstate" | "model";
name: string;
json: string;
}
/**
* Upserts the mod's raw blockstate/model JSON dump for one server (Phase 13) — sent once per
* connection, same lifecycle as `storeBlockRegistry`/`storeBlockTextures` (see textures.ts):
* stable for a world's lifetime, no need to resend on a timer. Read by the worker's
* `models::ModelRegistry` (via new `fetch_block_models`/`fetch_block_registry` queries) to resolve
* a modded block's real (possibly non-cube) shape — see worker/src/models.rs's doc comment.
*/
export async function storeBlockModels(serverId: string, entries: BlockModelEntry[]) {
if (entries.length === 0) return;
await db
.insert(blockModels)
.values(entries.map((e) => ({ serverId, kind: e.kind, name: e.name, json: e.json })))
.onConflictDoUpdate({
target: [blockModels.serverId, blockModels.kind, blockModels.name],
set: { json: sql.raw("excluded.json"), updatedAt: new Date() },
});
}
+17 -3
View File
@@ -6,6 +6,7 @@ import { storeLinkCode } from "./link";
import { recordAndPublishChat } from "./chat";
import { arePlayerPositionsVisible, publishPlayerPositions, type PlayerPosition } from "./players";
import { storeBlockRegistry, storeBlockTextures, type BlockRegistryEntry, type BlockTextureEntry } from "./textures";
import { storeBlockModels, type BlockModelEntry } from "./models";
// Wire protocol (mod <-> api), one JSON object per WS text frame:
//
@@ -62,15 +63,22 @@ import { storeBlockRegistry, storeBlockTextures, type BlockRegistryEntry, type B
//
// mod -> api {"type":"block_registry","entries":[{"id":4000,"name":"botania:manapool"}]}
// mod -> api {"type":"block_textures","textures":[{"name":"botania:manapool","dataBase64":"..."}]}
// mod -> api {"type":"block_models","entries":[{"kind":"blockstate","name":"botania:manapool","json":"..."},{"kind":"model","name":"botania:block/manapool","json":"..."}]}
//
// Phase 11: sent once after `hello_ack` (a world's numeric block-id assignments and mod-jar
// contents are both stable for the server's lifetime, so there's no need to resend on a timer).
// `block_registry` is the numeric-id -> registry-name mapping needed to make sense of
// `chunkColumns.blockId`/`blockMeta` for modded blocks (see MCMapperMod.java's connect-time
// registry dump); `block_textures` is the mod's best-effort classloader extraction of each
// block's texture PNG (see textures.ts's doc comment for storage). Both are stored now but not
// yet read by the render pipeline — see worker/README's Phase 11 note on the deferred per-server
// palette-resolution work this unlocks.
// block's texture PNG (see textures.ts's doc comment for storage). Both are read by the worker's
// per-job model resolution as of Phase 13 (`block_registry` for the id->name lookup); the worker's
// 2D/atlas texture-palette resolution itself is still process-wide only — see worker/README.
//
// Phase 13: `block_models` is the mod's raw blockstate/model JSON dump (see models.ts's doc
// comment) — `kind` distinguishes a blockstate entry (`name` = the block's own registry name)
// from a model entry (`name` = the model's resource location, as referenced by a variant's
// `"model"` or another model's `"parent"`). Same "sent once, stable for the world's lifetime"
// cadence as the other two.
interface Column {
x: number;
@@ -244,6 +252,12 @@ export const wsGateway = {
await storeBlockTextures(state.serverId, textures);
return;
}
if (msg.type === "block_models") {
const entries: BlockModelEntry[] = msg.entries ?? [];
await storeBlockModels(state.serverId, entries);
return;
}
},
close(ws: any) {