diff --git a/src/main/core/worldTiles.ts b/src/main/core/worldTiles.ts index fad1d7c..8fc7453 100644 --- a/src/main/core/worldTiles.ts +++ b/src/main/core/worldTiles.ts @@ -36,11 +36,12 @@ import { bitsPerIndex, blockColour, chunkSlot, + indexAt, localChunk, packingFor, parseLocationTable, + prepareIndices, regionOf, - unpackIndices, scanRuleFor, seeThrough, structureKind, @@ -92,9 +93,10 @@ export function _resetWorldTiles(): void { // ---- the on-disk cache (#133) ---- // // #119 kept parsed regions in memory only, so every restart re-parsed the -// world: 180ms per region just to decompress and parse the NBT, before any -// surface extraction. The encoded form of the same region gunzips in about a -// millisecond. +// world. Measured on a 4 MB region of 1024 chunks: 0.6 s to decompress and +// parse the NBT, and 0.8 s more to extract the surfaces — 1.5 s in total, and +// 14 s before #157. The encoded form of the same region reads back in about +// 16 ms, which is the whole reason this exists. function cacheDirFor(): string { return ensureDir(join(cacheDir(), 'worldtiles')) @@ -373,6 +375,50 @@ export function tileFromChunk(chunk: any, dim = 'overworld'): ChunkTile | null { 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) ?? '')) + + /** + * The block rules, resolved once per PALETTE ENTRY. + * + * This is the whole cost of a map (#157). A section is 4096 positions and + * its palette is at most a few dozen entries, and every one of these used + * to run per position: a regex to strip the namespace, a Set lookup for + * air, `seeThrough` (a Set miss then ten `endsWith` calls), and + * `blockColour` with a second regex inside it. A fully generated chunk is + * about 53 thousand of those to render 256 columns — 13 ms a chunk, 14 + * seconds a region, and the reason a viewport could take over a minute. + * + * Resolved per entry it is a few dozen, and the inner loop is array + * indexing. Same answers: the arrays are built by the same functions in the + * same order. + */ + const invisible = names.map((n) => { + // 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. + const short = n.replace(/^minecraft:/, '') + return !short || INVISIBLE.has(short) || seeThrough(short) + }) + // A section a map sees nothing in — and above the surface that is most of + // them, fifteen or so single-entry air palettes per chunk. Skipped HERE, + // before the colours are looked up, before the indices are unpacked and + // before anything is allocated, because the point is not to make those + // sections cheaper but to stop touching them at all. + if (invisible.every(Boolean)) { + // Except under a roof, where an air section is not nothing: it is the gap + // the scan is looking for. Recorded for every column in one pass instead + // of by walking 4096 positions to reach the same conclusion. Every + // section still standing here has its bottom layer at or below the + // ceiling, so a full air layer covers all 256 columns. + if (sawAir) sawAir.fill(true) + continue + } + + // Only now, for the sections that can actually contribute a colour. + const packedColour = names.map((n) => { + const c = blockColour(n.replace(/^minecraft:/, '')) + return (c.r << 16) | (c.g << 8) | c.b + }) + // 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. @@ -380,43 +426,48 @@ export function tileFromChunk(chunk: any, dim = 'overworld'): ChunkTile | null { // it, which is a list and wrapped twice. const longs = toLongs(tag(states?.data) ?? tag(s.BlockStates)) const bits = bitsPerIndex(names.length) + // `null` rather than 4096 zeroes: a uniform section reads palette entry 0 + // at every position, so the array only existed to say so. + // + // And prepared rather than unpacked: the loop below walks down from the top + // layer and stops the moment every column has an answer, which on real + // terrain is after two or three of the sixteen. Unpacking all 4096 did the + // rest for nothing. const indices = - names.length === 1 || !longs.length - ? new Array(4096).fill(0) - : unpackIndices(longs, bits, 4096, packing) + names.length === 1 || !longs.length ? null : prepareIndices(longs, bits, packing) for (let y = CHUNK_AXIS - 1; y >= 0 && remaining > 0; y--) { - for (let z = 0; z < CHUNK_AXIS; z++) { - for (let x = 0; x < CHUNK_AXIS; x++) { - const col = x + z * CHUNK_AXIS - if (colour[col] >= 0) continue - const worldY = sectionY * CHUNK_AXIS + y - if (rule.ceiling !== null && worldY > rule.ceiling) continue - const name = names[indices[y * 256 + z * CHUNK_AXIS + x]] ?? '' - const short = name.replace(/^minecraft:/, '') - // 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. - const invisible = !short || INVISIBLE.has(short) || seeThrough(short) - if (sawAir) { - // Under a roof: remember the gap, and skip everything solid until - // one has been seen. Without this the first hit is the roof itself. - if (invisible) sawAir[col] = true - if (!sawAir[col]) { - if (!invisible && fallbackColour && fallbackColour[col] < 0) { - const fc = blockColour(short) - fallbackColour[col] = (fc.r << 16) | (fc.g << 8) | fc.b - if (fallbackHeight) fallbackHeight[col] = worldY - } - continue + // Neither of these depends on the column, and both used to be recomputed + // 256 times a layer. + const worldY = sectionY * CHUNK_AXIS + y + if (rule.ceiling !== null && worldY > rule.ceiling) continue + const base = y * 256 + // `x + z * CHUNK_AXIS` IS the column index, so the two loops the original + // had over x and z collapse into one over the column in the same order. + for (let col = 0; col < 256; col++) { + if (colour[col] >= 0) continue + const pi = indices ? indexAt(indices, base + col) : 0 + // An index past the end of its palette: the width comes from + // `bitsPerIndex`, which rounds up, so a three-entry palette is read + // four bits wide and corrupt data can address entry 15. The old code + // resolved that to an empty name and treated it as invisible. + const inv = pi >= names.length || invisible[pi] + if (sawAir) { + // Under a roof: remember the gap, and skip everything solid until + // one has been seen. Without this the first hit is the roof itself. + if (inv) sawAir[col] = true + if (!sawAir[col]) { + if (!inv && fallbackColour && fallbackColour[col] < 0) { + fallbackColour[col] = packedColour[pi] + if (fallbackHeight) fallbackHeight[col] = worldY } + continue } - if (invisible) continue - const c = blockColour(short) - colour[col] = (c.r << 16) | (c.g << 8) | c.b - height[col] = worldY - remaining-- } + if (inv) continue + colour[col] = packedColour[pi] + height[col] = worldY + remaining-- } } } @@ -537,16 +588,29 @@ function parseSlot( * How many of a region's 1024 chunks to parse before letting the event loop * run. * - * A region takes about 180 ms to parse and the main process serves every IPC - * call, so parsing one in a single pass freezes the whole app for that long — - * the console stops reading, stats stop arriving, and the interface stutters - * while the map loads. Slicing does not make the work shorter; it makes it - * interruptible, which is the part that was hurting. + * The main process serves every IPC call, so parsing a region in a single pass + * freezes the whole app for as long as it takes — the console stops reading, + * stats stop arriving, and the interface stutters while the map loads. Slicing + * does not make the work shorter; it makes it interruptible, which is the part + * that was hurting. + * + * 32 was chosen against a measurement of 180 ms a region, which was wrong: that + * was the decompress and the NBT parse, about 4% of the real cost, and a region + * actually took 14 seconds (#157). The same 32 slots were therefore blocking + * for around 600 ms each, ten times the limit the smoke asserts — it did not + * catch it because its fixture had no sections to render. + * + * A region is now about 1.5 s here, so 4 slots is around 6 ms in steady state. + * 8 would be enough for that, and is not enough for the FIRST region a process + * parses: measured over five runs, the first blocks for 40 ms against 15-19 ms + * for every one after it, because V8 has not optimised these loops yet. The + * first is the one an operator meets — they open the map, and it is the only + * parse that has happened. Sized for that rather than for the average. * - * 32 puts the longest uninterrupted block around 6 ms — under a frame — at the - * cost of 32 extra event-loop turns per region, which is nothing. + * The price is 256 event-loop turns per region instead of 32, and a + * `setImmediate` costs microseconds. */ -const SLICE_SLOTS = 32 +const SLICE_SLOTS = 4 function loadRegion( serverId: string, @@ -860,9 +924,9 @@ async function drain(): Promise { // the very next call was about to do anyway. const before = lastParseAt try { - // Sliced: a region takes ~180 ms and this thread answers every IPC + // Sliced: a region takes about 1.5 s and this thread answers every IPC // call, so parsing one in a single block froze the interface for that - // long. Same work, interruptible. + // long. Same work, interruptible — see `SLICE_SLOTS`. await chunkTileSliced(job.serverId, job.dim, job.cx, job.cz) } catch { /* one bad region must not stop the queue */ diff --git a/src/main/smoke.ts b/src/main/smoke.ts index 0467ecb..1d93515 100644 --- a/src/main/smoke.ts +++ b/src/main/smoke.ts @@ -110,6 +110,7 @@ import { shade, unpackIndices } from '@shared/regionFormat' +import type { IndexPacking } from '@shared/regionFormat' import { publicVerifyReply, verifyDecision } from '@shared/playerVerify' import { newRefreshState, tryRefresh, INVENTORY_REFRESH } from '@shared/refreshLimit' import { @@ -1038,6 +1039,106 @@ export async function runModUpdateSmoke(): Promise { const negative = unpackIndices([-1n], 4, 16, 'padded') if (negative.some((v) => v !== 15)) return fail('a negative long decoded to ' + negative[0]) + // The 32-bit extraction against the arbitrary-precision one it replaced. + // + // #157 rewrote this off BigInt for speed, and a bit-packing bug does not + // throw — it produces a plausible map with the wrong blocks in it. The + // reference below is a VERBATIM copy of the pre-#157 implementation, kept + // here on purpose: the cases above pin the handful of layouts somebody + // thought of, and this pins every other one to the answer that shipped. + { + const reference = (l: bigint[], bits: number, count: number, p: IndexPacking): number[] => { + const out = new Array(count).fill(0) + if (bits <= 0 || !l.length) return out + const mask = (1n << BigInt(bits)) - 1n + const u = (v: bigint): bigint => BigInt.asUintN(64, v) + if (p === 'padded') { + const perLong = Math.floor(64 / bits) + for (let i = 0; i < count; i++) { + const li = Math.floor(i / perLong) + if (li >= l.length) break + out[i] = Number((u(l[li]) >> BigInt((i % perLong) * bits)) & mask) + } + return out + } + for (let i = 0; i < count; i++) { + const bitPos = i * bits + const li = Math.floor(bitPos / 64) + if (li >= l.length) break + const offset = bitPos % 64 + let value = (u(l[li]) >> BigInt(offset)) & mask + if (offset + bits > 64 && li + 1 < l.length) { + const taken = 64 - offset + value |= (u(l[li + 1]) & ((1n << BigInt(bits - taken)) - 1n)) << BigInt(taken) + } + out[i] = Number(value & mask) + } + return out + } + + let seed = 0x9e3779b9 + const rnd = (): number => { + seed ^= seed << 13 + seed |= 0 + seed ^= seed >>> 17 + seed ^= seed << 5 + seed |= 0 + return seed >>> 0 + } + // Counted, not assumed. A run that never produced a straddling layout + // would pass with the straddle deleted, which is the shape of vacuous + // test this file has been caught by before. + let straddled = 0 + let spanned = 0 + let signed = 0 + for (let trial = 0; trial < 1500; trial++) { + const bits = 4 + (rnd() % 28) // 4..31 + const n = 1 + (rnd() % 40) + const longs: bigint[] = [] + for (let i = 0; i < n; i++) { + const v = BigInt.asIntN(64, (BigInt(rnd()) << 32n) | BigInt(rnd())) + // The sign bit is DATA. A plain shift fills with ones instead. + if (v < 0n) signed++ + longs.push(v) + } + const count = 1 + (rnd() % 4096) + for (const packing of ['padded', 'spanning'] as IndexPacking[]) { + if (packing === 'padded') { + const perLong = Math.floor(64 / bits) + for (let i = 0; i < Math.min(count, perLong * n); i++) { + const o = (i % perLong) * bits + // Padded means an index never spans two LONGS — not that it + // never spans the two 32-bit halves of one. + if (o < 32 && o + bits > 32) { + straddled++ + break + } + } + } else { + for (let i = 0; i < count; i++) { + if (((i * bits) % 64) + bits > 64) { + spanned++ + break + } + } + } + const want = reference(longs, bits, count, packing) + const got = unpackIndices(longs, bits, count, packing) + for (let i = 0; i < count; i++) { + if (want[i] !== got[i]) { + return fail( + `unpackIndices differs from the reference: bits=${bits} packing=${packing} ` + + `index=${i} reference=${want[i]} got=${got[i]}` + ) + } + } + } + } + if (straddled < 100) return fail('the fuzz never straddled a 32-bit half: ' + straddled) + if (spanned < 100) return fail('the fuzz never spanned two longs: ' + spanned) + if (signed < 100) return fail('the fuzz never used a long with the sign bit set: ' + signed) + } + // Four bits minimum whatever the palette holds. if (bitsPerIndex(1) !== 4 || bitsPerIndex(16) !== 4) return fail('small palettes must still use 4 bits') if (bitsPerIndex(17) !== 5) return fail('17 entries needs 5 bits') @@ -2287,16 +2388,68 @@ export async function runWorldsSmoke(): Promise { // --- 12a. a region parse must not freeze the process (#151) ------------- { - // A real region file: 1024 slots, each a deflated NBT compound. The - // chunks carry no sections, so `tileFromChunk` yields nothing — but the - // expensive half, decompress plus NBT parse, runs exactly as it does on a - // real world, which is what the timing here is about. + // A real region file: 1024 slots, each a deflated NBT compound. + // + // The chunks carry REAL SECTIONS, and that is the whole point. This + // fixture used to write chunks with no sections at all, so + // `tileFromChunk` returned null on its first line and the timing below + // measured the decompress and the NBT parse and nothing else — which are + // together 4% of what a region actually costs. The assertion was sound + // and the fixture made it vacuous: the real longest slice was 584 ms + // against a limit of 60, and this gate stayed green for months (#157). + // + // Shaped like a real chunk rather than uniformly dense: above the surface + // everything is a single-entry air palette with no data array, below it + // is mostly stone, and only the band around the surface carries a full + // palette. A uniformly dense region came out at 52 MB against a real + // world's ~5, which would have measured something no operator has. + const BLOCKS = [ + 'minecraft:air', 'minecraft:stone', 'minecraft:dirt', 'minecraft:grass_block', + 'minecraft:water', 'minecraft:sand', 'minecraft:oak_log', 'minecraft:oak_leaves', + 'minecraft:gravel', 'minecraft:deepslate', 'minecraft:andesite', 'minecraft:diorite', + 'minecraft:granite', 'minecraft:coal_ore', 'minecraft:iron_ore', 'minecraft:snow' + ] + // 4 bits per index across 4096 blocks is 256 longs, the usual dense + // section. Repetitive, like real terrain — random longs do not deflate + // and the fixture would be six times the size of the thing it imitates. + const packed = (seed: number): number[][] => { + const pattern: number[][] = [] + for (let i = 0; i < 12; i++) pattern.push([(seed * 2654435761 + i * 40503) | 0, (seed + i) | 0]) + return Array.from({ length: 256 }, (_, i) => pattern[i % pattern.length]) + } + const section = (y: number, seed: number): unknown => { + // The surface band. Everything above it is air, everything below stone. + const dense = y >= -2 && y <= 6 + const names = dense ? BLOCKS : [BLOCKS[y > 6 ? 0 : 1]] + return { + 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 } })) + } + }, + ...(dense ? { data: { type: 'longArray', value: packed(seed + y) } } : {}) + } + } + } + } const chunkNbt = nbt.writeUncompressed({ type: 'compound', name: '', value: { DataVersion: { type: 'int', value: 3953 }, - // Padding, so one chunk is a realistic size rather than 30 bytes. + sections: { + type: 'list', + value: { + type: 'compound', + value: Array.from({ length: 24 }, (_, i) => section(i - 4, 7)) + } + }, Heightmaps: { type: 'longArray', value: Array.from({ length: 256 }, () => [0, 1]) } } } as never) @@ -2337,6 +2490,15 @@ export async function runWorldsSmoke(): Promise { const total = Date.now() - t0 const slice = tilesMod.lastParseSliceMs() if (slice <= 0) return fail('the sliced parse never ran; the fixture was not read') + // The fixture has to have produced a real surface, or the timing above is + // measuring the parse of something that renders to nothing — which is + // exactly how this gate came to certify a 584 ms freeze as 3 ms (#157). + // Asserting the interesting case was REACHED, not merely that the loop ran. + const drawn = tilesMod.peekChunkTile(SID, 'overworld', 0, 0) + if (!drawn) return fail('the fixture rendered no tile; the timing measured an empty parse') + if (drawn.colour.some((c) => c < 0)) { + return fail('the fixture left columns unresolved; it is not a full surface') + } if (slice > 60) return fail('a parse slice blocked for ' + slice + ' ms; the interface will stutter') console.log( 'WORLDS-SMOKE: region parsed in ' + total + ' ms, longest uninterrupted slice ' + slice + ' ms' diff --git a/src/shared/regionFormat.ts b/src/shared/regionFormat.ts index 3e00643..de74e3b 100644 --- a/src/shared/regionFormat.ts +++ b/src/shared/regionFormat.ts @@ -105,37 +105,158 @@ export function unpackIndices( ): number[] { const out = new Array(count).fill(0) if (bits <= 0 || !longs.length) return out - const mask = (1n << BigInt(bits)) - 1n - const asUnsigned = (v: bigint): bigint => BigInt.asUintN(64, v) + const src = prepareIndices(longs, bits, packing) + for (let i = 0; i < count; i++) { + if (!indexInRange(src, i)) break + out[i] = indexAt(src, i) + } + return out +} - if (packing === 'padded') { - const perLong = Math.floor(64 / bits) - for (let i = 0; i < count; i++) { - const longIndex = Math.floor(i / perLong) - if (longIndex >= longs.length) break - const shift = BigInt((i % perLong) * bits) - out[i] = Number((asUnsigned(longs[longIndex]) >> shift) & mask) +/** + * Longs prepared for repeated single-index reads. + * + * The reason this exists rather than a plain `number[]` of every index: a + * surface scan reads the top layer of a section, then the next one down, and + * stops as soon as every column has an answer — usually after two or three of + * the sixteen. Unpacking all 4096 up front did the other thirteen layers' + * work for nothing AND allocated a 4096-element array per section, which + * together were 562 ms of the 1030 ms a region spent here and a sixth of the + * process's garbage (#157). + */ +export interface IndexSource { + /** High and low 32-bit halves of each long. Empty when `big` is in use. */ + hi: Int32Array + lo: Int32Array + /** Set only for palettes too wide for 32-bit arithmetic — see below. */ + big: bigint[] | null + bits: number + mask: number + /** Indices per long under `padded`; 0 when spanning. */ + perLong: number + spanning: boolean + longs: number +} + +export function prepareIndices( + longs: bigint[], + bits: number, + packing: IndexPacking +): IndexSource { + const spanning = packing === 'spanning' + // Above 31 the 32-bit extraction cannot hold the answer. No real palette gets + // near it — 4096 blocks in a section is 12 bits — but a corrupt one must not + // read as a wrong index, so it keeps the arbitrary-precision path rather than + // truncating silently. + if (bits > 31) { + return { + hi: new Int32Array(0), lo: new Int32Array(0), big: longs, bits, + mask: 0, perLong: spanning ? 0 : Math.floor(64 / bits), spanning, longs: longs.length } - return out } + /** + * Each long split into two 32-bit halves, ONCE. + * + * The BigInt version did a shift, a mask and a `Number()` per index, at about + * 120 ns each. BigInt is arbitrary-precision and allocates; the 32-bit + * integer ops below do not. The conversion is still BigInt, but it happens + * per LONG (256 a section) rather than per index (4096). + */ + const n = longs.length + const hi = new Int32Array(n) + const lo = new Int32Array(n) + for (let i = 0; i < n; i++) { + const u = BigInt.asUintN(64, longs[i]) + lo[i] = Number(u & 0xffffffffn) | 0 + hi[i] = Number(u >> 32n) | 0 + } + return { + hi, lo, big: null, bits, + mask: bits === 31 ? 0x7fffffff : (1 << bits) - 1, + perLong: spanning ? 0 : Math.floor(64 / bits), + spanning, + longs: n + } +} - for (let i = 0; i < count; i++) { - const bitPos = i * bits - const longIndex = Math.floor(bitPos / 64) - if (longIndex >= longs.length) break - const offset = bitPos % 64 - let value = (asUnsigned(longs[longIndex]) >> BigInt(offset)) & mask - // Straddling: take the remaining high bits from the next long. - if (offset + bits > 64 && longIndex + 1 < longs.length) { - const taken = 64 - offset - const rest = asUnsigned(longs[longIndex + 1]) & ((1n << BigInt(bits - taken)) - 1n) - value |= rest << BigInt(taken) - } - out[i] = Number(value & mask) +/** + * Whether index `i` is backed by a long at all. + * + * The old loop `break`s when it runs past the end, leaving the rest of the + * output at zero. Callers that read one index at a time need the same answer + * without a loop to break out of. + */ +export function indexInRange(src: IndexSource, i: number): boolean { + const li = src.spanning ? ((i * src.bits) / 64) | 0 : (i / src.perLong) | 0 + return li < src.longs +} + +/** + * One index, `bits` wide. + * + * The straddle is the case worth naming: "padded" means an index never spans + * two LONGS, not that it never spans the two halves of one. At 5 bits index 6 + * starts at bit 30 and runs to 34, so it takes two bits from the low half and + * three from the high one. Reading only one half there gives a plausible wrong + * index, which is the failure this format specialises in. + */ +export function indexAt(src: IndexSource, i: number): number { + const { bits, mask, hi, lo } = src + if (src.big) return indexAtBig(src, i) + if (!src.spanning) { + const li = (i / src.perLong) | 0 + if (li >= src.longs) return 0 + const offset = (i % src.perLong) * bits + if (offset >= 32) return (hi[li] >>> (offset - 32)) & mask + if (offset + bits <= 32) return (lo[li] >>> offset) & mask + return ((lo[li] >>> offset) | (hi[li] << (32 - offset))) & mask } - return out + const bitPos = i * bits + const li = (bitPos / 64) | 0 + if (li >= src.longs) return 0 + const offset = bitPos % 64 + if (offset + bits <= 64) { + if (offset >= 32) return (hi[li] >>> (offset - 32)) & mask + if (offset + bits <= 32) return (lo[li] >>> offset) & mask + return ((lo[li] >>> offset) | (hi[li] << (32 - offset))) & mask + } + // Spanning two longs: the low bits finish this one, the rest start the next. + // `taken` is under `bits` here, so the shift stays inside 32 bits, and + // `offset` is above 33 for any width this path handles. + const taken = 64 - offset + let value = (hi[li] >>> (offset - 32)) & ((1 << taken) - 1) + if (li + 1 < src.longs) { + const rest = lo[li + 1] & ((1 << (bits - taken)) - 1) + value |= rest << taken + } + return value & mask } +/** The arbitrary-precision read, for palettes too wide for 32-bit arithmetic. */ +function indexAtBig(src: IndexSource, i: number): number { + const longs = src.big as bigint[] + const bits = src.bits + const mask = (1n << BigInt(bits)) - 1n + const asUnsigned = (v: bigint): bigint => BigInt.asUintN(64, v) + if (!src.spanning) { + const li = Math.floor(i / src.perLong) + if (li >= longs.length) return 0 + return Number((asUnsigned(longs[li]) >> BigInt((i % src.perLong) * bits)) & mask) + } + const bitPos = i * bits + const li = Math.floor(bitPos / 64) + if (li >= longs.length) return 0 + const offset = bitPos % 64 + let value = (asUnsigned(longs[li]) >> BigInt(offset)) & mask + if (offset + bits > 64 && li + 1 < longs.length) { + const taken = 64 - offset + const rest = asUnsigned(longs[li + 1]) & ((1n << BigInt(bits - taken)) - 1n) + value |= rest << BigInt(taken) + } + return Number(value & mask) +} + + /** * Blocks that are not surface. * diff --git a/src/shared/tileCache.ts b/src/shared/tileCache.ts index fc8cadd..3e66679 100644 --- a/src/shared/tileCache.ts +++ b/src/shared/tileCache.ts @@ -5,11 +5,11 @@ * mis-reads its own file produces a *plausible* map rather than an error, and * the cache then serves that wrong picture until somebody deletes it by hand. * - * Why it is worth having at all, measured on a real world: one 5.4 MB region of - * 811 chunks costs 180 ms to decompress and NBT-parse before any surface work, - * and #119's cache was a Map in memory — so every restart paid it again. The - * encoded form of the same region is about 1 MB, gzips in 2-3 ms and gunzips in - * about 1. + * Why it is worth having at all: a 4 MB region of 1024 chunks costs about 1.5 s + * to decompress, NBT-parse and extract surfaces from (#157 measured it — it was + * 14 s before that), and #119's cache was a Map in memory, so every restart + * paid it again. The encoded form of the same region is about 1 MB and reads + * back in roughly 16 ms. */ import type { StructureKind } from './regionFormat'