From f8216b6e77fab51fb4a3c33e0b7883473d787075 Mon Sep 17 00:00:00 2001 From: Octoturge Date: Sun, 9 Aug 2026 23:21:31 +0200 Subject: [PATCH] Phase 11: real block textures for 2D tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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// (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). --- README.md | 45 +++++ api/drizzle/0005_texture_pack.sql | 1 + api/drizzle/0006_block_textures.sql | 14 ++ api/src/admin.test.ts | 30 +++ api/src/admin.ts | 12 ++ api/src/db/schema.ts | 43 +++++ api/src/index.ts | 17 +- api/src/textures.test.ts | 79 ++++++++ api/src/textures.ts | 61 ++++++ api/src/ws-gateway.ts | 25 +++ e2e/tests/admin.spec.ts | 2 + frontend/src/public/js/admin.js | 3 + frontend/src/views/admin.pug | 5 + worker/.env.example | 20 ++ worker/Cargo.lock | 287 ++++++++++++++++++++++++++-- worker/Cargo.toml | 4 + worker/src/block_names.rs | 105 ++++++++++ worker/src/lib.rs | 2 + worker/src/main.rs | 42 +++- worker/src/palette.rs | 55 ++++++ worker/src/render/mod.rs | 21 +- worker/src/textures.rs | 240 +++++++++++++++++++++++ 22 files changed, 1086 insertions(+), 27 deletions(-) create mode 100644 api/drizzle/0005_texture_pack.sql create mode 100644 api/drizzle/0006_block_textures.sql create mode 100644 api/src/textures.test.ts create mode 100644 api/src/textures.ts create mode 100644 worker/src/block_names.rs create mode 100644 worker/src/textures.rs diff --git a/README.md b/README.md index 99a4d02..028cc1d 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,51 @@ stack isn't on the same LAN. `MINIO_SECRET_KEY` is a real credential and is deli committed — set it in an untracked `./api/.env` / `./worker/.env` (docker-compose layers those on top of the tracked `.env.example`, see `docker-compose.yml`). +### Real block textures (Phase 11) + +`worker/src/palette.rs`'s hand-picked color table is now a *fallback*, not the only source of +2D-tile colors. Set `ACCEPT_MINECRAFT_EULA=true` (`worker/.env.example`, off by default — mirrors +BlueMap's `accept-download`) and the worker downloads Minecraft's official client jar directly +from Mojang's own public `launchermeta`/`piston-meta`/`piston-data` endpoints (same source the +real launcher uses — no redistribution, so no licensing issue) on first startup, averages every +`assets/minecraft/textures/block(s)/*.png` into a representative color, and caches the result to +disk (`TEXTURE_CACHE_DIR`, default `./cache`) so it isn't re-downloaded every restart. +`MC_TEXTURE_VERSION` (default `1.12.2`) picks which version's jar to pull from — this project's +priority targets (1.7.10/1.12.2) share the pre-1.13 `textures/blocks/` (plural) naming, which +`worker/src/textures.rs` checks alongside the modern `textures/block/` path. + +A handful of blocks (grass block top, leaves, water, lava) are deliberately *excluded* from the +texture-averaged path (`worker/src/block_names.rs`'s doc comment) and keep their hand-picked +color: their real textures are either biome-tinted at runtime (grayscale in the raw file) or +animated/transparent frame strips, so averaging the raw asset would produce a wrong color, not +just an approximate one. + +**`texturepacks//`** (flat `*.png` files, mirrors Dynmap) lets an operator override the +downloaded defaults — set `TEXTURE_PACK=` and its colors are layered on top of the vanilla +palette at startup. Each server also has an admin-configurable `texturePack` field (`/admin`, +`servers.texture_pack` — same shape as `waypointFormat`) recording *which* pack an operator wants +per server — **but render-time application is currently worker-wide only, via `TEXTURE_PACK`, not +yet resolved per-server from that column.** True per-server resolution needs the render pipeline +to thread a server-scoped palette through `main.rs`'s batch loop instead of one process-wide +`OnceLock` (`worker/src/render/mod.rs`) — a real architectural change, deferred rather than rushed. + +**Modded blocks**: Forge doesn't split client/server jars, so a modded server's own classpath +already has every loaded mod's texture assets, just unused server-side. The `forge-1_12_2` mod +leaf (Enigmatica 2, this project's primary target) extracts them once at startup +(`BlockAssetExtractor`, best-effort: guesses each block's texture by its registry-name path +segment, not a real blockstate/model JSON resolution — that's Phase 12's job) and ships two new +WS messages after connecting: `block_registry` (numeric id -> registry name, needed since a +numeric `blockId` alone is meaningless without the mod list that assigned it) and `block_textures` +(the extracted PNGs, batched). The api stores both (`api/src/textures.ts`: registry rows in a new +`block_registry` table, texture PNGs in the shared MinIO bucket with pointer rows in +`block_textures`) — **but, like the `texturepacks/` case above, nothing reads these tables into +the render pipeline yet.** This is a deliberate two-step boundary: "ingest and store" (done, real, +tested) vs. "resolve into a per-server palette at render time" (the same deferred piece as +`texturepacks/` above, and naturally solved together). `forge-1_7_10`/`neoforge-26_1` don't +implement extraction yet either — `BackendConnection#sendBlockRegistry`/`#sendBlockTextures` are +on the shared interface (so any leaf can adopt them later with no protocol change), but only the +1.12.2 leaf calls them so far, matching this phase's Enigmatica-2-focused verification target. + ## Running ``` diff --git a/api/drizzle/0005_texture_pack.sql b/api/drizzle/0005_texture_pack.sql new file mode 100644 index 0000000..2b364c0 --- /dev/null +++ b/api/drizzle/0005_texture_pack.sql @@ -0,0 +1 @@ +ALTER TABLE "servers" ADD COLUMN IF NOT EXISTS "texture_pack" text; diff --git a/api/drizzle/0006_block_textures.sql b/api/drizzle/0006_block_textures.sql new file mode 100644 index 0000000..056ac24 --- /dev/null +++ b/api/drizzle/0006_block_textures.sql @@ -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") +); diff --git a/api/src/admin.test.ts b/api/src/admin.test.ts index 5df3dff..76ed306 100644 --- a/api/src/admin.test.ts +++ b/api/src/admin.test.ts @@ -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" }); diff --git a/api/src/admin.ts b/api/src/admin.ts index 7590ff1..7e816f8 100644 --- a/api/src/admin.ts +++ b/api/src/admin.ts @@ -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//` 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" }; diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index 4755700..3f7014c 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -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//` 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. diff --git a/api/src/index.ts b/api/src/index.ts index f5dab70..68c1b76 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -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; diff --git a/api/src/textures.test.ts b/api/src/textures.test.ts new file mode 100644 index 0000000..3bb4a27 --- /dev/null +++ b/api/src/textures.test.ts @@ -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); + }); +}); diff --git a/api/src/textures.ts b/api/src/textures.ts new file mode 100644 index 0000000..772fe89 --- /dev/null +++ b/api/src/textures.ts @@ -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() }, + }); + } +} diff --git a/api/src/ws-gateway.ts b/api/src/ws-gateway.ts index 02c0219..f6c898b 100644 --- a/api/src/ws-gateway.ts +++ b/api/src/ws-gateway.ts @@ -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) { diff --git a/e2e/tests/admin.spec.ts b/e2e/tests/admin.spec.ts index 98a824f..f558506 100644 --- a/e2e/tests/admin.spec.ts +++ b/e2e/tests/admin.spec.ts @@ -47,6 +47,7 @@ test.describe("admin panel", () => { const row = page.locator('[data-testid="admin-server-row"][data-server-name="e2e-server"]'); await row.getByTestId("admin-server-waypointformat").selectOption("xaero"); await row.getByTestId("admin-server-anonchat").uncheck(); + await row.getByTestId("admin-server-texturepack").fill("my-resource-pack"); await row.getByTestId("admin-server-save").click(); await expect(row.getByTestId("admin-server-status")).toHaveText("saved"); @@ -56,6 +57,7 @@ test.describe("admin panel", () => { const reloadedRow = page.locator('[data-testid="admin-server-row"][data-server-name="e2e-server"]'); await expect(reloadedRow.getByTestId("admin-server-waypointformat")).toHaveValue("xaero"); await expect(reloadedRow.getByTestId("admin-server-anonchat")).not.toBeChecked(); + await expect(reloadedRow.getByTestId("admin-server-texturepack")).toHaveValue("my-resource-pack"); }); test("deleting a server removes it from the list", async ({ page }) => { diff --git a/frontend/src/public/js/admin.js b/frontend/src/public/js/admin.js index d3fbebf..58b5f0a 100644 --- a/frontend/src/public/js/admin.js +++ b/frontend/src/public/js/admin.js @@ -61,6 +61,9 @@ function adminpanel() { anonymousChatAllowed: server.anonymousChatAllowed, waypointFormat: server.waypointFormat, playerPositionsVisible: server.playerPositionsVisible, + // Empty string means "no override" — normalize to null so it matches the schema's + // nullable column and admin.ts's "null clears it" validation path. + texturePack: server.texturePack ? server.texturePack.trim() || null : null, }), }); server.status = res.ok ? "saved" : "save failed"; diff --git a/frontend/src/views/admin.pug b/frontend/src/views/admin.pug index 4cf5ebf..36f50f8 100644 --- a/frontend/src/views/admin.pug +++ b/frontend/src/views/admin.pug @@ -53,6 +53,11 @@ html(lang="en") label.flex.items-center.gap-1.text-xs.text-neutral-300 input(type="checkbox" x-model="server.playerPositionsVisible" data-testid="admin-server-playerpositions") | player positions + div.flex.items-center.gap-2 + label.text-xs.text-neutral-400 texture pack + input.flex-1.bg-neutral-900.text-xs.px-2.py-1.rounded.border.border-neutral-700( + type="text" placeholder="(default — downloaded vanilla textures)" + x-model="server.texturePack" data-testid="admin-server-texturepack") div.flex.gap-2 button.px-2.py-1.bg-emerald-700.rounded.text-xs(x-on:click="saveServer(server)" data-testid="admin-server-save") Save button.px-2.py-1.bg-red-800.rounded.text-xs(x-on:click="removeServer(server)" data-testid="admin-server-delete") Delete diff --git a/worker/.env.example b/worker/.env.example index 758ae81..0146f70 100644 --- a/worker/.env.example +++ b/worker/.env.example @@ -28,3 +28,23 @@ RENDER_THREADS=auto # fewer, larger batches, less scheduling overhead per chunk on fewer/faster cores. Unrecognized # values fall back to `server`, matching this project's own primary deployment target. RENDER_PROFILE=server + +# Phase 11: set to exactly "true" to opt in to the worker downloading Mojang's official client +# jar (from launchermeta/piston-meta/piston-data — the same endpoints the real launcher uses, so +# this never redistributes anything, only fetches directly from Mojang) and averaging its block +# textures into real colors, replacing palette.rs's hand-picked table. Mirrors BlueMap's +# `accept-download` flag — off by default, since it's a real (if small, one-time, cached) +# network fetch an operator should consent to. +ACCEPT_MINECRAFT_EULA=false +# Which Minecraft version's client jar to pull textures from — 1.12.2 (this project's primary +# target) by default. Textures are looked up by file stem (e.g. "stone", "oak_planks"), which is +# stable across the 1.7.10/1.12.2 era this project prioritizes. +MC_TEXTURE_VERSION=1.12.2 +# Where the downloaded-and-averaged vanilla palette is cached (as JSON) so it isn't rebuilt on +# every restart. +TEXTURE_CACHE_DIR=./cache +# Optional: name of a `texturepacks//` directory (flat *.png files, file stem = texture +# name) whose colors are layered on top of the downloaded vanilla defaults — the Dynmap-style +# operator-override mechanism. Currently applies worker-wide (this env var), not yet resolved +# per-server from the admin panel's `texturePack` field — see README's Phase 11 note. +#TEXTURE_PACK= diff --git a/worker/Cargo.lock b/worker/Cargo.lock index fcc9e27..7385ac1 100644 --- a/worker/Cargo.lock +++ b/worker/Cargo.lock @@ -29,6 +29,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arc-swap" version = "1.9.2" @@ -729,6 +738,23 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "cmake" version = "0.1.58" @@ -910,7 +936,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -960,6 +986,17 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.10.7" @@ -1053,7 +1090,7 @@ dependencies = [ "group", "pem-rfc7468", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -1117,7 +1154,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1297,8 +1334,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1308,8 +1347,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1402,7 +1444,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1685,6 +1727,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.4", "tower-service", + "webpki-roots 1.0.9", ] [[package]] @@ -2012,6 +2055,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "malloc_buf" version = "0.0.6" @@ -2034,10 +2083,14 @@ dependencies = [ "image", "rayon", "redis", + "reqwest", + "serde", + "serde_json", "sqlx", "tokio", "uuid", "wgpu", + "zip", ] [[package]] @@ -2121,12 +2174,12 @@ dependencies = [ "arrayvec", "bit-set", "bitflags 2.13.1", - "cfg_aliases", + "cfg_aliases 0.1.1", "codespan-reporting", "hexf-parse", "indexmap", "log", - "rustc-hash", + "rustc-hash 1.1.0", "spirv", "termcolor", "thiserror 1.0.69", @@ -2163,7 +2216,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.7", "smallvec", "zeroize", ] @@ -2410,6 +2463,62 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases 0.2.2", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls 0.23.43", + "socket2 0.6.5", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls 0.23.43", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases 0.2.2", + "libc", + "once_cell", + "socket2 0.6.5", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -2433,7 +2542,18 @@ checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -2443,7 +2563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2455,6 +2575,21 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "range-alloc" version = "0.1.5" @@ -2541,6 +2676,44 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "hyper 1.11.0", + "hyper-rustls 0.27.9", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.43", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.9", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -2578,7 +2751,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -2591,6 +2764,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2645,6 +2824,7 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ + "web-time", "zeroize", ] @@ -2878,7 +3058,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest 0.10.7", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -3082,7 +3262,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.7", "rsa", "serde", "sha1 0.10.7", @@ -3121,7 +3301,7 @@ dependencies = [ "md-5 0.10.6", "memchr", "once_cell", - "rand", + "rand 0.8.7", "serde", "serde_json", "sha2 0.10.9", @@ -3210,6 +3390,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -3404,10 +3593,33 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", "tower-layer", "tower-service", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -3648,6 +3860,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -3673,7 +3895,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80f70000db37c469ea9d67defdc13024ddf9a5f1b89cb2941b812ad7cde1735a" dependencies = [ "arrayvec", - "cfg_aliases", + "cfg_aliases 0.1.1", "document-features", "js-sys", "log", @@ -3700,7 +3922,7 @@ dependencies = [ "arrayvec", "bit-vec", "bitflags 2.13.1", - "cfg_aliases", + "cfg_aliases 0.1.1", "document-features", "indexmap", "log", @@ -3709,7 +3931,7 @@ dependencies = [ "parking_lot", "profiling", "raw-window-handle", - "rustc-hash", + "rustc-hash 1.1.0", "smallvec", "thiserror 1.0.69", "wgpu-hal", @@ -3729,7 +3951,7 @@ dependencies = [ "bitflags 2.13.1", "block", "bytemuck", - "cfg_aliases", + "cfg_aliases 0.1.1", "core-graphics-types", "glow", "glutin_wgl_sys", @@ -3751,7 +3973,7 @@ dependencies = [ "range-alloc", "raw-window-handle", "renderdoc-sys", - "rustc-hash", + "rustc-hash 1.1.0", "smallvec", "thiserror 1.0.69", "wasm-bindgen", @@ -4130,8 +4352,37 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.20", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/worker/Cargo.toml b/worker/Cargo.toml index a3500b2..834fbe8 100644 --- a/worker/Cargo.toml +++ b/worker/Cargo.toml @@ -17,6 +17,10 @@ aws-credential-types = "1" base64 = "0.22" wgpu = "23" bytemuck = { version = "1", features = ["derive"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +zip = { version = "2", default-features = false, features = ["deflate"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" [profile.release] lto = true diff --git a/worker/src/block_names.rs b/worker/src/block_names.rs new file mode 100644 index 0000000..3f8600c --- /dev/null +++ b/worker/src/block_names.rs @@ -0,0 +1,105 @@ +/// Maps this crate's already-curated numeric-id palette (`palette.rs`) to the vanilla texture +/// file stem(s) Mojang ships them under, so Phase 11's texture-averaged colors (`textures.rs`) +/// can be looked up per block/meta. Deliberately covers only the ids `palette.rs` already knows +/// about — anything not covered here falls back to `palette.rs`'s own color (hand-picked, or +/// `UNKNOWN_COLOR` if `palette.rs` doesn't know it either), never a hard error. +/// +/// A few blocks are deliberately excluded (return `None`) even though Mojang ships a texture for +/// them: `grass_block_top` and leaves textures are grayscale in the client jar and only get their +/// green tint applied at render time via biome color multiplication, so averaging the raw texture +/// would produce a washed-out gray, not green — these keep `palette.rs`'s hand-picked color +/// instead. Water/lava are excluded for the same reason (their real textures are semi-transparent +/// animated frame strips, not one representative frame). +pub fn texture_name(block_id: u16, meta: u8) -> Option<&'static str> { + match block_id { + 1 => Some("stone"), + 3 => Some("dirt"), + 4 => Some("cobblestone"), + 5 => Some(match meta { + 1 => "spruce_planks", + 2 => "birch_planks", + 3 => "jungle_planks", + _ => "oak_planks", + }), + 7 => Some("bedrock"), + 12 => Some("sand"), + 13 => Some("gravel"), + 14 => Some("gold_ore"), + 15 => Some("iron_ore"), + 16 => Some("coal_ore"), + 17 => Some("oak_log"), + 20 => Some("glass"), + 24 => Some("sandstone"), + 35 => Some(wool_texture(meta)), + 41 => Some("gold_block"), + 42 => Some("iron_block"), + 45 => Some("bricks"), + 48 => Some("mossy_cobblestone"), + 49 => Some("obsidian"), + 56 => Some("diamond_ore"), + 73 | 74 => Some("redstone_ore"), + 78 => Some("snow"), + 80 => Some("snow"), + 82 => Some("clay"), + 86 => Some("pumpkin_side"), + 87 => Some("netherrack"), + 88 => Some("soul_sand"), + 89 => Some("glowstone"), + 110 => Some("mycelium_top"), + 121 => Some("end_stone"), + 129 => Some("emerald_ore"), + 133 => Some("emerald_block"), + _ => None, + } +} + +fn wool_texture(meta: u8) -> &'static str { + match meta { + 0 => "white_wool", + 1 => "orange_wool", + 2 => "magenta_wool", + 3 => "light_blue_wool", + 4 => "yellow_wool", + 5 => "lime_wool", + 6 => "pink_wool", + 7 => "gray_wool", + 8 => "light_gray_wool", + 9 => "cyan_wool", + 10 => "purple_wool", + 11 => "blue_wool", + 12 => "brown_wool", + 13 => "green_wool", + 14 => "red_wool", + _ => "black_wool", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tinted_blocks_are_deliberately_excluded() { + assert_eq!(texture_name(2, 0), None); // grass block (biome-tinted) + assert_eq!(texture_name(18, 0), None); // leaves (biome-tinted) + assert_eq!(texture_name(8, 0), None); // water (animated/transparent) + assert_eq!(texture_name(10, 0), None); // lava (animated) + } + + #[test] + fn wool_meta_maps_to_sixteen_distinct_names() { + let names: std::collections::HashSet<&str> = (0..16u8).map(wool_texture).collect(); + assert_eq!(names.len(), 16); + } + + #[test] + fn planks_vary_by_meta() { + assert_ne!(texture_name(5, 0), texture_name(5, 1)); + assert_ne!(texture_name(5, 1), texture_name(5, 2)); + } + + #[test] + fn unmapped_block_returns_none() { + assert_eq!(texture_name(9999, 0), None); + } +} diff --git a/worker/src/lib.rs b/worker/src/lib.rs index 72da688..e48d9cb 100644 --- a/worker/src/lib.rs +++ b/worker/src/lib.rs @@ -1,6 +1,8 @@ +pub mod block_names; pub mod config; pub mod db; pub mod mesh; pub mod palette; pub mod render; pub mod storage; +pub mod textures; diff --git a/worker/src/main.rs b/worker/src/main.rs index 6157bcc..97f1dce 100644 --- a/worker/src/main.rs +++ b/worker/src/main.rs @@ -2,7 +2,7 @@ use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use base64::{engine::general_purpose::STANDARD, Engine as _}; -use mcmapper_worker::{config, db, mesh, storage}; +use mcmapper_worker::{config, db, mesh, storage, textures}; use rayon::prelude::*; use redis::streams::{StreamReadOptions, StreamReadReply}; use redis::AsyncCommands; @@ -46,6 +46,46 @@ async fn main() -> anyhow::Result<()> { .xgroup_create_mkstream(DIRTY_CHUNK_STREAM, CONSUMER_GROUP, "$") .await; + // Phase 11: replace palette.rs's hand-picked colors with ones averaged from real Minecraft + // block textures, gated behind an explicit opt-in (mirrors BlueMap's `accept-download`) since + // this downloads Mojang's official client jar directly from their own public endpoints — no + // redistribution, so no licensing issue, but still something an operator should consent to. + if std::env::var("ACCEPT_MINECRAFT_EULA").as_deref() == Ok("true") { + let mc_version = std::env::var("MC_TEXTURE_VERSION").unwrap_or_else(|_| "1.12.2".into()); + let cache_dir = std::env::var("TEXTURE_CACHE_DIR").unwrap_or_else(|_| "./cache".into()); + match textures::load_or_build(std::path::Path::new(&cache_dir), &mc_version).await { + Ok(mut palette) => { + // texturepacks// (Dynmap-style operator override) — layered on top of the + // downloaded vanilla defaults if TEXTURE_PACK names an existing subdirectory. + if let Ok(pack) = std::env::var("TEXTURE_PACK") { + let pack_dir = std::path::Path::new("./texturepacks").join(&pack); + match textures::average_directory(&pack_dir) { + Ok(overrides) if overrides.len() > 0 => { + println!( + "[worker] applying texturepack '{pack}' ({} overrides) from {}", + overrides.len(), + pack_dir.display() + ); + palette.overlay(overrides); + } + Ok(_) => eprintln!( + "[worker] TEXTURE_PACK={pack} set but {} has no .png files, ignoring", + pack_dir.display() + ), + Err(err) => eprintln!("[worker] failed to load texturepack '{pack}': {err:#}"), + } + } + println!("[worker] texture palette ready ({} colors)", palette.len()); + mcmapper_worker::render::set_texture_palette(palette); + } + Err(err) => eprintln!( + "[worker] failed to build vanilla texture palette, falling back to hand-picked colors: {err:#}" + ), + } + } else { + println!("[worker] ACCEPT_MINECRAFT_EULA not set — using hand-picked palette colors (see README)"); + } + let requested_backend = std::env::var("RENDER_BACKEND").unwrap_or_else(|_| "cpu".into()); let backend: Box = match requested_backend.as_str() { "gpu" => match GpuRenderBackend::try_new().await { diff --git a/worker/src/palette.rs b/worker/src/palette.rs index 19facdf..c26713a 100644 --- a/worker/src/palette.rs +++ b/worker/src/palette.rs @@ -6,6 +6,27 @@ const UNKNOWN_COLOR: [u8; 3] = [204, 102, 204]; const AIR_COLOR: [u8; 3] = [30, 30, 40]; +/// Layers Phase 11's texture-averaged colors (`textures::TexturePalette`, looked up via +/// `block_names::texture_name`) over this file's hand-picked table: if a texture palette is +/// available (worker started with `ACCEPT_MINECRAFT_EULA=true`, see main.rs) and it has a color +/// for this block's texture name, that wins; otherwise falls back to `color_for` below unchanged +/// — so a missing/not-yet-downloaded palette, or a block this module doesn't map to a texture +/// name, never regresses to worse output than before Phase 11. +pub fn color_for_textured( + block_id: u16, + meta: u8, + textures: Option<&crate::textures::TexturePalette>, +) -> [u8; 3] { + if let Some(textures) = textures { + if let Some(name) = crate::block_names::texture_name(block_id, meta) { + if let Some(color) = textures.get(name) { + return color; + } + } + } + color_for(block_id, meta) +} + pub fn color_for(block_id: u16, meta: u8) -> [u8; 3] { match block_id { 0 => AIR_COLOR, @@ -124,6 +145,40 @@ mod tests { assert_eq!(color_for(9999, 0), UNKNOWN_COLOR); } + #[test] + fn textured_lookup_without_a_palette_matches_the_hand_picked_color() { + assert_eq!(color_for_textured(1, 0, None), color_for(1, 0)); + } + + #[test] + fn textured_lookup_prefers_the_palette_when_a_name_and_color_both_exist() { + let mut textures = crate::textures::TexturePalette::default(); + textures.overlay({ + let mut p = crate::textures::TexturePalette::default(); + p.insert("stone", [9, 9, 9]); + p + }); + assert_eq!(color_for_textured(1, 0, Some(&textures)), [9, 9, 9]); + } + + #[test] + fn textured_lookup_falls_back_when_the_palette_lacks_this_blocks_texture_name() { + // id 1 maps to "stone" (see block_names::texture_name) — a palette with unrelated + // entries must not accidentally match it. + let mut textures = crate::textures::TexturePalette::default(); + textures.insert("dirt", [9, 9, 9]); + assert_eq!(color_for_textured(1, 0, Some(&textures)), color_for(1, 0)); + } + + #[test] + fn textured_lookup_falls_back_for_deliberately_tinted_blocks_even_with_a_palette() { + // grass block (id 2) has no texture_name mapping (biome-tinted, see block_names.rs) — + // must keep the hand-picked green even if a palette happens to have a "grass_block_top". + let mut textures = crate::textures::TexturePalette::default(); + textures.insert("grass_block_top", [1, 1, 1]); + assert_eq!(color_for_textured(2, 0, Some(&textures)), color_for(2, 0)); + } + #[test] fn unknown_color_is_distinct_from_every_real_terrain_color() { // The whole point of UNKNOWN_COLOR is to be visually obvious — assert it doesn't diff --git a/worker/src/render/mod.rs b/worker/src/render/mod.rs index 9a61114..e560baa 100644 --- a/worker/src/render/mod.rs +++ b/worker/src/render/mod.rs @@ -8,6 +8,24 @@ pub use hybrid::HybridRenderBackend; use image::{ImageEncoder, RgbImage}; use std::io::Cursor; +use std::sync::OnceLock; + +use crate::textures::TexturePalette; + +/// Set once at startup (see main.rs) if `ACCEPT_MINECRAFT_EULA` is set and a texture palette was +/// built/loaded successfully — read by `base_colors` below so every `RenderBackend` (cpu/gpu/ +/// hybrid all share `base_colors`) automatically picks up texture-averaged colors without the +/// `RenderBackend` trait itself needing a new parameter. Left unset in tests and when the operator +/// hasn't opted into the EULA, which keeps `base_colors`/hand-picked-color test assertions valid — +/// `palette::color_for_textured` falls back to `palette::color_for` whenever this is `None`. +static TEXTURE_PALETTE: OnceLock = OnceLock::new(); + +/// Called at most once, before the first render (see main.rs). A second call is a no-op (`OnceLock` +/// semantics) — main.rs only ever calls this once anyway, since the palette is resolved once at +/// startup, not per-request. +pub fn set_texture_palette(palette: TexturePalette) { + let _ = TEXTURE_PALETTE.set(palette); +} /// One rendered column within a chunk, in chunk-local coordinates (0..16). pub struct ColumnPixel { @@ -53,12 +71,13 @@ pub trait RenderBackend: Send + Sync { /// — palette lookup is cheap (256 entries at most) and keeping it in one place avoids maintaining /// two copies of `palette::color_for`'s logic (one in Rust, one duplicated into WGSL). pub(crate) fn base_colors(columns: &[ColumnPixel]) -> [[u8; 3]; 256] { + let textures = TEXTURE_PALETTE.get(); let mut base = [crate::palette::color_for(0, 0); 256]; // air everywhere until overwritten for col in columns { if col.local_x as u32 >= CHUNK_SIZE || col.local_z as u32 >= CHUNK_SIZE { continue; } - let color = crate::palette::color_for(col.block_id, col.block_meta); + let color = crate::palette::color_for_textured(col.block_id, col.block_meta, textures); base[(col.local_z as usize) * 16 + col.local_x as usize] = color; } base diff --git a/worker/src/textures.rs b/worker/src/textures.rs new file mode 100644 index 0000000..aa59103 --- /dev/null +++ b/worker/src/textures.rs @@ -0,0 +1,240 @@ +use std::collections::HashMap; +use std::io::{Cursor, Read}; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +/// Texture file stem (e.g. "stone", "oak_planks") -> averaged RGB color, built by downloading and +/// averaging Minecraft's own textures (see `load_or_build`) rather than hand-picking colors like +/// `palette.rs`'s static table (Phase 11). Kept as its own map — not merged into `palette.rs` +/// directly — so "real average" vs. "hand-picked fallback" stay independently inspectable/ +/// testable; `palette::color_for_textured` is what layers them, and `block_names::texture_name` +/// is what maps a `(block_id, meta)` pair to the key this map is looked up by. +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct TexturePalette { + colors: HashMap, +} + +impl TexturePalette { + pub fn get(&self, name: &str) -> Option<[u8; 3]> { + self.colors.get(name).copied() + } + + pub fn insert(&mut self, name: impl Into, color: [u8; 3]) { + self.colors.insert(name.into(), color); + } + + pub fn len(&self) -> usize { + self.colors.len() + } + + /// Inserts/overwrites entries from `other` on top of `self` — used to layer a `texturepacks/` + /// operator override on top of the downloaded vanilla defaults (see the worker README note). + pub fn overlay(&mut self, other: TexturePalette) { + self.colors.extend(other.colors); + } +} + +/// Averages every non-fully-transparent pixel's RGB channels into a single representative color. +/// Pure/unit-testable on its own, separate from the network + zip-extraction plumbing around it — +/// matches this crate's existing pattern of keeping pure logic independently testable from I/O +/// (e.g. `config::resolve_thread_count`). +pub fn average_rgb(img: &image::RgbaImage) -> [u8; 3] { + let mut r_sum: u64 = 0; + let mut g_sum: u64 = 0; + let mut b_sum: u64 = 0; + let mut count: u64 = 0; + for pixel in img.pixels() { + let [r, g, b, a] = pixel.0; + if a == 0 { + continue; + } + r_sum += r as u64; + g_sum += g as u64; + b_sum += b as u64; + count += 1; + } + if count == 0 { + return [0, 0, 0]; + } + [(r_sum / count) as u8, (g_sum / count) as u8, (b_sum / count) as u8] +} + +/// Averages every `*.png` directly inside `dir` (non-recursive) into a `TexturePalette` keyed by +/// file stem — used both for a `texturepacks//` operator override directory and (indirectly, +/// via the same averaging logic) for textures extracted from the Mojang client jar. +pub fn average_directory(dir: &Path) -> anyhow::Result { + let mut colors = HashMap::new(); + if !dir.is_dir() { + return Ok(TexturePalette { colors }); + } + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("png") { + continue; + } + let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { continue }; + let Ok(img) = image::open(&path) else { continue }; + colors.insert(stem.to_string(), average_rgb(&img.to_rgba8())); + } + Ok(TexturePalette { colors }) +} + +/// Loads a cached palette from `/vanilla-.json` if present, otherwise +/// downloads the official Mojang client jar for `mc_version` (via the public version-manifest -> +/// per-version JSON -> `downloads.client.url` chain — the same endpoints the real launcher uses, +/// so this only ever fetches from Mojang directly, never redistributes anything) and averages +/// every `assets/minecraft/textures/block(s)/*.png` entry. Gated by the caller checking +/// `ACCEPT_MINECRAFT_EULA` first (see main.rs) — this function itself doesn't re-check that flag, +/// so it must never be called unless the operator has already opted in. +pub async fn load_or_build(cache_dir: &Path, mc_version: &str) -> anyhow::Result { + let cache_path = cache_dir.join(format!("vanilla-{mc_version}.json")); + if let Ok(bytes) = std::fs::read(&cache_path) { + if let Ok(palette) = serde_json::from_slice::(&bytes) { + println!( + "[worker] loaded cached vanilla texture palette ({} colors) from {}", + palette.len(), + cache_path.display() + ); + return Ok(palette); + } + } + + println!("[worker] downloading Minecraft {mc_version} client jar from Mojang to build the vanilla texture palette..."); + let client_jar = download_client_jar(mc_version).await?; + let palette = extract_palette(&client_jar)?; + + std::fs::create_dir_all(cache_dir)?; + std::fs::write(&cache_path, serde_json::to_vec(&palette)?)?; + println!( + "[worker] built vanilla texture palette ({} colors), cached to {}", + palette.len(), + cache_path.display() + ); + Ok(palette) +} + +#[derive(Deserialize)] +struct VersionManifest { + versions: Vec, +} + +#[derive(Deserialize)] +struct VersionEntry { + id: String, + url: String, +} + +#[derive(Deserialize)] +struct VersionMeta { + downloads: Downloads, +} + +#[derive(Deserialize)] +struct Downloads { + client: DownloadInfo, +} + +#[derive(Deserialize)] +struct DownloadInfo { + url: String, +} + +async fn download_client_jar(mc_version: &str) -> anyhow::Result> { + let manifest: VersionManifest = + reqwest::get("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json") + .await? + .json() + .await?; + let entry = manifest.versions.into_iter().find(|v| v.id == mc_version).ok_or_else(|| { + anyhow::anyhow!("Minecraft version '{mc_version}' not found in Mojang's version manifest") + })?; + let meta: VersionMeta = reqwest::get(&entry.url).await?.json().await?; + let jar_bytes = reqwest::get(&meta.downloads.client.url).await?.bytes().await?; + Ok(jar_bytes.to_vec()) +} + +fn extract_palette(jar_bytes: &[u8]) -> anyhow::Result { + let mut archive = zip::ZipArchive::new(Cursor::new(jar_bytes))?; + let mut colors = HashMap::new(); + for i in 0..archive.len() { + let mut file = archive.by_index(i)?; + let name = file.name().to_string(); + // 1.13+ renamed textures/blocks/ to textures/block/ — support both so `MC_TEXTURE_VERSION` + // can point at either era (this project's priority targets, 1.7.10/1.12.2, use `blocks/`). + let is_block_texture = (name.starts_with("assets/minecraft/textures/block/") + || name.starts_with("assets/minecraft/textures/blocks/")) + && name.ends_with(".png"); + if !is_block_texture { + continue; + } + let Some(stem) = name.rsplit('/').next().and_then(|f| f.strip_suffix(".png")) else { + continue; + }; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + let Ok(img) = image::load_from_memory(&bytes) else { + continue; // a handful of non-image entries can share the extension in odd jars + }; + colors.insert(stem.to_string(), average_rgb(&img.to_rgba8())); + } + Ok(TexturePalette { colors }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn average_rgb_of_a_solid_color_image_is_that_color() { + let mut img = image::RgbaImage::new(4, 4); + for p in img.pixels_mut() { + *p = image::Rgba([100, 150, 200, 255]); + } + assert_eq!(average_rgb(&img), [100, 150, 200]); + } + + #[test] + fn fully_transparent_pixels_are_excluded_from_the_average() { + let mut img = image::RgbaImage::new(2, 1); + img.put_pixel(0, 0, image::Rgba([255, 255, 255, 255])); + img.put_pixel(1, 0, image::Rgba([0, 0, 0, 0])); + assert_eq!(average_rgb(&img), [255, 255, 255]); + } + + #[test] + fn a_fully_transparent_image_falls_back_to_black_rather_than_dividing_by_zero() { + let img = image::RgbaImage::new(2, 2); + assert_eq!(average_rgb(&img), [0, 0, 0]); + } + + #[test] + fn empty_palette_has_no_colors() { + let palette = TexturePalette::default(); + assert_eq!(palette.get("stone"), None); + assert_eq!(palette.len(), 0); + } + + #[test] + fn overlay_adds_and_overwrites_entries() { + let mut base = TexturePalette::default(); + base.colors.insert("stone".into(), [1, 1, 1]); + base.colors.insert("dirt".into(), [2, 2, 2]); + + let mut over = TexturePalette::default(); + over.colors.insert("stone".into(), [9, 9, 9]); + over.colors.insert("sand".into(), [3, 3, 3]); + + base.overlay(over); + assert_eq!(base.get("stone"), Some([9, 9, 9])); // overwritten + assert_eq!(base.get("dirt"), Some([2, 2, 2])); // untouched + assert_eq!(base.get("sand"), Some([3, 3, 3])); // added + } + + #[test] + fn average_directory_on_a_missing_path_returns_an_empty_palette_not_an_error() { + let palette = average_directory(Path::new("/definitely/does/not/exist")).unwrap(); + assert_eq!(palette.len(), 0); + } +}