Phase 11: real block textures for 2D tiles

Layers texture-averaged colors on top of palette.rs's hand-picked table as a
fallback (color_for_textured), not a hard replacement — biome-tinted/animated
blocks (grass top, leaves, water, lava) deliberately keep the hand-picked
color since averaging their raw jar textures would be wrong, not just
imprecise.

Vanilla: worker/src/textures.rs downloads Mojang's official client jar
directly from launchermeta/piston-meta/piston-data (same endpoints the real
launcher uses, gated behind ACCEPT_MINECRAFT_EULA=true, off by default,
mirrors BlueMap's accept-download) and averages every block texture, cached
to disk so it's not re-downloaded every restart.

Modded: ingests block_registry/block_textures messages from the mod (new
Postgres tables + MinIO storage in api/src/textures.ts) — sent once per
connection over the existing WS gateway.

texturepacks/<name>/ (Dynmap-style flat PNGs) lets an operator override the
vanilla defaults worker-wide via TEXTURE_PACK. A per-server texturePack
admin column/API exists for the same purpose, but render-time per-server
resolution (of both the admin selection and the ingested modded textures) is
explicitly deferred — the worker still applies one process-wide palette; true
per-server resolution needs the render pipeline to thread a server-scoped
palette through the batch/GPU-dispatch path, judged too big a change for this
phase. Documented in README.

Docker was unavailable in this dev environment for the usual integration-test
verification; bunx tsc --noEmit was used as a fallback static check instead
(clean except 2 pre-existing unrelated errors in markers.test.ts).
This commit is contained in:
2026-08-09 23:21:31 +02:00
parent 1a0ccd8b17
commit f8216b6e77
22 changed files with 1086 additions and 27 deletions
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "servers" ADD COLUMN IF NOT EXISTS "texture_pack" text;
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS "block_registry" (
"server_id" uuid NOT NULL REFERENCES "servers"("id") ON DELETE CASCADE,
"block_id" integer NOT NULL,
"name" text NOT NULL,
PRIMARY KEY ("server_id", "block_id")
);
CREATE TABLE IF NOT EXISTS "block_textures" (
"server_id" uuid NOT NULL REFERENCES "servers"("id") ON DELETE CASCADE,
"name" text NOT NULL,
"storage_key" text NOT NULL,
"updated_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("server_id", "name")
);
+30
View File
@@ -115,6 +115,36 @@ describe("updateServerSettings", () => {
expect(result).toEqual({ ok: false, error: "invalid_waypoint_format" });
});
test("texturePack defaults to null (use the downloaded defaults) on a freshly registered server", async () => {
const created = await registerServer("admin-test-texturepack-default", "offline");
if (!created.ok) throw new Error("setup failed");
createdId = created.server.id;
expect(created.server.texturePack).toBeNull();
});
test("accepts a valid texturePack name and can clear it back to null", async () => {
const created = await registerServer("admin-test-texturepack", "offline");
if (!created.ok) throw new Error("setup failed");
createdId = created.server.id;
const set = await updateServerSettings(createdId, { texturePack: "my-resource-pack" });
expect(set.ok).toBe(true);
if (set.ok) expect(set.server.texturePack).toBe("my-resource-pack");
const cleared = await updateServerSettings(createdId, { texturePack: null });
expect(cleared.ok).toBe(true);
if (cleared.ok) expect(cleared.server.texturePack).toBeNull();
});
test("rejects a texturePack name shaped like a path traversal attempt", async () => {
const created = await registerServer("admin-test-bad-texturepack", "offline");
if (!created.ok) throw new Error("setup failed");
createdId = created.server.id;
const result = await updateServerSettings(createdId, { texturePack: "../../etc/passwd" });
expect(result).toEqual({ ok: false, error: "invalid_texture_pack" });
});
test("returns not_found for a nonexistent server", async () => {
const result = await updateServerSettings(crypto.randomUUID(), { name: "x" });
expect(result).toEqual({ ok: false, error: "not_found" });
+12
View File
@@ -4,6 +4,10 @@ import { servers } from "./db/schema";
const VALID_AUTH_MODES = ["online", "offline"];
const VALID_WAYPOINT_FORMATS = ["journeymap", "xaero", "off"];
// Matches a bare directory name only (no `/`, `..`, or other path-traversal-shaped input) — this
// is used to build a `texturepacks/<name>/` path on the worker's filesystem (see textures.rs's
// `average_directory`), so it's validated here even though nothing reads it yet at render time.
const TEXTURE_PACK_NAME = /^[a-zA-Z0-9_-]+$/;
/**
* Admin routes are gated on a single shared secret (`MCMAPPER_ADMIN_TOKEN`), not a per-account
@@ -46,6 +50,7 @@ export type UpdateServerSettingsInput = Partial<{
anonymousChatAllowed: boolean;
waypointFormat: string;
playerPositionsVisible: boolean;
texturePack: string | null;
}>;
export type UpdateServerSettingsResult =
@@ -62,6 +67,13 @@ export async function updateServerSettings(
if (input.waypointFormat !== undefined && !VALID_WAYPOINT_FORMATS.includes(input.waypointFormat)) {
return { ok: false, error: "invalid_waypoint_format" };
}
if (
input.texturePack !== undefined &&
input.texturePack !== null &&
!TEXTURE_PACK_NAME.test(input.texturePack)
) {
return { ok: false, error: "invalid_texture_pack" };
}
const [server] = await db.update(servers).set(input).where(eq(servers.id, id)).returning();
if (!server) return { ok: false, error: "not_found" };
+43
View File
@@ -18,6 +18,12 @@ export const servers = pgTable("servers", {
// viewers. Independent of the mod-local `playerTrackingEnabled` config (see MCMapperMod.java's
// doc comment) — this is the backend-side "should we show it" toggle, admin-configurable.
playerPositionsVisible: boolean("player_positions_visible").notNull().default(true),
// Phase 11: name of a `texturepacks/<name>/` directory on the worker (Dynmap-style operator
// override, layered on top of the downloaded vanilla-texture-averaged defaults) — null means
// "use the defaults". Recorded here so the choice is admin-configurable/persisted per server;
// see worker/README's Phase 11 note for the current limitation on how this is actually applied
// at render time (worker-wide via `TEXTURE_PACK`, not yet resolved per-job from this column).
texturePack: text("texture_pack"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
@@ -151,6 +157,43 @@ export const meshPointers = pgTable(
],
);
// Phase 11: the mod's numeric-blockId -> registry-name dump (e.g. `4000 -> "botania:manapool"`),
// sent once per connection since it's stable for a world's lifetime (see BackendConnection.java's
// wire-protocol comment). Lets the worker eventually resolve `chunkColumns.blockId` (a numeric id,
// meaningless without the mod list that assigned it) to a texture — see `blockTextures` below.
// Currently write-only from the api's perspective: nothing reads this table yet (the worker's
// texture-averaged rendering is still one process-wide default palette, not per-server-resolved —
// see worker/README's Phase 11 note), stored now so the data isn't lost once that wiring lands.
export const blockRegistry = pgTable(
"block_registry",
{
serverId: uuid("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
blockId: integer("block_id").notNull(),
name: text("name").notNull(),
},
(table) => [primaryKey({ columns: [table.serverId, table.blockId] })],
);
// Phase 11: metadata pointer to one modded block's texture PNG (extracted by the mod off its own
// classloader — Forge doesn't split client/server jars, so these assets are already present on a
// dedicated server's classpath, just unused server-side) in the shared MinIO bucket. Mirrors
// `tilePointers`/`meshPointers`'s "binary in MinIO, pointer in Postgres" shape, just populated
// from the api side (this data arrives over the mod's WS connection) rather than by the worker.
export const blockTextures = pgTable(
"block_textures",
{
serverId: uuid("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
name: text("name").notNull(),
storageKey: text("storage_key").notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [primaryKey({ columns: [table.serverId, 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.
+10 -7
View File
@@ -260,19 +260,22 @@ export const app = new Elysia()
set.status = 401;
return { error: "unauthenticated" };
}
const { name, authMode, anonymousChatAllowed, waypointFormat, playerPositionsVisible } = body as {
name?: string;
authMode?: string;
anonymousChatAllowed?: boolean;
waypointFormat?: string;
playerPositionsVisible?: boolean;
};
const { name, authMode, anonymousChatAllowed, waypointFormat, playerPositionsVisible, texturePack } =
body as {
name?: string;
authMode?: string;
anonymousChatAllowed?: boolean;
waypointFormat?: string;
playerPositionsVisible?: boolean;
texturePack?: string | null;
};
const result = await updateServerSettings(params.id, {
name,
authMode,
anonymousChatAllowed,
waypointFormat,
playerPositionsVisible,
texturePack,
});
if (!result.ok) set.status = result.error === "not_found" ? 404 : 400;
return result;
+79
View File
@@ -0,0 +1,79 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { eq, and } from "drizzle-orm";
import { db } from "./db/client";
import { blockRegistry, blockTextures } from "./db/schema";
import { minio, TILE_BUCKET } from "./minio";
import { storeBlockRegistry, storeBlockTextures, textureStorageKey } from "./textures";
import { createTestServer, deleteTestServer } from "./test-helpers";
describe("textureStorageKey", () => {
test("sanitizes registry-name characters not safe in a flat object key", () => {
expect(textureStorageKey("server-1", "botania:manaPool")).toBe("server-1/textures/botania_manaPool.png");
});
});
describe("storeBlockRegistry", () => {
let server: { id: string };
beforeAll(async () => {
server = await createTestServer("block-registry");
});
afterAll(async () => {
await deleteTestServer(server.id);
});
test("upserts entries, keyed by (serverId, blockId)", async () => {
await storeBlockRegistry(server.id, [
{ id: 4000, name: "botania:manapool" },
{ id: 4001, name: "botania:altar" },
]);
const rows = await db.select().from(blockRegistry).where(eq(blockRegistry.serverId, server.id));
expect(rows.length).toBe(2);
expect(rows.find((r) => r.blockId === 4000)?.name).toBe("botania:manapool");
});
test("re-sending the same id with a new name overwrites, not duplicates", async () => {
await storeBlockRegistry(server.id, [{ id: 4000, name: "botania:mana_pool_renamed" }]);
const rows = await db
.select()
.from(blockRegistry)
.where(and(eq(blockRegistry.serverId, server.id), eq(blockRegistry.blockId, 4000)));
expect(rows.length).toBe(1);
expect(rows[0]!.name).toBe("botania:mana_pool_renamed");
});
test("an empty list is a no-op, not an error", async () => {
await expect(storeBlockRegistry(server.id, [])).resolves.toBeUndefined();
});
});
describe("storeBlockTextures", () => {
let server: { id: string };
beforeAll(async () => {
server = await createTestServer("block-textures");
});
afterAll(async () => {
await deleteTestServer(server.id);
await minio.removeObject(TILE_BUCKET, textureStorageKey(server.id, "botania:manapool")).catch(() => {});
});
test("stores the raw PNG bytes in MinIO and a pointer row in Postgres", async () => {
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]); // not a real PNG — just distinguishable bytes
await storeBlockTextures(server.id, [{ name: "botania:manapool", dataBase64: pngBytes.toString("base64") }]);
const [row] = await db
.select()
.from(blockTextures)
.where(and(eq(blockTextures.serverId, server.id), eq(blockTextures.name, "botania:manapool")));
expect(row).toBeDefined();
expect(row!.storageKey).toBe(textureStorageKey(server.id, "botania:manapool"));
const stream = await minio.getObject(TILE_BUCKET, row!.storageKey);
const chunks: Buffer[] = [];
for await (const chunk of stream) chunks.push(chunk as Buffer);
expect(Buffer.concat(chunks).equals(pngBytes)).toBe(true);
});
});
+61
View File
@@ -0,0 +1,61 @@
import { sql } from "drizzle-orm";
import { db } from "./db/client";
import { blockRegistry, blockTextures } from "./db/schema";
import { minio, TILE_BUCKET } from "./minio";
export interface BlockRegistryEntry {
id: number;
name: string;
}
export interface BlockTextureEntry {
name: string;
dataBase64: string;
}
/**
* Upserts the mod's numeric-blockId -> registry-name dump for one server (Phase 11) — sent once
* per connection since a world's id assignments are stable for its lifetime (see
* BackendConnection.java's wire-protocol comment). Nothing reads this table yet; it's stored now
* so the mapping isn't lost before the worker's per-server texture resolution (still a single
* process-wide default palette — see worker/README's Phase 11 note) is wired up to use it.
*/
export async function storeBlockRegistry(serverId: string, entries: BlockRegistryEntry[]) {
if (entries.length === 0) return;
await db
.insert(blockRegistry)
.values(entries.map((e) => ({ serverId, blockId: e.id, name: e.name })))
.onConflictDoUpdate({
target: [blockRegistry.serverId, blockRegistry.blockId],
set: { name: sql.raw("excluded.name") },
});
}
/** Registry names are "modid:path" — sanitize to a flat, safe MinIO object key. */
export function textureStorageKey(serverId: string, name: string): string {
const safe = name.replace(/[^a-zA-Z0-9_.-]/g, "_");
return `${serverId}/textures/${safe}.png`;
}
/**
* Stores raw texture PNGs the mod extracted off its own classloader (Phase 11 — Forge doesn't
* split client/server jars, so a dedicated server already has every loaded mod's texture assets
* on its classpath, just unused server-side) into the shared MinIO bucket, one object per
* registry name, with a metadata pointer row — mirrors the tile/mesh pointer "binary in MinIO,
* pointer in Postgres" pattern (worker/src/storage.rs), just populated from the api side since
* this data arrives over the mod's WS connection rather than through the render pipeline.
*/
export async function storeBlockTextures(serverId: string, textures: BlockTextureEntry[]) {
for (const t of textures) {
const bytes = Buffer.from(t.dataBase64, "base64");
const storageKey = textureStorageKey(serverId, t.name);
await minio.putObject(TILE_BUCKET, storageKey, bytes);
await db
.insert(blockTextures)
.values({ serverId, name: t.name, storageKey })
.onConflictDoUpdate({
target: [blockTextures.serverId, blockTextures.name],
set: { storageKey, updatedAt: new Date() },
});
}
}
+25
View File
@@ -5,6 +5,7 @@ import { markChunkDirty } from "./redis";
import { storeLinkCode } from "./link";
import { recordAndPublishChat } from "./chat";
import { arePlayerPositionsVisible, publishPlayerPositions, type PlayerPosition } from "./players";
import { storeBlockRegistry, storeBlockTextures, type BlockRegistryEntry, type BlockTextureEntry } from "./textures";
// Wire protocol (mod <-> api), one JSON object per WS text frame:
//
@@ -58,6 +59,18 @@ import { arePlayerPositionsVisible, publishPlayerPositions, type PlayerPosition
// players.ts's publishPlayerPositions to whatever browsers are subscribed on /ws/players/:serverId
// (players-gateway.ts), gated on the per-server `playerPositionsVisible` admin toggle (independent
// of the mod-local `playerTrackingEnabled` config that decides whether this message is sent at all).
//
// mod -> api {"type":"block_registry","entries":[{"id":4000,"name":"botania:manapool"}]}
// mod -> api {"type":"block_textures","textures":[{"name":"botania:manapool","dataBase64":"..."}]}
//
// 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.
interface Column {
x: number;
@@ -219,6 +232,18 @@ export const wsGateway = {
await publishPlayerPositions(state.serverId, dimension, players);
return;
}
if (msg.type === "block_registry") {
const entries: BlockRegistryEntry[] = msg.entries ?? [];
await storeBlockRegistry(state.serverId, entries);
return;
}
if (msg.type === "block_textures") {
const textures: BlockTextureEntry[] = msg.textures ?? [];
await storeBlockTextures(state.serverId, textures);
return;
}
},
close(ws: any) {