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:
@@ -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")
|
||||
);
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user