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
43 changes: 34 additions & 9 deletions src/main/core/worldTiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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.
*
Expand All @@ -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)) }))
Expand All @@ -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
Expand All @@ -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
Expand Down
93 changes: 92 additions & 1 deletion src/main/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1054,7 +1055,97 @@ export async function runModUpdateSmoke(): Promise<void> {
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) ----
Expand Down
109 changes: 95 additions & 14 deletions src/shared/regionFormat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -163,18 +212,22 @@ export interface Rgb {
* from the name so an unknown block is consistent rather than invisible.
*/
const COLOURS: Record<string, Rgb> = {
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 },
Expand All @@ -184,13 +237,14 @@ const COLOURS: Record<string, Rgb> = {
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 },
Expand All @@ -205,7 +259,34 @@ const COLOURS: Record<string, Rgb> = {
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. */
Expand Down
Loading