Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 92 additions & 2 deletions src/main/core/clientAssets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -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.
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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<string, { r: number; g: number; b: number }> = {}
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)
Expand All @@ -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
}
232 changes: 232 additions & 0 deletions src/main/core/png.ts
Original file line number Diff line number Diff line change
@@ -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<number, number> = { 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) }
}
4 changes: 4 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()

Expand Down
Loading
Loading