diff --git a/src/main/core/clientAssets.ts b/src/main/core/clientAssets.ts index e513f33..ef3c6d1 100644 --- a/src/main/core/clientAssets.ts +++ b/src/main/core/clientAssets.ts @@ -5,7 +5,9 @@ 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 { textureCandidates, wantsTexture, textureKey, assetVersion, isTintedTexture } from '@shared/textures' +import { setTextureColours } from '@shared/regionFormat' +import { decodePng, averageColour, firstFrame, pngSize } from './png' import type { AssetStatus } from '@shared/textures' /** @@ -109,7 +111,14 @@ export function itemTexture(mcVersion: string, id: string): Buffer | null { // 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) + if (!existsSync(p)) continue + const buf = readFileSync(p) + // An animated texture is a tall strip of frames. Drawn into a 30px square + // it is thirty-two pictures squashed into one, which reads as a smear — + // the text chip is the better answer until this can crop. + const size = pngSize(buf) + if (size && size.height > size.width) continue + return buf } catch { // Unreadable file: try the next candidate rather than failing the row. } @@ -194,6 +203,9 @@ export async function ensureClientAssets( 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}`) + // Right away, not on the next start: an operator who just downloaded + // textures should see the map change, not be told to restart. + loadBlockColours(version) onProgress?.(100, 'done') return assetStatus(version) } catch (e) { @@ -218,6 +230,81 @@ export async function ensureClientAssets( return task } +/** + * Average every extracted BLOCK texture and hand the result to the renderer. + * + * Called once after an extraction and once on start, not per tile: 1039 PNGs is + * a few hundred milliseconds, and the map asks for a colour thousands of times a + * frame. + * + * A texture that will not decode is skipped rather than defaulted, so it keeps + * the hand-written table's answer. That is the whole safety property here — the + * map that worked before this existed must still work if the decoder meets + * something it does not understand. + */ +export function loadBlockColours(mcVersion: string): number { + const version = assetVersion(mcVersion) + if (!version) return 0 + const dir = versionDir(version) + const out: Record = {} + for (const key of readIndex(version)) { + if (!key.startsWith('block/')) continue + const block = key.slice('block/'.length) + // Grey in the file, green on screen: the game multiplies these by a biome + // colour it decides at render time, and the raw average would replace the + // table's green with grey. Measured: grass_block_top averages #939393. + if (isTintedTexture(block)) continue + try { + const bm = decodePng(readFileSync(join(dir, 'block__' + block + '.png'))) + if (!bm) continue + // Water and lava are a vertical strip of frames in one file. Averaging + // the strip averages every frame at once. + const c = averageColour(firstFrame(bm)) + if (c === null) continue + out[block] = { r: (c >> 16) & 255, g: (c >> 8) & 255, b: c & 255 } + } catch { + // One unreadable texture is one block on the table's colour, not a + // failure of the map. + } + } + setTextureColours(out) + log.info(`Client assets: ${Object.keys(out).length} block colours from ${version} textures`) + return Object.keys(out).length +} + +/** + * On start: average whichever version's textures are already extracted. + * + * A colour table held only in memory would be right until the first restart and + * then quietly revert to the hand-written one — the sort of regression nobody + * reports because the map still draws, just differently than yesterday. + * + * Picks the version with the most textures when several are present. There is + * one colour table and several servers may be on different versions; the biggest + * extraction is the best single answer, and block colours barely move between + * releases anyway. + */ +export function initBlockColours(): void { + try { + const root = join(cacheDir(), 'assets') + if (!existsSync(root)) return + let best = '' + let bestN = 0 + for (const v of readdirSync(root)) { + const n = readIndex(v).length + if (n > bestN) { + best = v + bestN = n + } + } + if (best) loadBlockColours(best) + } catch (e) { + // The map has a table to fall back on; failing to start over a colour would + // be a poor trade. + log.warn('Client assets: could not load block colours:', e) + } +} + /** Drop one version's textures. Returns how many files went. */ export function clearClientAssets(mcVersion: string): number { const version = assetVersion(mcVersion) @@ -229,5 +316,8 @@ export function clearClientAssets(mcVersion: string): number { } catch { return 0 } + // Back to the table. Keeping averaged colours whose files have been deleted + // would make "clear" a thing that changed nothing visible. + setTextureColours({}) return n } diff --git a/src/main/core/png.ts b/src/main/core/png.ts new file mode 100644 index 0000000..52969cc --- /dev/null +++ b/src/main/core/png.ts @@ -0,0 +1,232 @@ +import { inflateSync } from 'node:zlib' + +/** + * A minimal PNG reader, for averaging block textures (#127). + * + * In `main/` rather than `shared/` for one reason: it needs `node:zlib`, and a + * shared module is fair game for the renderer to import — where a `node:` import + * is a broken bundle. It is still pure (bytes in, pixels out) and the smoke runs + * in the main process, so nothing about testing it is harder here. + * + * Deliberately small: it reads the PNGs Mojang ships and nothing else, and says + * so by returning null rather than guessing whenever it meets something it does + * not know. A null costs one block its averaged colour and falls back to the + * table — a wrong guess would put the wrong colour on the map and look like a + * rendering bug. + * + * What "the PNGs Mojang ships" means was measured, not assumed. Of the 1039 + * block textures in 1.21.4: 626 are 4-bit palette, 155 are 8-bit RGBA, 146 are + * 8-bit palette, 35 are 2-bit palette, 18 are 8-bit RGB and 7 are greyscale. + * The first version of this handled only depth 8 and silently skipped two + * thirds of them. + */ + +export interface Bitmap { + width: number + height: number + /** RGBA, 4 bytes per pixel, row-major. */ + data: Uint8Array +} + +const SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] + +/** Channels per pixel for each PNG colour type. Palette is one index. */ +const CHANNELS: Record = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 } + +function paeth(a: number, b: number, c: number): number { + const p = a + b - c + const pa = Math.abs(p - a) + const pb = Math.abs(p - b) + const pc = Math.abs(p - c) + return pa <= pb && pa <= pc ? a : pb <= pc ? b : c +} + +export function decodePng(buf: Buffer): Bitmap | null { + if (buf.length < 8) return null + for (let i = 0; i < 8; i++) if (buf[i] !== SIG[i]) return null + + let width = 0 + let height = 0 + let depth = 0 + let colour = 0 + let interlace = 0 + let palette: Buffer | null = null + let trns: Buffer | null = null + const idat: Buffer[] = [] + + let off = 8 + while (off + 8 <= buf.length) { + const len = buf.readUInt32BE(off) + const type = buf.toString('ascii', off + 4, off + 8) + const start = off + 8 + // A truncated file must not read past the end of the buffer. + if (start + len > buf.length) return null + if (type === 'IHDR') { + if (len < 13) return null + width = buf.readUInt32BE(start) + height = buf.readUInt32BE(start + 4) + depth = buf[start + 8] + colour = buf[start + 9] + interlace = buf[start + 12] + } else if (type === 'PLTE') palette = buf.subarray(start, start + len) + else if (type === 'tRNS') trns = buf.subarray(start, start + len) + else if (type === 'IDAT') idat.push(buf.subarray(start, start + len)) + else if (type === 'IEND') break + off = start + len + 4 // + CRC + } + + // Non-interlaced, one of the five colour types, and a bit depth this reads. + // + // Depth 8 was the first version's only case, and measuring the real jar said + // 626 of 1039 block textures are 4-bit palette and 35 are 2-bit — two thirds + // of them, silently skipped. Sub-byte depths are palette-only in practice and + // that is all that is handled here. + if (!width || !height || interlace !== 0) return null + if (depth !== 8 && !(colour === 3 && (depth === 1 || depth === 2 || depth === 4))) return null + if (width > 4096 || height > 4096) return null + const ch = CHANNELS[colour] + if (!ch) return null + if (colour === 3 && !palette) return null + if (!idat.length) return null + + let raw: Buffer + try { + raw = inflateSync(Buffer.concat(idat)) + } catch { + return null + } + + // Bytes per scanline, rounded up: at depth 4 two pixels share a byte. + const stride = Math.ceil((width * ch * depth) / 8) + if (raw.length < (stride + 1) * height) return null + // Filtering works on whole bytes, so the step back to the "left" pixel is one + // byte when several pixels share one. + const fstep = Math.max(1, Math.floor((ch * depth) / 8)) + + // Unfilter in place, row by row. Each row is prefixed with its filter type. + const lines = Buffer.alloc(stride * height) + for (let y = 0; y < height; y++) { + const ft = raw[y * (stride + 1)] + const src = y * (stride + 1) + 1 + const dst = y * stride + const up = dst - stride + for (let x = 0; x < stride; x++) { + const v = raw[src + x] + const a = x >= fstep ? lines[dst + x - fstep] : 0 + const b = y > 0 ? lines[up + x] : 0 + const c = x >= fstep && y > 0 ? lines[up + x - fstep] : 0 + let out: number + if (ft === 0) out = v + else if (ft === 1) out = v + a + else if (ft === 2) out = v + b + else if (ft === 3) out = v + ((a + b) >> 1) + else if (ft === 4) out = v + paeth(a, b, c) + else return null + lines[dst + x] = out & 255 + } + } + + /** One sample, whatever the bit depth. */ + const sample = (row: number, index: number): number => { + if (depth === 8) return lines[row * stride + index] + const per = 8 / depth + const byte = lines[row * stride + Math.floor(index / per)] + const shift = 8 - depth * ((index % per) + 1) + return (byte >> shift) & ((1 << depth) - 1) + } + + const data = new Uint8Array(width * height * 4) + for (let i = 0; i < width * height; i++) { + const row = Math.floor(i / width) + const col = i % width + const s = i * ch + const d = i * 4 + if (colour === 0) { + data[d] = data[d + 1] = data[d + 2] = lines[s] + data[d + 3] = 255 + } else if (colour === 2) { + data[d] = lines[s] + data[d + 1] = lines[s + 1] + data[d + 2] = lines[s + 2] + data[d + 3] = 255 + } else if (colour === 3) { + const idx = sample(row, col) + const p = idx * 3 + const pal = palette as Buffer + if (p + 2 >= pal.length) return null + data[d] = pal[p] + data[d + 1] = pal[p + 1] + data[d + 2] = pal[p + 2] + // tRNS on a palette image is a per-entry alpha table; entries past its + // end are opaque. Without this, a cut-out texture averages its + // background in and a sapling comes out the colour of nothing. + data[d + 3] = trns && sample(row, col) < trns.length ? trns[sample(row, col)] : 255 + } else if (colour === 4) { + data[d] = data[d + 1] = data[d + 2] = lines[s] + data[d + 3] = lines[s + 1] + } else { + data[d] = lines[s] + data[d + 1] = lines[s + 1] + data[d + 2] = lines[s + 2] + data[d + 3] = lines[s + 3] + } + } + return { width, height, data } +} + +/** + * The colour a block reads as on the map. + * + * Transparent pixels are skipped rather than averaged as black — half of a + * sapling or a ladder is empty space, and counting it drags every cut-out + * texture towards a dark smudge. Partly transparent pixels count in proportion + * to how solid they are, which is what makes glass come out pale rather than + * the colour of its frame. + * + * Returns null when there is nothing solid enough to average, so the caller + * keeps whatever it had rather than painting a block black. + */ +export function averageColour(bm: Bitmap): number | null { + let r = 0 + let g = 0 + let b = 0 + let w = 0 + const n = bm.width * bm.height + for (let i = 0; i < n; i++) { + const a = bm.data[i * 4 + 3] + if (a < 16) continue + const f = a / 255 + r += bm.data[i * 4] * f + g += bm.data[i * 4 + 1] * f + b += bm.data[i * 4 + 2] * f + w += f + } + if (w < 1) return null + return ((Math.round(r / w) << 16) | (Math.round(g / w) << 8) | Math.round(b / w)) >>> 0 +} + +/** + * The first frame of an animated texture. + * + * Minecraft ships water, lava, fire and the portal as a vertical strip of frames + * in one png — `water_still` is 16x512, thirty-two frames of 16x16. Averaging + * the whole strip is averaging every frame at once, and drawing it as an icon + * squashes thirty-two pictures into one square. + * + * A texture is animated when it is taller than it is wide, which is how every + * loader has always told: the `.mcmeta` beside it says how to play it, not that + * it exists. + */ +export function firstFrame(bm: Bitmap): Bitmap { + if (bm.height <= bm.width) return bm + const n = bm.width * bm.width * 4 + return { width: bm.width, height: bm.width, data: bm.data.subarray(0, n) } +} + +/** Dimensions from the header alone, without decoding the pixels. */ +export function pngSize(buf: Buffer): { width: number; height: number } | null { + if (buf.length < 26) return null + for (let i = 0; i < 8; i++) if (buf[i] !== SIG[i]) return null + if (buf.toString('ascii', 12, 16) !== 'IHDR') return null + return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) } +} diff --git a/src/main/index.ts b/src/main/index.ts index 09de457..c797d92 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -11,6 +11,7 @@ import { initMetrics, flushAll as flushMetrics } from './core/metrics' import { initEvents } from './core/events' import { initAudit } from './core/audit' import { initAlerts } from './core/alerts' +import { initBlockColours } from './core/clientAssets' import { resolveBaseDir } from './paths' import { log } from './logger' import { @@ -274,6 +275,9 @@ if (!gotLock) { initAudit() initScheduler() initAlerts() + // Averaged block colours, if any version's textures are on disk (#127). + // Before the web server, because the public map draws from the same table. + initBlockColours() initWebServer() createWindow() diff --git a/src/main/smoke.ts b/src/main/smoke.ts index 1f77218..2e2ec46 100644 --- a/src/main/smoke.ts +++ b/src/main/smoke.ts @@ -102,6 +102,7 @@ import { STRUCTURE_KINDS } from '@shared/regionFormat' import { bitsPerIndex, blockColour, + setTextureColours, localChunk, packingFor, parseLocationTable, @@ -129,6 +130,8 @@ 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 pngMod from './core/png' +import { deflateSync } from 'node:zlib' import * as assetsMod from './core/clientAssets' import { isValidMcName, @@ -325,6 +328,16 @@ function wsTestConnect( }) } +/** Shared by the hand-built zip and the hand-built png fixtures. */ +function crc32(buf: Buffer): number { + let c = ~0 + for (let i = 0; i < buf.length; i++) { + c ^= buf[i] + for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1)) + } + return ~c >>> 0 +} + /** * Build a zip by hand so a malicious entry name survives - adm-zip strips * `../` on addFile, so its own API cannot produce the archive a zip-slip guard @@ -332,14 +345,6 @@ function wsTestConnect( * needs. */ function craftZip(entries: Array<{ name: string; data: Buffer }>): Buffer { - const crc32 = (buf: Buffer): number => { - let c = ~0 - for (let i = 0; i < buf.length; i++) { - c ^= buf[i] - for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1)) - } - return ~c >>> 0 - } const u16 = (n: number): Buffer => { const b = Buffer.alloc(2) b.writeUInt16LE(n >>> 0) @@ -2321,7 +2326,192 @@ export async function runWorldsSmoke(): Promise { rmSync(adir, { recursive: true, force: true }) } } - console.log('WORLDS-SMOKE: client-jar textures OK (candidates, extractor filter, safe ids, local lookup)') + // ---- the PNG reader behind averaged block colours ---- + // + // Fixtures built here rather than committed, so what each one contains is + // stated in the test instead of being a blob nobody can read. + { + const mkPng = (w: number, h: number, px: (x: number, y: number) => number[]): Buffer => { + const raw = Buffer.alloc((w * 4 + 1) * h) + for (let y = 0; y < h; y++) { + raw[y * (w * 4 + 1)] = 0 // filter: none + for (let x = 0; x < w; x++) { + const p = px(x, y) + const o = y * (w * 4 + 1) + 1 + x * 4 + raw[o] = p[0] + raw[o + 1] = p[1] + raw[o + 2] = p[2] + raw[o + 3] = p[3] + } + } + const chunk = (type: string, body: Buffer): Buffer => { + const len = Buffer.alloc(4) + len.writeUInt32BE(body.length) + const td = Buffer.concat([Buffer.from(type, 'ascii'), body]) + const crc = Buffer.alloc(4) + crc.writeUInt32BE(crc32(td) >>> 0) + return Buffer.concat([len, td, crc]) + } + const ihdr = Buffer.alloc(13) + ihdr.writeUInt32BE(w, 0) + ihdr.writeUInt32BE(h, 4) + ihdr[8] = 8 // bit depth + ihdr[9] = 6 // RGBA + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)) + ]) + } + + // A flat colour averages to itself. If this is wrong, everything is. + const flat = pngMod.decodePng(mkPng(4, 4, () => [10, 200, 30, 255])) + if (!flat) return fail('a plain RGBA png did not decode') + if (flat.width !== 4 || flat.height !== 4) return fail('the png dimensions are wrong') + if (pngMod.averageColour(flat) !== 0x0ac81e) { + return fail('a flat colour did not average to itself: ' + pngMod.averageColour(flat)?.toString(16)) + } + + // Half red, half blue, averaged. Proves rows are read independently — + // an unfilter bug would smear one row into the next. + const split = pngMod.decodePng(mkPng(2, 2, (_x, y) => (y === 0 ? [255, 0, 0, 255] : [0, 0, 255, 255]))) + const avg = split ? pngMod.averageColour(split) : null + if (avg === null) return fail('a two-colour png did not average') + if (((avg >> 16) & 255) !== 128 || (avg & 255) !== 128) { + return fail('the halves did not average evenly: ' + avg.toString(16)) + } + + // TRANSPARENCY IS SKIPPED, not averaged as black. Half of a sapling or a + // ladder is empty space, and counting it drags every cut-out texture + // towards a dark smudge — which on a map reads as a shadow that is not + // there. + const cutout = pngMod.decodePng( + mkPng(4, 4, (x) => (x < 2 ? [0, 255, 0, 255] : [0, 0, 0, 0])) + ) + if (!cutout) return fail('a cut-out png did not decode') + if (pngMod.averageColour(cutout) !== 0x00ff00) { + return fail('transparent pixels were averaged in: ' + pngMod.averageColour(cutout)?.toString(16)) + } + // Entirely transparent: null, so the caller keeps the table's colour + // rather than painting the block black. + const empty = pngMod.decodePng(mkPng(2, 2, () => [0, 0, 0, 0])) + if (!empty) return fail('an empty png did not decode') + if (pngMod.averageColour(empty) !== null) return fail('an invisible texture produced a colour') + + // Anything it does not understand is a null, never a guess. A wrong + // colour looks like a rendering bug; a null falls back to the table. + for (const [what, bytes] of [ + ['not a png', Buffer.from('hello world')], + ['truncated', mkPng(2, 2, () => [1, 2, 3, 255]).subarray(0, 20)], + ['empty', Buffer.alloc(0)] + ] as const) { + if (pngMod.decodePng(bytes)) return fail('the decoder accepted ' + what) + } + + // A 4-BIT PALETTE image, which is what most of Minecraft actually is. + // Measured on 1.21.4: 626 of 1039 block textures are depth 4 and 35 are + // depth 2 — the first version of the decoder handled only depth 8 and + // silently skipped two thirds of them, which no test noticed because + // every fixture it had was written at depth 8. + { + const chunk = (type: string, body: Buffer): Buffer => { + const len = Buffer.alloc(4) + len.writeUInt32BE(body.length) + const td = Buffer.concat([Buffer.from(type, 'ascii'), body]) + const crc = Buffer.alloc(4) + crc.writeUInt32BE(crc32(td)) + return Buffer.concat([len, td, crc]) + } + const ihdr = Buffer.alloc(13) + ihdr.writeUInt32BE(4, 0) + ihdr.writeUInt32BE(2, 4) + ihdr[8] = 4 // bit depth + ihdr[9] = 3 // palette + // Two entries: index 0 red, index 1 blue. + const plte = Buffer.from([255, 0, 0, 0, 0, 255]) + // 4 pixels per row at 4 bits = 2 bytes, plus the filter byte. + // Row 0: 0,0,1,1 Row 1: 1,1,0,0 + const raw = Buffer.from([0, 0x00, 0x11, 0, 0x11, 0x00]) + const p4 = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('PLTE', plte), + chunk('IDAT', deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)) + ]) + const bm4 = pngMod.decodePng(p4) + if (!bm4) return fail('a 4-bit palette png did not decode') + if (bm4.width !== 4 || bm4.height !== 2) return fail('4-bit dimensions wrong') + // Top-left is index 0 (red), top-right index 1 (blue). Getting the + // nibble order backwards would swap them and still "decode". + if (bm4.data[0] !== 255 || bm4.data[2] !== 0) return fail('the first 4-bit pixel is not red') + const last = (3 + 0 * 4) * 4 + if (bm4.data[last] !== 0 || bm4.data[last + 2] !== 255) return fail('the fourth 4-bit pixel is not blue') + // Half red, half blue over the whole image. + const a4 = pngMod.averageColour(bm4) + if (a4 === null || ((a4 >> 16) & 255) !== 128 || (a4 & 255) !== 128) { + return fail('the 4-bit average is wrong: ' + a4?.toString(16)) + } + } + + // An ANIMATED texture is a vertical strip of frames in one file. Water + // is 16x512 — thirty-two frames — and averaging the strip averages every + // frame at once. + { + const strip = mkPng(2, 8, (_x, y) => (y < 2 ? [255, 0, 0, 255] : [0, 0, 255, 255])) + const bm = pngMod.decodePng(strip) + if (!bm) return fail('an animated strip did not decode') + const whole = pngMod.averageColour(bm) + const frame = pngMod.averageColour(pngMod.firstFrame(bm)) + if (frame !== 0xff0000) return fail('the first frame is not the first frame: ' + frame?.toString(16)) + if (whole === frame) return fail('firstFrame changed nothing on a strip') + // A square texture is its own first frame, untouched. + const sq = pngMod.decodePng(mkPng(4, 4, () => [1, 2, 3, 255])) + if (!sq || pngMod.firstFrame(sq).height !== 4) return fail('firstFrame cropped a square texture') + // ...and the header alone is enough to spot one, without decoding. + const sz = pngMod.pngSize(strip) + if (!sz || sz.width !== 2 || sz.height !== 8) return fail('pngSize is wrong') + if (pngMod.pngSize(Buffer.from('not a png at all, really')) !== null) { + return fail('pngSize accepted something that is not a png') + } + } + + // BIOME-TINTED textures are grey in the file and green on screen: the + // game multiplies them by a colour it picks at render time. Measured on + // 1.21.4, grass_block_top averages #939393 and oak_leaves #909090 — + // overriding the table's green with that would be strictly worse than + // not overriding at all. + for (const tinted of ['grass_block_top', 'oak_leaves', 'water_still', 'birch_leaves', 'vine', 'melon_stem']) { + if (!tex.isTintedTexture(tinted)) return fail(tinted + ' is not treated as tinted') + } + for (const plain of ['stone', 'sand', 'gold_block', 'oak_planks', 'obsidian', 'netherrack']) { + if (tex.isTintedTexture(plain)) return fail(plain + ' was wrongly treated as tinted') + } + + // A map is looked at from ABOVE, so a block whose faces differ should + // read as its top: a log's top is its rings, not its bark. + setTextureColours({ oak_log: { r: 9, g: 9, b: 9 }, oak_log_top: { r: 1, g: 2, b: 3 } }) + const log = blockColour('oak_log') + if (log.r !== 1 || log.g !== 2 || log.b !== 3) return fail('the side face won over the top') + setTextureColours({ oak_log: { r: 9, g: 9, b: 9 } }) + if (blockColour('oak_log').r !== 9) return fail('a block with only one face lost its colour') + setTextureColours({}) + + // ...and the override only wins where it has an answer. This is the + // safety property: the map that worked before must still work. + const before = blockColour('stone') + setTextureColours({ stone: { r: 1, g: 2, b: 3 } }) + const after = blockColour('stone') + if (after.r !== 1 || after.g !== 2 || after.b !== 3) return fail('the texture colour did not win') + if (blockColour('grass_block').r === 1) return fail('an unrelated block took the override') + setTextureColours({}) + const restored = blockColour('stone') + if (restored.r !== before.r || restored.g !== before.g || restored.b !== before.b) { + return fail('clearing the override did not restore the table') + } + } + console.log('WORLDS-SMOKE: client-jar textures OK (candidates, extractor filter, safe ids, png average, table fallback)') } // --- 12. chunk areas: the rules four map surfaces have to share (#144) --- diff --git a/src/shared/regionFormat.ts b/src/shared/regionFormat.ts index 1d8950f..3e00643 100644 --- a/src/shared/regionFormat.ts +++ b/src/shared/regionFormat.ts @@ -292,8 +292,35 @@ const COLOURS: Record = { /** Water reads as water on a map, so the renderer needs to know which is which. */ export const WATERY = new Set(['water', 'bubble_column']) +/** + * Colours averaged from the real block textures, when they have been extracted + * (#127). + * + * An override rather than a replacement, and installed rather than imported: the + * table below covers about forty blocks and guesses the rest from suffixes, + * which is wrong for the hundreds it does not name and for every modded block. + * Averaging the actual texture is simply better data for the same question. + * + * Empty until an operator downloads the client jar, and one bad decode costs one + * block its entry rather than the map its colours — which is why this is a map + * consulted first and not a new code path. + */ +let textureColours: Record = {} + +export function setTextureColours(map: Record): void { + textureColours = map || {} +} + +export function textureColourCount(): number { + return Object.keys(textureColours).length +} + export function blockColour(rawName: string): Rgb { const name = String(rawName || '').replace(/^minecraft:/, '') + // The TOP face first: this is a map looked at from above, and a block whose + // sides and top differ (grass, a log, a furnace) should read as its top. + const real = textureColours[name + '_top'] ?? textureColours[name] + if (real) return real const hit = COLOURS[name] if (hit) return hit // Families, so a wood or a wool variant is close to right without 400 entries. diff --git a/src/shared/textures.ts b/src/shared/textures.ts index f4dfe6f..6b720b5 100644 --- a/src/shared/textures.ts +++ b/src/shared/textures.ts @@ -79,6 +79,42 @@ export function textureCandidates(id: string): string[] { return out } +/** + * Textures Minecraft TINTS by biome, whose raw pixels are grey. + * + * `grass_block_top` averages to #939393 — the texture is greyscale and the game + * multiplies it by a biome colour at render time. Overriding the map's green + * with that grey would be strictly worse than the hand-written table, which is + * why these keep the table's answer instead. + * + * Matched by suffix as well as by name, because there are forty leaf and vine + * variants and listing them all would be a list that goes stale every release. + */ +const TINTED_EXACT = new Set([ + 'grass_block_top', + 'grass_block_side_overlay', + 'short_grass', + 'tall_grass_top', + 'tall_grass_bottom', + 'fern', + 'large_fern_top', + 'large_fern_bottom', + 'vine', + 'lily_pad', + 'sugar_cane', + 'water_still', + 'water_flow', + 'water_overlay', + 'attached_melon_stem', + 'attached_pumpkin_stem' +]) + +export function isTintedTexture(name: string): boolean { + const n = String(name || '').toLowerCase() + if (TINTED_EXACT.has(n)) return true + return n.endsWith('_leaves') || n.endsWith('_stem') || n.startsWith('redstone_dust') +} + /** * Is this a texture the extractor should keep? *