diff --git a/src/main/core/clientAssets.ts b/src/main/core/clientAssets.ts new file mode 100644 index 0000000..e513f33 --- /dev/null +++ b/src/main/core/clientAssets.ts @@ -0,0 +1,233 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import AdmZip from 'adm-zip' +import { cacheDir } from '../paths' +import { log } from '../logger' +import { httpJson, downloadFile } from './net' +import { textureCandidates, wantsTexture, textureKey, assetVersion } from '@shared/textures' +import type { AssetStatus } from '@shared/textures' + +/** + * Item and block textures, taken from Mojang's own client jar (#127). + * + * The app used to hot-link `assets.mcasset.cloud` for every item icon. That is a + * third party in the middle of a private server's inventory, it tells them which + * items an operator is looking at, and on an air-gapped LAN — a normal place to + * run this — it renders a grid of broken images. + * + * Mojang publishes the client jar for every version and MSMS already downloads + * and sha1-verifies jars from that same manifest, so this needs no new trust and + * no new dependency. One download per Minecraft version, shared by every server + * on it. + * + * Nothing here is required for the app to work: with no assets downloaded, the + * lookup answers null and the caller draws what it drew before. + */ + +const MANIFEST = 'https://launchermeta.mojang.com/mc/game/version_manifest_v2.json' + +interface Manifest { + versions: { id: string; type: string; url: string }[] +} +interface VersionDetail { + downloads?: { client?: { url: string; sha1: string; size?: number } } +} + +/** `msms-data/cache/assets//`. Beside the other per-version data. */ +function versionDir(version: string): string { + return join(cacheDir(), 'assets', version) +} + +function indexPath(version: string): string { + return join(versionDir(version), 'index.json') +} + +/** + * In-flight downloads, keyed by version. + * + * Two servers on the same version opening a profile at once must not start two + * 28 MB downloads. The promise is shared, so the second caller waits for the + * first rather than racing it into the same directory. + */ +const inFlight = new Map>() + +function readIndex(version: string): string[] { + try { + const p = indexPath(version) + if (!existsSync(p)) return [] + const j = JSON.parse(readFileSync(p, 'utf-8')) as { keys?: string[] } + return Array.isArray(j.keys) ? j.keys : [] + } catch { + return [] + } +} + +function dirSizeMB(dir: string): number { + let n = 0 + try { + for (const f of readdirSync(dir)) { + try { + n += statSync(join(dir, f)).size + } catch { + // A file that vanished between listing and stat is not worth failing a + // size report over. + } + } + } catch { + return 0 + } + return Math.round((n / 1048576) * 10) / 10 +} + +export function assetStatus(mcVersion: string): AssetStatus { + const version = assetVersion(mcVersion) + if (!version) return { version: '', ready: false, count: 0, busy: false, sizeMB: 0 } + const keys = readIndex(version) + return { + version, + ready: keys.length > 0, + count: keys.length, + busy: inFlight.has(version), + sizeMB: keys.length ? dirSizeMB(versionDir(version)) : 0 + } +} + +/** + * One texture's PNG bytes, or null. + * + * Never downloads. A lookup that could start a 28 MB fetch would make drawing an + * inventory unpredictably slow, and this is called once per slot; fetching is + * something the operator asks for, once, in the UI. + */ +export function itemTexture(mcVersion: string, id: string): Buffer | null { + const version = assetVersion(mcVersion) + if (!version) return null + const dir = versionDir(version) + for (const cand of textureCandidates(id)) { + // `textureCandidates` only ever returns `item/x` or `block/x` with `x` + // matched against [a-z0-9_], so this cannot leave the directory. + const p = join(dir, cand.replace('/', '__') + '.png') + try { + if (existsSync(p)) return readFileSync(p) + } catch { + // Unreadable file: try the next candidate rather than failing the row. + } + } + return null +} + +/** Many at once, so drawing an inventory is one call rather than forty. */ +export function itemTextures(mcVersion: string, ids: string[]): Record { + const out: Record = {} + for (const id of ids.slice(0, 256)) { + if (out[id] !== undefined) continue + const png = itemTexture(mcVersion, id) + if (png) out[id] = 'data:image/png;base64,' + png.toString('base64') + } + return out +} + +async function resolveClientJar(version: string): Promise<{ url: string; sha1: string }> { + const m = await httpJson(MANIFEST) + const entry = m.versions.find((v) => v.id === version) + if (!entry) throw new Error('unknown-version') + const detail = await httpJson(entry.url) + const dl = detail.downloads?.client + // `downloads.client`, not `.server` — the same object carries both, and the + // server jar has no assets in it at all. + if (!dl?.url || !dl.sha1) throw new Error('no-client-jar-for-version') + return { url: dl.url, sha1: dl.sha1 } +} + +/** + * Download the client jar for this version and extract the two texture folders. + * + * Idempotent: with the textures already on disk it returns immediately without + * touching the network, which is what makes it safe to call from a button an + * operator may press twice. + */ +export async function ensureClientAssets( + mcVersion: string, + onProgress?: (pct: number, note: string) => void +): Promise { + const version = assetVersion(mcVersion) + if (!version) throw new Error('unknown-version') + const have = assetStatus(version) + if (have.ready) return have + const running = inFlight.get(version) + if (running) return running + + const task = (async (): Promise => { + const dir = versionDir(version) + mkdirSync(dir, { recursive: true }) + // Staged outside the cache: a jar half-written into the assets directory + // would be indistinguishable from a finished one on the next start. + const jar = join(tmpdir(), `msms-client-${version}-${Date.now()}.jar`) + try { + onProgress?.(0, 'resolving') + const { url, sha1 } = await resolveClientJar(version) + onProgress?.(2, 'downloading') + await downloadFile(url, jar, { + sha1, + timeoutMs: 120_000, + // 0-95% is the download; extraction is the rest. A bar that sits at 100 + // while a jar is still being unpacked is a bar that looks stuck. + onProgress: (got, total) => + onProgress?.(total ? 2 + Math.round((got / total) * 93) : 50, 'downloading') + }) + onProgress?.(96, 'extracting') + const zip = new AdmZip(jar) + const keys: string[] = [] + for (const e of zip.getEntries()) { + if (e.isDirectory) continue + const name = e.entryName.replace(/\\/g, '/') + if (!wantsTexture(name)) continue + const key = textureKey(name) + if (!key) continue + // Flattened with a separator that cannot occur in a texture name, so the + // directory stays one level deep and no entry name from the archive is + // ever used as a path. + writeFileSync(join(dir, key.replace('/', '__') + '.png'), e.getData()) + keys.push(key) + } + if (!keys.length) throw new Error('no-textures-in-jar') + writeFileSync(indexPath(version), JSON.stringify({ version, keys, at: Date.now() }), 'utf-8') + log.info(`Client assets: extracted ${keys.length} textures for ${version}`) + onProgress?.(100, 'done') + return assetStatus(version) + } catch (e) { + // A failed extraction must not leave a directory that looks half-ready. + try { + rmSync(dir, { recursive: true, force: true }) + } catch { + // Best effort; the missing index is what makes it not-ready anyway. + } + throw e + } finally { + try { + rmSync(jar, { force: true }) + } catch { + // A leftover temp jar is untidy, not broken. + } + inFlight.delete(version) + } + })() + + inFlight.set(version, task) + return task +} + +/** Drop one version's textures. Returns how many files went. */ +export function clearClientAssets(mcVersion: string): number { + const version = assetVersion(mcVersion) + if (!version) return 0 + const dir = versionDir(version) + const n = readIndex(version).length + try { + rmSync(dir, { recursive: true, force: true }) + } catch { + return 0 + } + return n +} diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index ab09a10..ea2c416 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -21,6 +21,7 @@ import * as mods from '../core/mods' import * as bridgeInstall from '../core/bridgeInstall' import * as worldTiles from '../core/worldTiles' import * as chunkAreas from '../core/chunkAreas' +import * as clientAssets from '../core/clientAssets' import { areaChunkCount } from '@shared/chunkAreas' import { normalizeMapPage } from '@shared/mapPage' import type { AreaInput } from '@shared/chunkAreas' @@ -491,6 +492,12 @@ export function registerIpc(): void { }) return k }) + // Item textures from the client jar (#127). + H(IPC.assetsStatus, (_e, v: string) => clientAssets.assetStatus(v)) + H(IPC.assetsEnsure, (_e, v: string) => clientAssets.ensureClientAssets(v)) + H(IPC.assetsTextures, (_e, v: string, ids: string[]) => + clientAssets.itemTextures(v, Array.isArray(ids) ? ids : []) + ) // Named chunk areas (#144). The rules and the store are shared with the HTTP // routes — this is the same call the panel makes, reached a different way. H(IPC.areasList, (_e, serverId: string) => chunkAreas.listAreas(serverId)) diff --git a/src/main/smoke.ts b/src/main/smoke.ts index de4a894..1f77218 100644 --- a/src/main/smoke.ts +++ b/src/main/smoke.ts @@ -128,6 +128,8 @@ import * as alertsMod from './core/alerts' import * as worldsMod from './core/worlds' import * as areasMod from '@shared/chunkAreas' import * as areasMod2 from './core/chunkAreas' +import * as tex from '@shared/textures' +import * as assetsMod from './core/clientAssets' import { isValidMcName, isValidWorldName, @@ -189,7 +191,7 @@ import { aggregateJoins, type JoinRecord } from '@shared/joins' import * as auditMod from './core/audit' import type { UptimeReport } from '@shared/uptime' import type { JavaArgsConfig, MetricSeries, ServerConfig, ServerEvent, ServerType } from '@shared/types' -import { alertsPath, uploadsDir, auditDir, dataDir } from './paths' +import { alertsPath, uploadsDir, auditDir, dataDir, cacheDir } from './paths' import { analyzeCrash } from './core/crash' import { CREATABLE_TYPES, createErrorKey } from '@shared/versions' @@ -2231,6 +2233,97 @@ export async function runWorldsSmoke(): Promise { rmSync(emptyZip, { force: true }) console.log('WORLDS-SMOKE: import refuses zip-slip and worldless archives, cleans up after itself') + // --- 12b. item textures from the client jar (#127) ---------------------- + { + // Which paths are worth trying. The old CDN only ever asked for + // `item/.png` and then `block/.png`, which is why a chest, a log + // or a grass block fell through to a three-letter text chip. + const cands = tex.textureCandidates('oak_log') + if (cands[0] !== 'item/oak_log') return fail('an item is not tried first: ' + cands[0]) + if (!cands.includes('block/oak_log')) return fail('the block folder is not tried') + if (!cands.includes('block/oak_log_top')) return fail('the top face is not tried') + // grass_block has no block/grass_block.png at all — only _top and _side. + if (!tex.textureCandidates('grass_block').includes('block/grass_block_side')) { + return fail('grass_block would still find nothing') + } + // A namespaced id is the same item. + if (tex.textureCandidates('minecraft:apple')[0] !== 'item/apple') return fail('the namespace was not stripped') + // An id from hand-edited NBT must not become a path. This is the only + // thing between a crafted inventory entry and the cache directory. + for (const bad of ['../../etc/passwd', 'a/b', '..', '', 'x'.repeat(80), 'Apple!', 'a b']) { + const c = tex.textureCandidates(bad) + if (c.length) return fail('an unsafe id produced candidates: ' + JSON.stringify(bad)) + } + + // What the extractor keeps. The client jar holds thousands of files and + // only two folders are ever looked up. + if (!tex.wantsTexture('assets/minecraft/textures/item/apple.png')) return fail('an item texture was skipped') + if (!tex.wantsTexture('assets/minecraft/textures/block/stone.png')) return fail('a block texture was skipped') + for (const skip of [ + 'assets/minecraft/textures/entity/creeper/creeper.png', + 'assets/minecraft/textures/item/apple.png.mcmeta', + 'assets/minecraft/lang/en_us.json', + '../../../evil.png', + 'assets/minecraft/textures/item/sub/dir.png' + ]) { + if (tex.wantsTexture(skip)) return fail('the extractor would keep ' + skip) + } + if (tex.textureKey('assets/minecraft/textures/block/stone.png') !== 'block/stone') { + return fail('the texture key is wrong') + } + + // Which version's jar. Snapshots and modded strings reduce to the release + // they are built on; there is no point downloading twenty snapshots to + // get the same apple. + for (const [given, want] of [ + ['1.21.4', '1.21.4'], + ['1.21', '1.21'], + ['1.21.4-pre2', '1.21.4'], + ['1.20.1-forge-47.2.0', '1.20.1'], + ['nonsense', ''], + ['', ''] + ] as const) { + if (tex.assetVersion(given) !== want) { + return fail('assetVersion(' + JSON.stringify(given) + ') = ' + tex.assetVersion(given)) + } + } + + // ...and the round trip, without the network: write a texture where the + // extractor would have put it and check the lookup finds it through the + // candidate list rather than only by an exact name. + const av = tex.assetVersion('1.21.4') + const adir = join(cacheDir(), 'assets', av) + const hadIndex = existsSync(join(adir, 'index.json')) + if (!hadIndex) { + mkdirSync(adir, { recursive: true }) + // A one-pixel PNG. Only the bytes coming back matter here. + const png = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64' + ) + writeFileSync(join(adir, 'block__grass_block_side.png'), png) + writeFileSync(join(adir, 'item__apple.png'), png) + writeFileSync(join(adir, 'index.json'), JSON.stringify({ version: av, keys: ['item/apple'] })) + try { + if (!assetsMod.itemTexture('1.21.4', 'apple')) return fail('a written item texture was not found') + // The case the CDN got wrong: found by suffix, not by exact name. + if (!assetsMod.itemTexture('1.21.4', 'grass_block')) return fail('grass_block was not found by alias') + if (assetsMod.itemTexture('1.21.4', 'nonexistent_thing')) return fail('a missing texture returned bytes') + if (assetsMod.itemTexture('1.21.4', '../../evil')) return fail('an unsafe id reached the disk') + const many = assetsMod.itemTextures('1.21.4', ['apple', 'apple', 'nonexistent_thing']) + if (!many['apple']?.startsWith('data:image/png;base64,')) return fail('the batch lookup returned no data url') + if (many['nonexistent_thing']) return fail('the batch lookup invented a texture') + if (!assetsMod.assetStatus('1.21.4').ready) return fail('the status does not see the index') + // An unknown version is not ready and does not throw — an operator on + // a modded string must still get an app, not an exception. + if (assetsMod.assetStatus('nonsense').ready) return fail('an unknown version claimed to be ready') + } finally { + rmSync(adir, { recursive: true, force: true }) + } + } + console.log('WORLDS-SMOKE: client-jar textures OK (candidates, extractor filter, safe ids, local lookup)') + } + // --- 12. chunk areas: the rules four map surfaces have to share (#144) --- { const at = (rs: number[][]): { x1: number; z1: number; x2: number; z2: number }[] => diff --git a/src/preload/index.ts b/src/preload/index.ts index 15e6d3c..79f5ff2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -83,6 +83,9 @@ const api: MsmsApi = { mapTiles: (id, dim, chunks, marks) => ipcRenderer.invoke(IPC.mapTiles, id, dim, chunks, marks), clearMapCache: () => ipcRenderer.invoke(IPC.mapCacheClear), setApiKeyDisabled: (id, disabled) => ipcRenderer.invoke(IPC.apiKeyDisabled, id, disabled), + assetStatus: (v) => ipcRenderer.invoke(IPC.assetsStatus, v), + ensureAssets: (v) => ipcRenderer.invoke(IPC.assetsEnsure, v), + itemTextures: (v, ids) => ipcRenderer.invoke(IPC.assetsTextures, v, ids), listChunkAreas: (serverId) => ipcRenderer.invoke(IPC.areasList, serverId), saveChunkArea: (serverId, input) => ipcRenderer.invoke(IPC.areasSave, serverId, input), deleteChunkArea: (serverId, areaId) => ipcRenderer.invoke(IPC.areasDelete, serverId, areaId), diff --git a/src/renderer/src/components/ItemIcon.tsx b/src/renderer/src/components/ItemIcon.tsx index e1c2704..1fe38fd 100644 --- a/src/renderer/src/components/ItemIcon.tsx +++ b/src/renderer/src/components/ItemIcon.tsx @@ -1,43 +1,53 @@ import { useState, useEffect } from 'react' /** - * Minecraft item/block icon from an online, always-updatable source - * (assets.mcasset.cloud, which mirrors Mojang textures by version). - * Tries item/ then block/, then falls back to a text chip. + * A Minecraft item or block icon. + * + * Drawn from the textures MSMS extracted out of Mojang's own client jar (#127), + * which is why this takes a `src` rather than building a URL: the picture is a + * `data:` URI the main process produced from a local file, so there is no third + * party in the middle of a private server's inventory and it works with no + * internet at all. + * + * It used to hot-link `assets.mcasset.cloud`. That host does not have a texture + * at `item/.png` for most blocks, so a chest, a log or a grass block fell + * through to the three-letter chip — the "icons aren't loading" this replaces. + * + * No `src` means the operator has not downloaded the assets for this version, or + * the id has no texture at all. The chip is the fallback, and it is not + * politeness: it is what keeps an inventory readable before anything has been + * downloaded. */ export function ItemIcon({ id, - version, + src, size = 32 }: { id: string - version?: string + src?: string size?: number }): JSX.Element { - const ver = version && /^1\.\d+(\.\d+)?$/.test(version) ? version : '1.21.4' - const [stage, setStage] = useState(0) // 0=item, 1=block, 2=text - useEffect(() => setStage(0), [id, ver]) + const [broken, setBroken] = useState(false) + useEffect(() => setBroken(false), [src, id]) - if (stage >= 2) { + if (!src || broken) { return (
{id.slice(0, 3)}
) } - const folder = stage === 0 ? 'item' : 'block' - const url = `https://assets.mcasset.cloud/${ver}/assets/minecraft/textures/${folder}/${id}.png` return ( {id} setStage((s) => s + 1)} + onError={() => setBroken(true)} /> ) } diff --git a/src/renderer/src/locales/en.ts b/src/renderer/src/locales/en.ts index bce7969..8e1d2ab 100644 --- a/src/renderer/src/locales/en.ts +++ b/src/renderer/src/locales/en.ts @@ -462,6 +462,10 @@ export default { inventory: 'Inventory', enderChest: 'Ender chest', inventorySoon: 'Live inventory viewer is coming in a later update.', + assetsMissing: 'Item pictures for {{version}} are not downloaded yet.', + assetsGet: 'Download textures', + assetsFetching: 'Downloading…', + assetsReady: 'Item textures ready.', noInventory: 'No saved inventory (the player must have logged in and the world saved). Icons load from an online source.', clickHint: 'Click a player card for details and actions.', offline: 'Offline' diff --git a/src/renderer/src/locales/tr.ts b/src/renderer/src/locales/tr.ts index f2b47d3..bb145c5 100644 --- a/src/renderer/src/locales/tr.ts +++ b/src/renderer/src/locales/tr.ts @@ -465,6 +465,10 @@ const tr: typeof en = { inventory: 'Envanter', enderChest: 'Ender sandığı', inventorySoon: 'Canlı envanter görüntüleyici sonraki güncellemede gelecek.', + assetsMissing: '{{version}} için item görselleri henüz indirilmedi.', + assetsGet: 'Görselleri indir', + assetsFetching: 'İndiriliyor…', + assetsReady: 'Item görselleri hazır.', noInventory: 'Kayıtlı envanter yok (oyuncu giriş yapmış ve dünya kaydedilmiş olmalı). Simgeler çevrimiçi kaynaktan yüklenir.', clickHint: 'Ayrıntılar ve işlemler için bir oyuncu kartına tıklayın.', offline: 'Çevrimdışı' diff --git a/src/renderer/src/views/PlayersView.tsx b/src/renderer/src/views/PlayersView.tsx index be1a8c5..0a701dc 100644 --- a/src/renderer/src/views/PlayersView.tsx +++ b/src/renderer/src/views/PlayersView.tsx @@ -20,13 +20,13 @@ import { Drumstick, Sparkles, Clock, - Map as MapIcon -} from 'lucide-react' + Map as MapIcon, Download} from 'lucide-react' import { useStore } from '../store' import { PlayerAvatar } from '../components/PlayerAvatar' import { LiveMap } from '../components/LiveMap' import { ItemIcon } from '../components/ItemIcon' import type { PlayerInfo } from '@shared/types' +import type { AssetStatus } from '@shared/textures' const GAMEMODES = ['survival', 'creative', 'adventure', 'spectator'] const DIFFICULTIES = ['peaceful', 'easy', 'normal', 'hard'] @@ -98,6 +98,46 @@ export function PlayersView(): JSX.Element { void load() }, [load]) + // ---- item textures from the client jar (#127) ---- + const [textures, setTextures] = useState>({}) + const [assets, setAssets] = useState(null) + const [fetching, setFetching] = useState(false) + + useEffect(() => { + if (!mcVersion) return + window.msms.assetStatus(mcVersion).then(setAssets).catch(() => setAssets(null)) + }, [mcVersion]) + + // One call for the whole inventory rather than one per slot, and only for the + // ids actually on screen. + useEffect(() => { + const ids = [ + ...(selected?.inventory ?? []).map((i) => i.id), + ...(selected?.enderChest ?? []).map((i) => i.id) + ] + if (!mcVersion || !ids.length || !assets?.ready) { + setTextures({}) + return + } + window.msms + .itemTextures(mcVersion, [...new Set(ids)]) + .then(setTextures) + .catch(() => setTextures({})) + }, [mcVersion, selected, assets?.ready]) + + const downloadAssets = async (): Promise => { + if (!mcVersion) return + setFetching(true) + try { + setAssets(await window.msms.ensureAssets(mcVersion)) + toast('success', 'players.assetsReady') + } catch (e) { + toast('error', String(e)) + } finally { + setFetching(false) + } + } + useEffect(() => { if (!running) return const iv = setInterval(load, 5000) @@ -250,11 +290,24 @@ export function PlayersView(): JSX.Element { {t('players.inventory')} + {/* The offer, where the missing pictures are. Measured on 1.21.4: a + 27 MB jar from Mojang, of which 1683 textures and 0.4 MB are + kept — once per Minecraft version, shared by every server on it. + An explicit action rather than something that happens on open, + because 27 MB is not a thing to spend on somebody's behalf. */} + {mcVersion && !assets?.ready && (selected.inventory?.length ?? 0) > 0 && ( +
+ {t('players.assetsMissing', { version: assets?.version || mcVersion })} + +
+ )} {selected.inventory && selected.inventory.length > 0 ? (
{selected.inventory.map((it, i) => (
- + {it.count > 1 && {it.count}}
))} @@ -275,7 +328,7 @@ export function PlayersView(): JSX.Element {
{selected.enderChest.map((it, i) => (
- + {it.count > 1 && {it.count}}
))} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 11d723d..2bc9885 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -44,6 +44,7 @@ import type { ModEntry, ModrinthDetail, ModrinthHit, ModUpdateReport } from './m import type { BridgeInstallResult, BridgeStatus } from './bridgeRelease' import type { StructureMark } from './regionFormat' import type { AreaInput, ChunkArea } from './chunkAreas' +import type { AssetStatus } from './textures' import type { WebStatus, WebUserView, @@ -138,6 +139,9 @@ export const IPC = { mapTiles: 'map:tiles', mapCacheClear: 'map:cache-clear', apiKeyDisabled: 'apikey:disabled', + assetsStatus: 'assets:status', + assetsEnsure: 'assets:ensure', + assetsTextures: 'assets:textures', areasList: 'areas:list', areasSave: 'areas:save', areasDelete: 'areas:delete', @@ -363,6 +367,16 @@ export interface MsmsApi { /** Drop every cached region. Returns how many files went. */ clearMapCache(): Promise + /** + * Item textures from Mojang's client jar (#127), so an inventory draws with + * no third party in the middle and no internet at all. + */ + assetStatus(mcVersion: string): Promise + /** Download and extract them. One jar per Minecraft version. */ + ensureAssets(mcVersion: string): Promise + /** `id -> data:image/png;base64,...` for the ids that have a texture on disk. */ + itemTextures(mcVersion: string, ids: string[]): Promise> + /** * Named chunk areas (#144). The operator's own list — hidden areas included, * which is the difference between this and what the public site is served. diff --git a/src/shared/textures.ts b/src/shared/textures.ts new file mode 100644 index 0000000..f4dfe6f --- /dev/null +++ b/src/shared/textures.ts @@ -0,0 +1,117 @@ +/** + * Where a Minecraft item's picture lives inside the client jar (#127). + * + * Pure, because the answer is a list of guesses and guesses are exactly the kind + * of thing that should be testable without a 28 MB download. The extractor uses + * this to decide what to keep, and the lookup uses it to decide what to try. + */ + +/** What the app knows about one version's extracted textures. */ +export interface AssetStatus { + version: string + ready: boolean + /** How many textures are on disk. */ + count: number + /** True while a download for this version is in flight. */ + busy: boolean + sizeMB: number +} + +/** The two texture folders that matter. Order is the lookup order. */ +export const TEXTURE_ROOTS = ['item', 'block'] as const + +/** + * Suffixes tried for a BLOCK whose id names no texture of its own. + * + * Most blocks have `block/.png`, but the ones a player is most likely to be + * holding do not: `grass_block` is `grass_block_top` and `_side`, a door is + * `_top` and `_bottom`, a furnace is `_front`. Guessing the front-facing or top + * texture is what makes a chest look like a chest instead of a text chip. + * + * `_top` before `_front` before `_side`: for the blocks where several exist, the + * top is the face the inventory icon is drawn from. + */ +const BLOCK_SUFFIXES = ['_top', '_front', '_side', '_0', '_still', '_stage0'] + +/** + * A few ids whose texture shares no prefix with them at all, so no suffix rule + * can find it. Deliberately short: this is a list of exceptions, and every entry + * is a thing an operator will actually see in an inventory. + */ +const ALIASES: Record = { + // Items whose texture is named for the thing rather than the item. + wheat_seeds: 'item/wheat_seeds', + redstone: 'item/redstone', + // Blocks placed from an item with a different texture name. + cobweb: 'block/cobweb', + grass_block: 'block/grass_block_side', + dirt_path: 'block/dirt_path_top', + farmland: 'block/farmland', + water_bucket: 'item/water_bucket', + lava_bucket: 'item/lava_bucket', + // The three that are drawn from an entity texture and have no block/item png. + // Named so the lookup fails fast rather than trying eight paths. + chest: 'block/oak_planks', + trapped_chest: 'block/oak_planks', + ender_chest: 'block/obsidian' +} + +/** + * Every path worth trying for one id, most likely first. + * + * `id` is expected to be already namespace-stripped and validated — the caller + * is `itemIconId`, which refuses anything that is not a plain `[a-z0-9_]` id. + * An empty or unsafe id yields no candidates rather than a path to try, so a + * hand-edited NBT entry cannot reach into the cache directory. + */ +export function textureCandidates(id: string): string[] { + const clean = String(id || '').replace(/^minecraft:/, '').trim().toLowerCase() + if (!/^[a-z0-9_]{1,64}$/.test(clean)) return [] + const out: string[] = [] + const push = (p: string): void => { + if (!out.includes(p)) out.push(p) + } + const alias = ALIASES[clean] + if (alias) push(alias) + push('item/' + clean) + push('block/' + clean) + for (const s of BLOCK_SUFFIXES) push('block/' + clean + s) + return out +} + +/** + * Is this a texture the extractor should keep? + * + * The client jar holds thousands of files; only two folders are ever looked up, + * and keeping the rest would turn a cache into a copy of the jar. Animated + * textures ship a `.mcmeta` beside the png — the png itself is a vertical strip + * of frames, which draws as a squashed column, so those are skipped rather than + * shown wrong. + */ +export function wantsTexture(entryPath: string): boolean { + const m = /^assets\/minecraft\/textures\/(item|block)\/([a-z0-9_]+)\.png$/.exec( + entryPath.replace(/\\/g, '/') + ) + return !!m +} + +/** `assets/minecraft/textures/item/apple.png` -> `item/apple`. */ +export function textureKey(entryPath: string): string { + const m = /^assets\/minecraft\/textures\/(item|block)\/([a-z0-9_]+)\.png$/.exec( + entryPath.replace(/\\/g, '/') + ) + return m ? m[1] + '/' + m[2] : '' +} + +/** + * The Minecraft version whose assets should be used for a server. + * + * Snapshots, release candidates and modded version strings all reduce to the + * release they are built on, because Mojang publishes a client jar per version + * id and there is no point downloading twenty snapshots of one release to get + * the same apple. Anything unrecognisable yields '' and the caller falls back. + */ +export function assetVersion(mcVersion: string): string { + const m = /^(1\.\d{1,2}(?:\.\d{1,2})?)/.exec(String(mcVersion || '').trim()) + return m ? m[1] : '' +}