From 3ede2bff781e2fd58960e9b9cb2aead53f458994 Mon Sep 17 00:00:00 2001 From: CaYatur Date: Wed, 29 Jul 2026 04:17:30 +0300 Subject: [PATCH] The world renderer drew nothing; make it draw the world MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #119 shipped a world renderer that produced no output at all on a real world, and every test was green because none of them read a real chunk. **The palette was unwrapped once and needed twice.** prismarine-nbt represents an NBT list as `{type:'list', value:{type:'compound', value:[...]}}` — the outer wrapper says "list", the inner says what the elements are. `sections` happened to work because that code unwrapped twice; `palette` did not, so `Array.isArray(palette)` was false for every section of every chunk, every section was skipped, and every tile came back null. Silently: no error, no exception, just a map with nothing on it. Verified against a real Paper 1.21.6 world rather than argued about — 339 chunks of one region decoded, rendered to an image and looked at. The bit-level work from #119 turned out to be right: DataVersion 4435, padded packing, and the long counts match `bitsPerIndex` exactly (a 26-entry palette is 5 bits and 342 longs, which is what the file holds). **And then it looked wrong, so this fixes that too.** The topmost block of a column is usually the plant standing on the ground rather than the ground: on that world the commonest surface blocks after grass were `short_grass`, `vine`, `fern` and `bamboo`, none of them in the colour table, all falling through to the stable-hash fallback. A bamboo jungle rendered as a maroon smear and every meadow as blue-grey. A map looks *through* foliage. `seeThrough` skips grass, ferns, vines, flowers, bamboo, crops, carpets, signs and the rest — by name and by suffix family, so a flower added in a future version needs no release — while leaves stay, because a forest canopy is exactly what you see from above. After it, every drawn surface block is one the table names. The common colours are Minecraft's own map-item palette now instead of eyeballed values, since that is the palette a player recognises. Mine were darker and redder, which is the other half of why podzol read as maroon. Asserted with a real NBT chunk built in the test and read by the real function: air above and stone below renders as stone at the right height, grass with `short_grass` on it renders as *grass*, and an all-air chunk produces no tile. Proved failable — restoring the single unwrap gives `FAIL - a valid chunk produced no tile — the reader found no sections`, which is precisely what was shipping. --- src/main/core/worldTiles.ts | 43 +++++++++++--- src/main/smoke.ts | 93 +++++++++++++++++++++++++++++- src/shared/regionFormat.ts | 109 +++++++++++++++++++++++++++++++----- 3 files changed, 221 insertions(+), 24 deletions(-) diff --git a/src/main/core/worldTiles.ts b/src/main/core/worldTiles.ts index bd0b68b..e91d0b3 100644 --- a/src/main/core/worldTiles.ts +++ b/src/main/core/worldTiles.ts @@ -28,9 +28,9 @@ import { parseLocationTable, regionOf, unpackIndices, + seeThrough, CHUNK_AXIS, - INVISIBLE, - WATERY + INVISIBLE } from '@shared/regionFormat' /** A chunk's surface: 256 columns, row-major (x fastest). */ @@ -93,6 +93,25 @@ function tag(v: any): any { return v && typeof v === 'object' && 'value' in v ? v.value : v } +/** + * Unwrap an NBT *list*, which prismarine-nbt wraps twice. + * + * A list arrives as `{type:'list', value:{type:'compound', value:[...]}}` — the + * outer wrapper says "list", the inner one says what the elements are. One + * `tag()` leaves you holding the inner descriptor object, not the array. + * + * This is not a nicety. Reading `sections` happened to work because the code + * unwrapped it twice by accident, while `palette` was unwrapped once — so + * `Array.isArray(palette)` was false for every section of every chunk, every + * section was skipped, and the world renderer produced nothing at all. The + * smoke tested the bit decoding and never a real chunk, so nothing caught it. + */ +function listOf(v: any): any[] { + const once = tag(v) + const twice = tag(once) + return Array.isArray(twice) ? twice : Array.isArray(once) ? once : [] +} + /** * The topmost visible block of every column in one chunk. * @@ -101,14 +120,14 @@ function tag(v: any): any { * but air the whole way — under an unlit sky, or a chunk that is only partly * generated — is left transparent rather than drawn as the void. */ -function tileFromChunk(chunk: any): ChunkTile | null { +export function tileFromChunk(chunk: any): ChunkTile | null { const v = tag(chunk) if (!v) return null const dataVersion = tag(v.DataVersion) const packing = packingFor(typeof dataVersion === 'number' ? dataVersion : undefined) // 1.18+ uses `sections`; 1.13-1.17 used `Level.Sections`. - const sections = tag(tag(v.sections)) ?? tag(tag(tag(v.Level)?.Sections)) - if (!Array.isArray(sections)) return null + const sections = listOf(v.sections).length ? listOf(v.sections) : listOf(tag(v.Level)?.Sections) + if (!sections.length) return null const withY = sections .map((s: any) => ({ s: tag(s), y: Number(tag(tag(s)?.Y)) })) @@ -122,13 +141,16 @@ function tileFromChunk(chunk: any): ChunkTile | null { for (const { s, y: sectionY } of withY) { if (remaining === 0) break const states = tag(s.block_states) ?? tag(s.BlockStates) - const paletteRaw = tag(tag(states)?.palette) ?? tag(s.Palette) - if (!Array.isArray(paletteRaw) || !paletteRaw.length) continue + // A list, so unwrapped twice. See `listOf`. + const paletteRaw = listOf(states?.palette).length ? listOf(states?.palette) : listOf(s.Palette) + if (!paletteRaw.length) continue const names: string[] = paletteRaw.map((p: any) => String(tag(tag(p)?.Name) ?? '')) // A section whose palette is one entry has no data array at all — it is // 4096 of that block, which is how a solid stone or all-air section is // stored. Reading `data` there would skip the section entirely. - const longs = toLongs(tag(tag(states)?.data) ?? tag(s.BlockStates)) + // `data` is a longArray, which is wrapped once — unlike the palette beside + // it, which is a list and wrapped twice. + const longs = toLongs(tag(states?.data) ?? tag(s.BlockStates)) const bits = bitsPerIndex(names.length) const indices = names.length === 1 || !longs.length @@ -142,7 +164,10 @@ function tileFromChunk(chunk: any): ChunkTile | null { if (colour[col] >= 0) continue const name = names[indices[y * 256 + z * CHUNK_AXIS + x]] ?? '' const short = name.replace(/^minecraft:/, '') - if (!short || INVISIBLE.has(short)) continue + // Air, and the plants a map looks through — see `seeThrough`. Without + // it the surface is whatever is standing ON the ground rather than + // the ground, which is how a bamboo jungle rendered as a maroon smear. + if (!short || INVISIBLE.has(short) || seeThrough(short)) continue const c = blockColour(short) colour[col] = (c.r << 16) | (c.g << 8) | c.b height[col] = sectionY * CHUNK_AXIS + y diff --git a/src/main/smoke.ts b/src/main/smoke.ts index b8bd321..c4ff949 100644 --- a/src/main/smoke.ts +++ b/src/main/smoke.ts @@ -78,6 +78,7 @@ import * as backupsMod from './core/backups' import * as schedulerMod from './core/scheduler' import * as modsMod from './core/mods' import * as bridgeInstallMod from './core/bridgeInstall' +import * as worldTilesMod from './core/worldTiles' import { bridgeNeed, bridgeVersionOf, @@ -1054,7 +1055,97 @@ export async function runModUpdateSmoke(): Promise { if (shade({ r: 100, g: 100, b: 100 }, 3).r <= flat.r) return fail('a step up is not lighter') if (shade({ r: 100, g: 100, b: 100 }, -3).r >= flat.r) return fail('a step down is not darker') - console.log('MODUPDATE-SMOKE: region decoding OK (1.16 packing split, negative longs and coords, stable colours)') + // A real chunk, built as real NBT and read back by the real function. + // + // The bit decoding above was tested and the CHUNK READER was not, and + // that is exactly where the bug was: prismarine-nbt wraps a list twice + // (`{type:'list', value:{type:'compound', value:[...]}}`) and the palette + // was unwrapped once. `Array.isArray` was false for every section of + // every chunk, every section was skipped, and the world renderer produced + // nothing at all — on a real world, silently, with every pure test green. + { + const section = (y: number, names: string[], data?: bigint[]): unknown => ({ + Y: { type: 'byte', value: y }, + block_states: { + type: 'compound', + value: { + palette: { + type: 'list', + value: { + type: 'compound', + value: names.map((n) => ({ Name: { type: 'string', value: n } })) + } + }, + ...(data + ? { data: { type: 'longArray', value: data.map((b) => [Number(b >> 32n), Number(b & 0xffffffffn)]) } } + : {}) + } + } + }) + const chunkNbt = { + type: 'compound', + name: '', + value: { + DataVersion: { type: 'int', value: 4435 }, + sections: { + type: 'list', + value: { + type: 'compound', + // Air above, solid stone below: the reader must walk down past + // the air and stop on the stone. + value: [section(5, ['minecraft:air']), section(4, ['minecraft:stone'])] + } + } + } + } + const tile = worldTilesMod.tileFromChunk(chunkNbt) + if (!tile) return fail('a valid chunk produced no tile — the reader found no sections') + if (tile.colour.length !== 256) return fail('a tile is not 16x16') + const stone = blockColour('stone') + const want = (stone.r << 16) | (stone.g << 8) | stone.b + if (tile.colour.some((c) => c !== want)) return fail('a solid stone chunk did not render as stone') + // Height is the top of the stone section: Y=4 means blocks 64..79, and + // the surface is the highest of them. + if (tile.height.some((h) => h !== 4 * 16 + 15)) { + return fail('the surface height is wrong: ' + tile.height[0]) + } + // Foliage is looked THROUGH: the grass standing on the ground is not + // the ground, and colouring by it turned a bamboo jungle maroon. + const planted = worldTilesMod.tileFromChunk({ + type: 'compound', + name: '', + value: { + DataVersion: { type: 'int', value: 4435 }, + sections: { + type: 'list', + value: { + type: 'compound', + value: [section(5, ['minecraft:short_grass']), section(4, ['minecraft:grass_block'])] + } + } + } + }) + const grass = blockColour('grass_block') + if (!planted) return fail('a planted chunk produced no tile') + if (planted.colour[0] !== ((grass.r << 16) | (grass.g << 8) | grass.b)) { + return fail('the map coloured a column by the plant standing on it, not the ground') + } + // An all-air chunk is not a tile at all. + if ( + worldTilesMod.tileFromChunk({ + type: 'compound', + name: '', + value: { + DataVersion: { type: 'int', value: 4435 }, + sections: { type: 'list', value: { type: 'compound', value: [section(5, ['minecraft:air'])] } } + } + }) + ) { + return fail('an all-air chunk produced a tile') + } + } + + console.log('MODUPDATE-SMOKE: region decoding OK (1.16 packing split, real NBT chunk renders, foliage seen through)') } // ---- the Bridge plugin installer (#103) ---- diff --git a/src/shared/regionFormat.ts b/src/shared/regionFormat.ts index 2bc3cf8..888646f 100644 --- a/src/shared/regionFormat.ts +++ b/src/shared/regionFormat.ts @@ -146,6 +146,55 @@ export function unpackIndices( */ export const INVISIBLE = new Set(['air', 'cave_air', 'void_air']) +/** + * Blocks a map looks THROUGH to the ground below. + * + * Not cosmetic. The topmost block of a column is very often the plant standing + * on it, and colouring the column by the plant is what turned a bamboo jungle + * into a maroon smear and every meadow into blue-grey on a real world: the + * three most common surface blocks after grass were `short_grass`, `vine` and + * `fern`, none of which is what you see when you look down at that terrain. + * + * Leaves are deliberately NOT here — a forest canopy is exactly what you see + * from above, and it is already green. + */ +export const SEE_THROUGH = new Set([ + 'short_grass', 'grass', 'tall_grass', 'fern', 'large_fern', 'dead_bush', + 'vine', 'glow_lichen', 'bamboo', 'bamboo_sapling', 'sugar_cane', 'cactus_flower', + 'poppy', 'dandelion', 'blue_orchid', 'allium', 'azure_bluet', 'oxeye_daisy', + 'cornflower', 'lily_of_the_valley', 'wither_rose', 'torchflower', 'pink_petals', + 'sunflower', 'lilac', 'rose_bush', 'peony', 'sweet_berry_bush', + 'brown_mushroom', 'red_mushroom', 'crimson_fungus', 'warped_fungus', + 'cave_vines', 'cave_vines_plant', 'twisting_vines', 'twisting_vines_plant', + 'weeping_vines', 'weeping_vines_plant', 'hanging_roots', 'spore_blossom', + 'seagrass', 'tall_seagrass', 'kelp', 'kelp_plant', 'sea_pickle', + 'torch', 'wall_torch', 'soul_torch', 'soul_wall_torch', 'lantern', 'snow', + 'rail', 'powered_rail', 'detector_rail', 'activator_rail', 'ladder', + 'melon_stem', 'pumpkin_stem', 'wheat', 'carrots', 'potatoes', 'beetroots', + 'nether_wrt', 'cocoa', 'lily_pad', 'moss_carpet', 'fire', 'soul_fire', + 'crimson_roots', 'warped_roots', 'nether_sprouts', 'big_dripleaf', 'small_dripleaf' +]) + +/** + * A block that decorates rather than covers. Suffix families, so a new flower + * or sapling in a future version is skipped without a release. + */ +export function seeThrough(name: string): boolean { + if (SEE_THROUGH.has(name)) return true + return ( + name.endsWith('_tulip') || + name.endsWith('_sapling') || + name.endsWith('_carpet') || + name.endsWith('_banner') || + name.endsWith('_sign') || + name.endsWith('_button') || + name.endsWith('_pressure_plate') || + name.endsWith('_candle') || + name.endsWith('_coral_fan') || + name.endsWith('_coral_wall_fan') + ) +} + export interface Rgb { r: number g: number @@ -163,18 +212,22 @@ export interface Rgb { * from the name so an unknown block is consistent rather than invisible. */ const COLOURS: Record = { - grass_block: { r: 106, g: 152, b: 73 }, - dirt: { r: 134, g: 96, b: 67 }, - coarse_dirt: { r: 119, g: 85, b: 59 }, - podzol: { r: 89, g: 58, b: 26 }, - stone: { r: 122, g: 122, b: 122 }, + // The common ones are Minecraft's own map colours rather than eyeballed + // values — that is the palette the game shows on a map item, so it is the + // one a player recognises. Mine were darker and redder, which turned a + // jungle's podzol floor into a maroon smear next to the green canopy. + grass_block: { r: 127, g: 178, b: 56 }, + dirt: { r: 151, g: 109, b: 77 }, + coarse_dirt: { r: 151, g: 109, b: 77 }, + podzol: { r: 129, g: 86, b: 49 }, + stone: { r: 112, g: 112, b: 112 }, andesite: { r: 136, g: 136, b: 136 }, diorite: { r: 188, g: 188, b: 188 }, granite: { r: 149, g: 103, b: 86 }, deepslate: { r: 78, g: 78, b: 84 }, cobblestone: { r: 127, g: 127, b: 127 }, gravel: { r: 136, g: 126, b: 126 }, - sand: { r: 219, g: 207, b: 163 }, + sand: { r: 247, g: 233, b: 163 }, red_sand: { r: 190, g: 102, b: 33 }, sandstone: { r: 216, g: 203, b: 155 }, water: { r: 63, g: 118, b: 228 }, @@ -184,13 +237,14 @@ const COLOURS: Record = { ice: { r: 165, g: 194, b: 245 }, packed_ice: { r: 141, g: 180, b: 245 }, blue_ice: { r: 116, g: 167, b: 253 }, - oak_leaves: { r: 60, g: 122, b: 40 }, - birch_leaves: { r: 128, g: 167, b: 85 }, - spruce_leaves: { r: 50, g: 89, b: 50 }, - jungle_leaves: { r: 61, g: 130, b: 32 }, - acacia_leaves: { r: 108, g: 145, b: 46 }, - dark_oak_leaves: { r: 48, g: 100, b: 32 }, - azalea_leaves: { r: 92, g: 140, b: 56 }, + // Canopy: darker than the grass under it, so a forest reads as a forest. + oak_leaves: { r: 46, g: 110, b: 30 }, + birch_leaves: { r: 110, g: 150, b: 66 }, + spruce_leaves: { r: 42, g: 80, b: 45 }, + jungle_leaves: { r: 48, g: 116, b: 26 }, + acacia_leaves: { r: 98, g: 134, b: 40 }, + dark_oak_leaves: { r: 38, g: 88, b: 26 }, + azalea_leaves: { r: 82, g: 128, b: 48 }, oak_log: { r: 102, g: 81, b: 50 }, spruce_log: { r: 58, g: 40, b: 22 }, birch_log: { r: 216, g: 214, b: 207 }, @@ -205,7 +259,34 @@ const COLOURS: Record = { mud: { r: 60, g: 55, b: 60 }, farmland: { r: 110, g: 78, b: 52 }, grass_path: { r: 148, g: 121, b: 65 }, - dirt_path: { r: 148, g: 121, b: 65 } + dirt_path: { r: 148, g: 121, b: 65 }, + // Seen from above on a real world often enough to be worth naming, rather + // than left to the hash fallback — which is stable but arbitrary, and an + // arbitrary colour on a common block is what makes a map look wrong. + mycelium: { r: 111, g: 100, b: 105 }, + rooted_dirt: { r: 144, g: 103, b: 76 }, + mossy_cobblestone: { r: 106, g: 117, b: 92 }, + calcite: { r: 223, g: 224, b: 220 }, + tuff: { r: 108, g: 110, b: 103 }, + dripstone_block: { r: 145, g: 111, b: 92 }, + basalt: { r: 73, g: 71, b: 78 }, + blackstone: { r: 42, g: 35, b: 41 }, + soul_sand: { r: 81, g: 62, b: 50 }, + soul_soil: { r: 75, g: 57, b: 46 }, + magma_block: { r: 142, g: 74, b: 34 }, + glowstone: { r: 231, g: 187, b: 111 }, + crimson_nylium: { r: 130, g: 31, b: 31 }, + warped_nylium: { r: 43, g: 115, b: 112 }, + bamboo_block: { r: 152, g: 165, b: 63 }, + pumpkin: { r: 198, g: 118, b: 24 }, + melon: { r: 111, g: 145, b: 32 }, + hay_block: { r: 166, g: 137, b: 24 }, + glass: { r: 200, g: 220, b: 232 }, + cobweb: { r: 220, g: 224, b: 228 }, + amethyst_block: { r: 133, g: 97, b: 191 }, + cactus: { r: 85, g: 127, b: 47 }, + brick_block: { r: 150, g: 97, b: 83 }, + bricks: { r: 150, g: 97, b: 83 } } /** Water reads as water on a map, so the renderer needs to know which is which. */