From 51073c49bc3d08d708c26f7bc6b745ee1ace5d56 Mon Sep 17 00:00:00 2001 From: CaYatur Date: Wed, 29 Jul 2026 20:20:21 +0300 Subject: [PATCH] Map colours averaged from the real block textures (#127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The other half of #127. `COLOURS` in regionFormat.ts names about forty blocks and guesses the rest from suffixes — `*_planks`, `*_leaves`, anything containing "stone" — which is wrong for the hundreds it does not name and for every modded block there has ever been. The textures are already on disk from the item-icon half; averaging them is simply better data for the same question. An OVERRIDE, not a replacement, installed at runtime rather than imported. Empty until an operator downloads a client jar, and one bad decode costs one block its entry rather than the map its colours. The map that worked before this existed still works if the decoder meets something it does not understand. Everything below was measured against the real 1.21.4 jar, and every number found a defect. TWO THIRDS OF THE TEXTURES DID NOT DECODE. 355 of 1039. The failures were entirely bit depths 4 and 2 — 626 block textures in Minecraft are 4-bit palette pngs and 35 are 2-bit. The decoder handled depth 8 only, and no fixture caught it because every fixture was written at depth 8. Sub-byte palette samples now unpack from the byte they share. 1037 of 1039 decode; the two left are one 1-bit greyscale and one oddity. ANIMATED TEXTURES WERE AVERAGED WHOLE. Water, lava, fire and the portal ship as a vertical strip of frames in one file — water_still is 16x512, thirty-two frames. Averaging the strip averages every frame at once and came out a muddy nothing. `firstFrame` crops to the top square; lava now reads #d9691a instead. The same check keeps a strip out of the item icons, where it was drawn as thirty-two pictures squashed into a 30px box. The comment in the extractor claimed this was already handled by skipping `.mcmeta` files. It was not: skipping the metadata file does nothing about the png beside it. BIOME-TINTED TEXTURES ARE GREY IN THE FILE. grass_block_top averages #939393, oak_leaves #909090, water_still #b1b1b1 — the game multiplies them by a colour it picks per biome at render time. Overriding the table's green with grey would have made the map visibly worse than before, which is exactly the regression this was split into its own PR to avoid. Those keep the table's answer. And the lookup prefers the TOP face: a map is looked at from above, so a log should read as its rings rather than its bark. Verified: 12/12 gates. Restoring the depth-8-only check fails with "a 4-bit palette png did not decode" — the check the first version did not have. --- src/main/core/clientAssets.ts | 94 +++++++++++++- src/main/core/png.ts | 232 ++++++++++++++++++++++++++++++++++ src/main/index.ts | 4 + src/main/smoke.ts | 208 ++++++++++++++++++++++++++++-- src/shared/regionFormat.ts | 27 ++++ src/shared/textures.ts | 36 ++++++ 6 files changed, 590 insertions(+), 11 deletions(-) create mode 100644 src/main/core/png.ts 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? *