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:
@@ -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/<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
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "servers" ADD COLUMN IF NOT EXISTS "texture_pack" text;
|
||||
@@ -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")
|
||||
);
|
||||
@@ -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" });
|
||||
|
||||
@@ -4,6 +4,10 @@ import { servers } from "./db/schema";
|
||||
|
||||
const VALID_AUTH_MODES = ["online", "offline"];
|
||||
const VALID_WAYPOINT_FORMATS = ["journeymap", "xaero", "off"];
|
||||
// Matches a bare directory name only (no `/`, `..`, or other path-traversal-shaped input) — this
|
||||
// is used to build a `texturepacks/<name>/` path on the worker's filesystem (see textures.rs's
|
||||
// `average_directory`), so it's validated here even though nothing reads it yet at render time.
|
||||
const TEXTURE_PACK_NAME = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
/**
|
||||
* Admin routes are gated on a single shared secret (`MCMAPPER_ADMIN_TOKEN`), not a per-account
|
||||
@@ -46,6 +50,7 @@ export type UpdateServerSettingsInput = Partial<{
|
||||
anonymousChatAllowed: boolean;
|
||||
waypointFormat: string;
|
||||
playerPositionsVisible: boolean;
|
||||
texturePack: string | null;
|
||||
}>;
|
||||
|
||||
export type UpdateServerSettingsResult =
|
||||
@@ -62,6 +67,13 @@ export async function updateServerSettings(
|
||||
if (input.waypointFormat !== undefined && !VALID_WAYPOINT_FORMATS.includes(input.waypointFormat)) {
|
||||
return { ok: false, error: "invalid_waypoint_format" };
|
||||
}
|
||||
if (
|
||||
input.texturePack !== undefined &&
|
||||
input.texturePack !== null &&
|
||||
!TEXTURE_PACK_NAME.test(input.texturePack)
|
||||
) {
|
||||
return { ok: false, error: "invalid_texture_pack" };
|
||||
}
|
||||
|
||||
const [server] = await db.update(servers).set(input).where(eq(servers.id, id)).returning();
|
||||
if (!server) return { ok: false, error: "not_found" };
|
||||
|
||||
@@ -18,6 +18,12 @@ export const servers = pgTable("servers", {
|
||||
// viewers. Independent of the mod-local `playerTrackingEnabled` config (see MCMapperMod.java's
|
||||
// doc comment) — this is the backend-side "should we show it" toggle, admin-configurable.
|
||||
playerPositionsVisible: boolean("player_positions_visible").notNull().default(true),
|
||||
// Phase 11: name of a `texturepacks/<name>/` directory on the worker (Dynmap-style operator
|
||||
// override, layered on top of the downloaded vanilla-texture-averaged defaults) — null means
|
||||
// "use the defaults". Recorded here so the choice is admin-configurable/persisted per server;
|
||||
// see worker/README's Phase 11 note for the current limitation on how this is actually applied
|
||||
// at render time (worker-wide via `TEXTURE_PACK`, not yet resolved per-job from this column).
|
||||
texturePack: text("texture_pack"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
@@ -151,6 +157,43 @@ export const meshPointers = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
// Phase 11: the mod's numeric-blockId -> registry-name dump (e.g. `4000 -> "botania:manapool"`),
|
||||
// sent once per connection since it's stable for a world's lifetime (see BackendConnection.java's
|
||||
// wire-protocol comment). Lets the worker eventually resolve `chunkColumns.blockId` (a numeric id,
|
||||
// meaningless without the mod list that assigned it) to a texture — see `blockTextures` below.
|
||||
// Currently write-only from the api's perspective: nothing reads this table yet (the worker's
|
||||
// texture-averaged rendering is still one process-wide default palette, not per-server-resolved —
|
||||
// see worker/README's Phase 11 note), stored now so the data isn't lost once that wiring lands.
|
||||
export const blockRegistry = pgTable(
|
||||
"block_registry",
|
||||
{
|
||||
serverId: uuid("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
blockId: integer("block_id").notNull(),
|
||||
name: text("name").notNull(),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.serverId, table.blockId] })],
|
||||
);
|
||||
|
||||
// Phase 11: metadata pointer to one modded block's texture PNG (extracted by the mod off its own
|
||||
// classloader — Forge doesn't split client/server jars, so these assets are already present on a
|
||||
// dedicated server's classpath, just unused server-side) in the shared MinIO bucket. Mirrors
|
||||
// `tilePointers`/`meshPointers`'s "binary in MinIO, pointer in Postgres" shape, just populated
|
||||
// from the api side (this data arrives over the mod's WS connection) rather than by the worker.
|
||||
export const blockTextures = pgTable(
|
||||
"block_textures",
|
||||
{
|
||||
serverId: uuid("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
storageKey: text("storage_key").notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.serverId, table.name] })],
|
||||
);
|
||||
|
||||
// Metadata pointer to a rendered tile PNG in MinIO — the binary itself never touches Postgres.
|
||||
// One row per (server, dimension, zoom, tileX, tileZ); zoom is always 0 until Phase 2 adds
|
||||
// multi-resolution tiles.
|
||||
|
||||
+4
-1
@@ -260,12 +260,14 @@ export const app = new Elysia()
|
||||
set.status = 401;
|
||||
return { error: "unauthenticated" };
|
||||
}
|
||||
const { name, authMode, anonymousChatAllowed, waypointFormat, playerPositionsVisible } = body as {
|
||||
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,
|
||||
@@ -273,6 +275,7 @@ export const app = new Elysia()
|
||||
anonymousChatAllowed,
|
||||
waypointFormat,
|
||||
playerPositionsVisible,
|
||||
texturePack,
|
||||
});
|
||||
if (!result.ok) set.status = result.error === "not_found" ? 404 : 400;
|
||||
return result;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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() },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<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=
|
||||
|
||||
Generated
+269
-18
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+41
-1
@@ -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/<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 backend: Box<dyn RenderBackend> = match requested_backend.as_str() {
|
||||
"gpu" => match GpuRenderBackend::try_new().await {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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).
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user