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
+45
View File
@@ -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 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`). 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/<name>/`** (flat `*.png` files, mirrors Dynmap) lets an operator override the
downloaded defaults — set `TEXTURE_PACK=<name>` 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 ## Running
``` ```
+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" }); 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 () => { test("returns not_found for a nonexistent server", async () => {
const result = await updateServerSettings(crypto.randomUUID(), { name: "x" }); const result = await updateServerSettings(crypto.randomUUID(), { name: "x" });
expect(result).toEqual({ ok: false, error: "not_found" }); 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_AUTH_MODES = ["online", "offline"];
const VALID_WAYPOINT_FORMATS = ["journeymap", "xaero", "off"]; 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 * 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; anonymousChatAllowed: boolean;
waypointFormat: string; waypointFormat: string;
playerPositionsVisible: boolean; playerPositionsVisible: boolean;
texturePack: string | null;
}>; }>;
export type UpdateServerSettingsResult = export type UpdateServerSettingsResult =
@@ -62,6 +67,13 @@ export async function updateServerSettings(
if (input.waypointFormat !== undefined && !VALID_WAYPOINT_FORMATS.includes(input.waypointFormat)) { if (input.waypointFormat !== undefined && !VALID_WAYPOINT_FORMATS.includes(input.waypointFormat)) {
return { ok: false, error: "invalid_waypoint_format" }; 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(); const [server] = await db.update(servers).set(input).where(eq(servers.id, id)).returning();
if (!server) return { ok: false, error: "not_found" }; 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 // 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. // doc comment) — this is the backend-side "should we show it" toggle, admin-configurable.
playerPositionsVisible: boolean("player_positions_visible").notNull().default(true), 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(), 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. // 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 // One row per (server, dimension, zoom, tileX, tileZ); zoom is always 0 until Phase 2 adds
// multi-resolution tiles. // multi-resolution tiles.
+4 -1
View File
@@ -260,12 +260,14 @@ export const app = new Elysia()
set.status = 401; set.status = 401;
return { error: "unauthenticated" }; return { error: "unauthenticated" };
} }
const { name, authMode, anonymousChatAllowed, waypointFormat, playerPositionsVisible } = body as { const { name, authMode, anonymousChatAllowed, waypointFormat, playerPositionsVisible, texturePack } =
body as {
name?: string; name?: string;
authMode?: string; authMode?: string;
anonymousChatAllowed?: boolean; anonymousChatAllowed?: boolean;
waypointFormat?: string; waypointFormat?: string;
playerPositionsVisible?: boolean; playerPositionsVisible?: boolean;
texturePack?: string | null;
}; };
const result = await updateServerSettings(params.id, { const result = await updateServerSettings(params.id, {
name, name,
@@ -273,6 +275,7 @@ export const app = new Elysia()
anonymousChatAllowed, anonymousChatAllowed,
waypointFormat, waypointFormat,
playerPositionsVisible, playerPositionsVisible,
texturePack,
}); });
if (!result.ok) set.status = result.error === "not_found" ? 404 : 400; if (!result.ok) set.status = result.error === "not_found" ? 404 : 400;
return result; 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 { storeLinkCode } from "./link";
import { recordAndPublishChat } from "./chat"; import { recordAndPublishChat } from "./chat";
import { arePlayerPositionsVisible, publishPlayerPositions, type PlayerPosition } from "./players"; 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: // 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.ts's publishPlayerPositions to whatever browsers are subscribed on /ws/players/:serverId
// (players-gateway.ts), gated on the per-server `playerPositionsVisible` admin toggle (independent // (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). // 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 { interface Column {
x: number; x: number;
@@ -219,6 +232,18 @@ export const wsGateway = {
await publishPlayerPositions(state.serverId, dimension, players); await publishPlayerPositions(state.serverId, dimension, players);
return; 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) { close(ws: any) {
+2
View File
@@ -47,6 +47,7 @@ test.describe("admin panel", () => {
const row = page.locator('[data-testid="admin-server-row"][data-server-name="e2e-server"]'); 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-waypointformat").selectOption("xaero");
await row.getByTestId("admin-server-anonchat").uncheck(); 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 row.getByTestId("admin-server-save").click();
await expect(row.getByTestId("admin-server-status")).toHaveText("saved"); 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"]'); 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-waypointformat")).toHaveValue("xaero");
await expect(reloadedRow.getByTestId("admin-server-anonchat")).not.toBeChecked(); 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 }) => { test("deleting a server removes it from the list", async ({ page }) => {
+3
View File
@@ -61,6 +61,9 @@ function adminpanel() {
anonymousChatAllowed: server.anonymousChatAllowed, anonymousChatAllowed: server.anonymousChatAllowed,
waypointFormat: server.waypointFormat, waypointFormat: server.waypointFormat,
playerPositionsVisible: server.playerPositionsVisible, 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"; server.status = res.ok ? "saved" : "save failed";
+5
View File
@@ -53,6 +53,11 @@ html(lang="en")
label.flex.items-center.gap-1.text-xs.text-neutral-300 label.flex.items-center.gap-1.text-xs.text-neutral-300
input(type="checkbox" x-model="server.playerPositionsVisible" data-testid="admin-server-playerpositions") input(type="checkbox" x-model="server.playerPositionsVisible" data-testid="admin-server-playerpositions")
| player positions | 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 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-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 button.px-2.py-1.bg-red-800.rounded.text-xs(x-on:click="removeServer(server)" data-testid="admin-server-delete") Delete
+20
View File
@@ -28,3 +28,23 @@ RENDER_THREADS=auto
# fewer, larger batches, less scheduling overhead per chunk on fewer/faster cores. Unrecognized # 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. # values fall back to `server`, matching this project's own primary deployment target.
RENDER_PROFILE=server 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/<name>/` 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=
+269 -18
View File
@@ -29,6 +29,15 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" 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]] [[package]]
name = "arc-swap" name = "arc-swap"
version = "1.9.2" version = "1.9.2"
@@ -729,6 +738,23 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" 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]] [[package]]
name = "cmake" name = "cmake"
version = "0.1.58" version = "0.1.58"
@@ -910,7 +936,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [ dependencies = [
"generic-array", "generic-array",
"rand_core", "rand_core 0.6.4",
"subtle", "subtle",
"zeroize", "zeroize",
] ]
@@ -960,6 +986,17 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" 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]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -1053,7 +1090,7 @@ dependencies = [
"group", "group",
"pem-rfc7468", "pem-rfc7468",
"pkcs8", "pkcs8",
"rand_core", "rand_core 0.6.4",
"sec1", "sec1",
"subtle", "subtle",
"zeroize", "zeroize",
@@ -1117,7 +1154,7 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [ dependencies = [
"rand_core", "rand_core 0.6.4",
"subtle", "subtle",
] ]
@@ -1297,8 +1334,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi", "wasi",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1308,8 +1347,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi", "r-efi",
"rand_core 0.10.1",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1402,7 +1444,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [ dependencies = [
"ff", "ff",
"rand_core", "rand_core 0.6.4",
"subtle", "subtle",
] ]
@@ -1685,6 +1727,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls 0.26.4", "tokio-rustls 0.26.4",
"tower-service", "tower-service",
"webpki-roots 1.0.9",
] ]
[[package]] [[package]]
@@ -2012,6 +2055,12 @@ dependencies = [
"hashbrown 0.16.1", "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]] [[package]]
name = "malloc_buf" name = "malloc_buf"
version = "0.0.6" version = "0.0.6"
@@ -2034,10 +2083,14 @@ dependencies = [
"image", "image",
"rayon", "rayon",
"redis", "redis",
"reqwest",
"serde",
"serde_json",
"sqlx", "sqlx",
"tokio", "tokio",
"uuid", "uuid",
"wgpu", "wgpu",
"zip",
] ]
[[package]] [[package]]
@@ -2121,12 +2174,12 @@ dependencies = [
"arrayvec", "arrayvec",
"bit-set", "bit-set",
"bitflags 2.13.1", "bitflags 2.13.1",
"cfg_aliases", "cfg_aliases 0.1.1",
"codespan-reporting", "codespan-reporting",
"hexf-parse", "hexf-parse",
"indexmap", "indexmap",
"log", "log",
"rustc-hash", "rustc-hash 1.1.0",
"spirv", "spirv",
"termcolor", "termcolor",
"thiserror 1.0.69", "thiserror 1.0.69",
@@ -2163,7 +2216,7 @@ dependencies = [
"num-integer", "num-integer",
"num-iter", "num-iter",
"num-traits", "num-traits",
"rand", "rand 0.8.7",
"smallvec", "smallvec",
"zeroize", "zeroize",
] ]
@@ -2410,6 +2463,62 @@ version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" 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]] [[package]]
name = "quote" name = "quote"
version = "1.0.47" version = "1.0.47"
@@ -2433,7 +2542,18 @@ checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha", "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]] [[package]]
@@ -2443,7 +2563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [ dependencies = [
"ppv-lite86", "ppv-lite86",
"rand_core", "rand_core 0.6.4",
] ]
[[package]] [[package]]
@@ -2455,6 +2575,21 @@ dependencies = [
"getrandom 0.2.17", "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]] [[package]]
name = "range-alloc" name = "range-alloc"
version = "0.1.5" version = "0.1.5"
@@ -2541,6 +2676,44 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" 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]] [[package]]
name = "rfc6979" name = "rfc6979"
version = "0.4.0" version = "0.4.0"
@@ -2578,7 +2751,7 @@ dependencies = [
"num-traits", "num-traits",
"pkcs1", "pkcs1",
"pkcs8", "pkcs8",
"rand_core", "rand_core 0.6.4",
"signature", "signature",
"spki", "spki",
"subtle", "subtle",
@@ -2591,6 +2764,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@@ -2645,6 +2824,7 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [ dependencies = [
"web-time",
"zeroize", "zeroize",
] ]
@@ -2878,7 +3058,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [ dependencies = [
"digest 0.10.7", "digest 0.10.7",
"rand_core", "rand_core 0.6.4",
] ]
[[package]] [[package]]
@@ -3082,7 +3262,7 @@ dependencies = [
"memchr", "memchr",
"once_cell", "once_cell",
"percent-encoding", "percent-encoding",
"rand", "rand 0.8.7",
"rsa", "rsa",
"serde", "serde",
"sha1 0.10.7", "sha1 0.10.7",
@@ -3121,7 +3301,7 @@ dependencies = [
"md-5 0.10.6", "md-5 0.10.6",
"memchr", "memchr",
"once_cell", "once_cell",
"rand", "rand 0.8.7",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.9", "sha2 0.10.9",
@@ -3210,6 +3390,15 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "synstructure" name = "synstructure"
version = "0.13.2" version = "0.13.2"
@@ -3404,10 +3593,33 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [ dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer", "tower-layer",
"tower-service", "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]] [[package]]
name = "tower-layer" name = "tower-layer"
version = "0.3.3" version = "0.3.3"
@@ -3648,6 +3860,16 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "0.26.11" version = "0.26.11"
@@ -3673,7 +3895,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80f70000db37c469ea9d67defdc13024ddf9a5f1b89cb2941b812ad7cde1735a" checksum = "80f70000db37c469ea9d67defdc13024ddf9a5f1b89cb2941b812ad7cde1735a"
dependencies = [ dependencies = [
"arrayvec", "arrayvec",
"cfg_aliases", "cfg_aliases 0.1.1",
"document-features", "document-features",
"js-sys", "js-sys",
"log", "log",
@@ -3700,7 +3922,7 @@ dependencies = [
"arrayvec", "arrayvec",
"bit-vec", "bit-vec",
"bitflags 2.13.1", "bitflags 2.13.1",
"cfg_aliases", "cfg_aliases 0.1.1",
"document-features", "document-features",
"indexmap", "indexmap",
"log", "log",
@@ -3709,7 +3931,7 @@ dependencies = [
"parking_lot", "parking_lot",
"profiling", "profiling",
"raw-window-handle", "raw-window-handle",
"rustc-hash", "rustc-hash 1.1.0",
"smallvec", "smallvec",
"thiserror 1.0.69", "thiserror 1.0.69",
"wgpu-hal", "wgpu-hal",
@@ -3729,7 +3951,7 @@ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"block", "block",
"bytemuck", "bytemuck",
"cfg_aliases", "cfg_aliases 0.1.1",
"core-graphics-types", "core-graphics-types",
"glow", "glow",
"glutin_wgl_sys", "glutin_wgl_sys",
@@ -3751,7 +3973,7 @@ dependencies = [
"range-alloc", "range-alloc",
"raw-window-handle", "raw-window-handle",
"renderdoc-sys", "renderdoc-sys",
"rustc-hash", "rustc-hash 1.1.0",
"smallvec", "smallvec",
"thiserror 1.0.69", "thiserror 1.0.69",
"wasm-bindgen", "wasm-bindgen",
@@ -4130,8 +4352,37 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.23" version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" 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",
]
+4
View File
@@ -17,6 +17,10 @@ aws-credential-types = "1"
base64 = "0.22" base64 = "0.22"
wgpu = "23" wgpu = "23"
bytemuck = { version = "1", features = ["derive"] } 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] [profile.release]
lto = true lto = true
+105
View File
@@ -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);
}
}
+2
View File
@@ -1,6 +1,8 @@
pub mod block_names;
pub mod config; pub mod config;
pub mod db; pub mod db;
pub mod mesh; pub mod mesh;
pub mod palette; pub mod palette;
pub mod render; pub mod render;
pub mod storage; pub mod storage;
pub mod textures;
+41 -1
View File
@@ -2,7 +2,7 @@ use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use base64::{engine::general_purpose::STANDARD, Engine as _}; 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 rayon::prelude::*;
use redis::streams::{StreamReadOptions, StreamReadReply}; use redis::streams::{StreamReadOptions, StreamReadReply};
use redis::AsyncCommands; use redis::AsyncCommands;
@@ -46,6 +46,46 @@ async fn main() -> anyhow::Result<()> {
.xgroup_create_mkstream(DIRTY_CHUNK_STREAM, CONSUMER_GROUP, "$") .xgroup_create_mkstream(DIRTY_CHUNK_STREAM, CONSUMER_GROUP, "$")
.await; .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/<name>/ (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 requested_backend = std::env::var("RENDER_BACKEND").unwrap_or_else(|_| "cpu".into());
let backend: Box<dyn RenderBackend> = match requested_backend.as_str() { let backend: Box<dyn RenderBackend> = match requested_backend.as_str() {
"gpu" => match GpuRenderBackend::try_new().await { "gpu" => match GpuRenderBackend::try_new().await {
+55
View File
@@ -6,6 +6,27 @@
const UNKNOWN_COLOR: [u8; 3] = [204, 102, 204]; const UNKNOWN_COLOR: [u8; 3] = [204, 102, 204];
const AIR_COLOR: [u8; 3] = [30, 30, 40]; 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] { pub fn color_for(block_id: u16, meta: u8) -> [u8; 3] {
match block_id { match block_id {
0 => AIR_COLOR, 0 => AIR_COLOR,
@@ -124,6 +145,40 @@ mod tests {
assert_eq!(color_for(9999, 0), UNKNOWN_COLOR); 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] #[test]
fn unknown_color_is_distinct_from_every_real_terrain_color() { 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 // The whole point of UNKNOWN_COLOR is to be visually obvious — assert it doesn't
+20 -1
View File
@@ -8,6 +8,24 @@ pub use hybrid::HybridRenderBackend;
use image::{ImageEncoder, RgbImage}; use image::{ImageEncoder, RgbImage};
use std::io::Cursor; 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<TexturePalette> = 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). /// One rendered column within a chunk, in chunk-local coordinates (0..16).
pub struct ColumnPixel { 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 /// — 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). /// 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] { 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 let mut base = [crate::palette::color_for(0, 0); 256]; // air everywhere until overwritten
for col in columns { for col in columns {
if col.local_x as u32 >= CHUNK_SIZE || col.local_z as u32 >= CHUNK_SIZE { if col.local_x as u32 >= CHUNK_SIZE || col.local_z as u32 >= CHUNK_SIZE {
continue; 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[(col.local_z as usize) * 16 + col.local_x as usize] = color;
} }
base base
+240
View File
@@ -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<String, [u8; 3]>,
}
impl TexturePalette {
pub fn get(&self, name: &str) -> Option<[u8; 3]> {
self.colors.get(name).copied()
}
pub fn insert(&mut self, name: impl Into<String>, 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/<name>/` 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<TexturePalette> {
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 `<cache_dir>/vanilla-<version>.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<TexturePalette> {
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::<TexturePalette>(&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<VersionEntry>,
}
#[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<Vec<u8>> {
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<TexturePalette> {
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);
}
}