diff --git a/scripts/buildMesherWorker.mjs b/scripts/buildMesherWorker.mjs index 990ab4e..9eb0c08 100644 --- a/scripts/buildMesherWorker.mjs +++ b/scripts/buildMesherWorker.mjs @@ -77,7 +77,11 @@ const buildOptions = createWorkerBuildOptions({ // Flatten output paths so worker bundles land at dist/mesherWasm.js (web-client contract) // instead of dist/wasm-mesher/worker/mesherWasm.js. entryNames: '[name]', - entryPoints: [path.join(rootDir, './src/wasm-mesher/worker/mesherWasm.ts'), path.join(rootDir, './src/mesher-legacy/mesher.ts')], + entryPoints: [ + path.join(rootDir, './src/wasm-mesher/worker/mesherWasm.ts'), + path.join(rootDir, './src/mesher-legacy/mesher.ts'), + path.join(rootDir, './src/wasm-mesher/worker/lightOwnerWorker.ts') + ], plugins: [wasmPlugin] } }) diff --git a/scripts/generate-light-tables-1171.mjs b/scripts/generate-light-tables-1171.mjs new file mode 100644 index 0000000..bd69ae1 --- /dev/null +++ b/scripts/generate-light-tables-1171.mjs @@ -0,0 +1,259 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import MinecraftData from 'minecraft-data' + +const VERSION = '1.17.1' +const WORLD_VERSION = 2730 +const VANILLA_COMMIT = '72447031d70fca0cfc9087de2704da83bc12fa79' +const FACE_PROPS = ['down', 'east', 'north', 'south', 'up', 'west'] + +const here = dirname(fileURLToPath(import.meta.url)) +const root = join(here, '..') +const blocksJavaPath = join(root, 'src/lib/vanilla-1.17.1/Blocks.java') +const outPath = join(root, 'src/lib/lightTables1171.generated.json') + +export function matchParens(text, openIdx) { + let depth = 0 + for (let i = openIdx; i < text.length; i++) { + const ch = text[i] + if (ch === '(') depth++ + else if (ch === ')') { + depth-- + if (depth === 0) return text.slice(openIdx + 1, i) + } + } + throw new Error('unbalanced parentheses') +} + +export function parseEmissionRules(blocksJava) { + const rules = new Map() + let i = 0 + while (true) { + const j = blocksJava.indexOf('register(', i) + if (j < 0) break + if (blocksJava.startsWith('String', j + 9)) { + i = j + 9 + continue + } + const inner = matchParens(blocksJava, j + 8) + const nameMatch = inner.match(/^\s*"([^"]+)"/) + if (nameMatch) { + const lightAt = inner.indexOf('.lightLevel(') + let kind = { type: 'zero' } + if (lightAt >= 0) { + const arg = matchParens(inner, lightAt + '.lightLevel'.length) + .replace(/\s+/g, ' ') + .trim() + kind = classifyLightArg(arg) + } + rules.set(nameMatch[1], kind) + } + i = j + 9 + } + return rules +} + +function classifyLightArg(arg) { + const constant = arg.match(/^blockStatex -> (\d+)$/) + if (constant) return { type: 'const', value: Number(constant[1]) } + const lit = arg.match(/^litBlockEmission\((\d+)\)$/) + if (lit) return { type: 'lit', value: Number(lit[1]) } + if (arg === 'CandleBlock.LIGHT_EMISSION') return { type: 'candle' } + if (arg === 'LightBlock.LIGHT_EMISSION') return { type: 'light' } + if (arg === 'CaveVines.emission(14)') return { type: 'berries', value: 14 } + if (arg === 'GlowLichenBlock.emission(7)') return { type: 'anyFace', value: 7 } + if (arg === 'blockStatex -> RespawnAnchorBlock.getScaledChargeLevel(blockStatex, 15)') { + return { type: 'respawnAnchor' } + } + if (arg.includes('SeaPickleBlock.isDead')) return { type: 'seaPickle' } + throw new Error(`unknown lightLevel argument: ${arg}`) +} + +function propsAt(block, stateId) { + let data = stateId - (block.minStateId ?? block.defaultState) + const out = {} + const states = block.states ?? [] + for (let i = states.length - 1; i >= 0; i--) { + const prop = states[i] + const idx = data % prop.num_values + data = Math.floor(data / prop.num_values) + if (prop.type === 'bool') out[prop.name] = idx === 0 + else if (prop.values) out[prop.name] = prop.values[idx] + else out[prop.name] = idx + } + return out +} + +function emissionFor(rule, props) { + switch (rule.type) { + case 'zero': + return 0 + case 'const': + return rule.value + case 'lit': + return props.lit === true ? rule.value : 0 + case 'candle': + return props.lit === true ? 3 * Number(props.candles) : 0 + case 'light': + return Number(props.level) + case 'berries': + return props.berries === true ? rule.value : 0 + case 'anyFace': + return FACE_PROPS.some(face => props[face] === true) ? rule.value : 0 + case 'respawnAnchor': + return Math.floor((Number(props.charges) / 4) * 15) + case 'seaPickle': + return props.waterlogged === true ? 3 + 3 * Number(props.pickles) : 0 + default: + throw new Error(`unhandled rule ${rule.type}`) + } +} + +function isSlabName(name) { + return name.endsWith('_slab') || name === 'petrified_oak_slab' +} + +function isStairsName(name) { + return name.endsWith('_stairs') +} + +function isLeavesName(name) { + return name.endsWith('_leaves') +} + +function isLiquidName(name) { + return name === 'water' || name === 'lava' || name === 'bubble_column' +} + +/** Raw 1.17.1 getLightBlock. Decay opacity is max(1, this); sky source stops when this != 0. */ +function lightBlockFor(block, props) { + const name = block.name + if (name === 'tinted_glass') return 15 + if (isLeavesName(name)) return 1 + if (isLiquidName(name)) return 1 + if (name === 'barrier') return 1 + if (isSlabName(name)) { + if (props.type === 'double') return 15 + return props.waterlogged === true ? 1 : 0 + } + if (isStairsName(name)) { + return props.waterlogged === true ? 1 : 0 + } + if (name === 'snow') { + return Number(props.layers) === 8 ? 15 : 0 + } + if ((name === 'piston' || name === 'sticky_piston') && props.extended === true) { + return 0 + } + if (name === 'glass' || name.endsWith('_stained_glass')) return 0 + const fl = block.filterLight + if (typeof fl === 'number') return Math.max(0, Math.min(15, fl)) + if (!block.transparent && block.boundingBox === 'block') return 15 + return 0 +} + +const UPPER = { + north: 0x30, + south: 0xc0, + west: 0x50, + east: 0xa0 +} +const CCW = { north: 'west', west: 'south', south: 'east', east: 'north' } +const CW = { north: 'east', east: 'south', south: 'west', west: 'north' } +const OUTER_OCTANT = { + north_west: 0x10, + north_east: 0x20, + south_west: 0x40, + south_east: 0x80 +} + +function invertY(occ) { + return ((occ & 0x0f) << 4) | ((occ & 0xf0) >> 4) +} + +function stairOccupancy(props) { + const facing = String(props.facing) + const shape = String(props.shape) + const base = 0x0f + let occ = base + if (shape === 'straight') occ |= UPPER[facing] ?? 0 + else if (shape === 'inner_left') occ |= (UPPER[facing] ?? 0) | (UPPER[CCW[facing]] ?? 0) + else if (shape === 'inner_right') occ |= (UPPER[facing] ?? 0) | (UPPER[CW[facing]] ?? 0) + else if (shape === 'outer_left') occ |= OUTER_OCTANT[`${facing}_${CCW[facing]}`] ?? OUTER_OCTANT[`${CCW[facing]}_${facing}`] ?? 0 + else if (shape === 'outer_right') occ |= OUTER_OCTANT[`${facing}_${CW[facing]}`] ?? OUTER_OCTANT[`${CW[facing]}_${facing}`] ?? 0 + return props.half === 'top' ? invertY(occ) : occ +} + +/** + * 2×2×2 occupancy for 1.17.1 useShapeForLightOcclusion blocks. + * Full cubes stay 0: they block via lightBlock 15, and vanilla isEmptyShape is true + * unless useShapeForLightOcclusion. Glass/leaves: noOcclusion → 0. + */ +function occupancyFor(block, props) { + const name = block.name + if (isSlabName(name)) { + if (props.type === 'double') return 0 + return props.type === 'top' ? 0xf0 : 0x0f + } + if (isStairsName(name)) return stairOccupancy(props) + if (name === 'snow' && Number(props.layers) === 8) return 0xff + return 0 +} + +export function buildVanillaLightTables1171(blocksJava, mcData = MinecraftData(VERSION)) { + const rules = parseEmissionRules(blocksJava) + let maxState = 0 + for (const block of mcData.blocksArray) { + const hi = block.maxStateId ?? block.defaultState + if (hi > maxState) maxState = hi + if (!rules.has(block.name)) { + throw new Error(`1.17.1 Blocks.java has no register() for ${block.name}`) + } + } + const emission = new Uint8Array(maxState + 1) + const opacity = new Uint8Array(maxState + 1) + const occupancy = new Uint8Array(maxState + 1) + for (const block of mcData.blocksArray) { + const rule = rules.get(block.name) + const lo = block.minStateId ?? block.defaultState + const hi = block.maxStateId ?? block.defaultState + for (let id = lo; id <= hi; id++) { + const props = propsAt(block, id) + emission[id] = Math.max(0, Math.min(15, emissionFor(rule, props))) + opacity[id] = lightBlockFor(block, props) + occupancy[id] = occupancyFor(block, props) + } + } + return { + version: VERSION, + vanillaWorldVersion: WORLD_VERSION, + source: 'vanilla-1.17.1-blocks-java', + vanillaCommit: VANILLA_COMMIT, + vanillaRegistryComplete: true, + emission, + opacity, + occupancy + } +} + +function toArtifact(tables) { + return { + version: tables.version, + vanillaWorldVersion: tables.vanillaWorldVersion, + source: tables.source, + vanillaCommit: tables.vanillaCommit, + vanillaRegistryComplete: tables.vanillaRegistryComplete, + emissionB64: Buffer.from(tables.emission).toString('base64'), + opacityB64: Buffer.from(tables.opacity).toString('base64'), + occupancyB64: Buffer.from(tables.occupancy).toString('base64') + } +} + +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1] +if (isMain) { + const blocksJava = readFileSync(blocksJavaPath, 'utf8') + const tables = buildVanillaLightTables1171(blocksJava) + writeFileSync(outPath, `${JSON.stringify(toArtifact(tables), null, 2)}\n`) + console.log(`wrote ${outPath} states=${tables.emission.length}`) +} diff --git a/src/bundler/bundlePrepare.test.ts b/src/bundler/bundlePrepare.test.ts new file mode 100644 index 0000000..bb60bab --- /dev/null +++ b/src/bundler/bundlePrepare.test.ts @@ -0,0 +1,24 @@ +import { mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { bundlePrepareMesherWorkers, MESHER_DIST_FILES } from './bundlePrepare' + +describe('MESHER_DIST_FILES', () => { + it('is the source of truth for lightOwnerWorker.js', () => { + expect(MESHER_DIST_FILES).toContain('lightOwnerWorker.js') + expect(MESHER_DIST_FILES).toContain('lightOwnerWorker.js.map') + }) + + it('copies lightOwnerWorker.js from renderer dist', async () => { + const mesherDistDir = await mkdtemp(join(tmpdir(), 'renderer-dist-')) + const outDir = await mkdtemp(join(tmpdir(), 'app-dist-')) + for (const name of MESHER_DIST_FILES) { + await writeFile(join(mesherDistDir, name), `asset:${name}`) + } + + const copied = await bundlePrepareMesherWorkers({ cwd: outDir, mesherDistDir, outDir }) + expect(copied.some(path => path.endsWith('lightOwnerWorker.js'))).toBe(true) + expect(await readFile(join(outDir, 'lightOwnerWorker.js'), 'utf8')).toBe('asset:lightOwnerWorker.js') + }) +}) diff --git a/src/bundler/bundlePrepare.ts b/src/bundler/bundlePrepare.ts index 8700816..a401ab9 100644 --- a/src/bundler/bundlePrepare.ts +++ b/src/bundler/bundlePrepare.ts @@ -3,7 +3,7 @@ import { createRequire } from 'node:module' import path from 'node:path' /** Worker-related basenames inside `minecraft-renderer`/`dist`; skipped if missing. */ -export const MESHER_DIST_FILES = ['mesher.js', 'mesher.js.map', 'mesherWasm.js', 'mesherWasm.js.map'] as const +export const MESHER_DIST_FILES = ['mesher.js', 'mesher.js.map', 'mesherWasm.js', 'mesherWasm.js.map', 'lightOwnerWorker.js', 'lightOwnerWorker.js.map'] as const export type BundlePrepareMesherOptions = { cwd?: string diff --git a/src/graphicsBackend/appViewer.ts b/src/graphicsBackend/appViewer.ts index e1a1757..cd7616a 100644 --- a/src/graphicsBackend/appViewer.ts +++ b/src/graphicsBackend/appViewer.ts @@ -24,6 +24,7 @@ import type { import { WorldView, WorldProvider, WorldViewWorker } from '../worldView' import { getInitialPlayerState } from './playerState' import { defaultWorldRendererConfig, defaultGraphicsBackendConfig, getDefaultRendererState, WorldRendererConfig } from './config' +import { maybeEnableClientLightTraceFromLocation } from '../lib/clientLightTrace' import { PlayerStateReactive } from '../playerState/playerState' import { ResourcesManager, ResourcesManagerTransferred } from '../resourcesManager' import { preloadMesherWorkerScript } from './preloadWorkers' @@ -101,6 +102,20 @@ export class AppViewer { ...defaultWorldRendererConfig, ...options.rendererConfig }) + // Manual live-test hatch: `?clientLight=1` sets `enableClientLightOwner`. Spawn still + // requires session version 1.17.1 (`shouldSpawnClientLightOwner`); other versions keep + // server light. An explicit code override always wins; without a browser location this + // is a no-op (unit tests keep testing the default). + if (options.rendererConfig?.enableClientLightOwner === undefined && typeof location !== 'undefined') { + try { + if (new URLSearchParams(location.search).get('clientLight') === '1') { + this.inWorldRenderingConfig.enableClientLightOwner = true + } + } catch { + // ignore malformed URL in non-browser runtimes + } + } + maybeEnableClientLightTraceFromLocation() const defaultState = getDefaultRendererState() this.rendererState = defaultState.reactive diff --git a/src/graphicsBackend/config.ts b/src/graphicsBackend/config.ts index 78c00e3..4b55245 100644 --- a/src/graphicsBackend/config.ts +++ b/src/graphicsBackend/config.ts @@ -42,6 +42,11 @@ export const defaultWorldRendererConfig = { * remeshing (change worker). When true, initial chunk meshing is * distributed only across workers[0 .. n-2]. */ dedicatedChangeWorker: false, + /** + * Dedicated WASM light-owner worker. Default OFF: wiring exists so a + * human can flip this for live 1.17.1 runs. Do not enable for players. + */ + enableClientLightOwner: false, // Rendering engine settings /** Face shading: vanilla Minecraft vs higher-contrast client look */ diff --git a/src/graphicsBackend/rendererDefaultOptions.test.ts b/src/graphicsBackend/rendererDefaultOptions.test.ts new file mode 100644 index 0000000..a6ed053 --- /dev/null +++ b/src/graphicsBackend/rendererDefaultOptions.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { migrateRendererOptions, RENDERER_DEFAULT_OPTIONS, resolveEnableLighting } from './rendererDefaultOptions' + +describe('migrateRendererOptions', () => { + it('drops the leaked migration flag so it is not a user option', () => { + const saved: Record = { + newVersionsLighting: false, + migratedNewVersionsLightingDefault: true + } + migrateRendererOptions(saved) + expect(saved.newVersionsLighting).toBe(false) + expect(saved).not.toHaveProperty('migratedNewVersionsLightingDefault') + }) +}) + +describe('newer-version lighting defaults', () => { + it('keeps lighting opt-in on 1.13+', () => { + expect(RENDERER_DEFAULT_OPTIONS.newVersionsLighting).toBe(false) + expect(resolveEnableLighting(RENDERER_DEFAULT_OPTIONS.newVersionsLighting, true)).toBe(false) + }) + + it.each([false, true])('preserves a saved lighting preference (%s)', value => { + const saved: Record = { newVersionsLighting: value } + migrateRendererOptions(saved) + expect(saved.newVersionsLighting).toBe(value) + }) + + it('does not invent a stored value when the key was never saved', () => { + const saved: Record = {} + migrateRendererOptions(saved) + expect(saved).not.toHaveProperty('newVersionsLighting') + }) +}) + +describe('resolveEnableLighting', () => { + it('follows the option on 1.13+ (blockStateId)', () => { + expect(resolveEnableLighting(true, true)).toBe(true) + expect(resolveEnableLighting(false, true)).toBe(false) + }) + + it('always enables lighting on pre-1.13', () => { + expect(resolveEnableLighting(false, false)).toBe(true) + expect(resolveEnableLighting(true, false)).toBe(true) + }) + + it('treats a missing protocol probe as pre-1.13 so a menu toggle cannot throw', () => { + expect(resolveEnableLighting(false, undefined)).toBe(true) + expect(resolveEnableLighting(true, undefined)).toBe(true) + }) +}) diff --git a/src/graphicsBackend/rendererDefaultOptions.ts b/src/graphicsBackend/rendererDefaultOptions.ts index 87061fb..065a204 100644 --- a/src/graphicsBackend/rendererDefaultOptions.ts +++ b/src/graphicsBackend/rendererDefaultOptions.ts @@ -121,6 +121,17 @@ export function migrateRendererOptions(saved: Record): void { } delete saved[oldKey] } + + // Internal one-shot flag from an earlier lighting-default migration. Never a user option. + delete saved.migratedNewVersionsLightingDefault +} + +/** + * Runtime `enableLighting` from the stored option and protocol. + * Pre-1.13 (no blockStateId) always lights. Missing protocol (menu, no bot) must not throw. + */ +export function resolveEnableLighting(newVersionsLighting: boolean, blockStateIdSupported: boolean | undefined): boolean { + return blockStateIdSupported !== true || newVersionsLighting } /** Settings UI metadata for {@link RENDERER_DEFAULT_OPTIONS} keys. */ @@ -235,7 +246,8 @@ export const RENDERER_OPTIONS_META: Partial( } /** Call when mineflayer bot is created (lighting depends on protocol features). */ -export function applyRendererEnableLighting(appViewer: AppViewer, newVersionsLighting: boolean, blockStateIdSupported: boolean): void { - appViewer.inWorldRenderingConfig.enableLighting = !blockStateIdSupported || newVersionsLighting +export function applyRendererEnableLighting(appViewer: AppViewer, newVersionsLighting: boolean, blockStateIdSupported: boolean | undefined): void { + appViewer.inWorldRenderingConfig.enableLighting = resolveEnableLighting(newVersionsLighting, blockStateIdSupported) } diff --git a/src/lib/blockEntityLightRegistry.ts b/src/lib/blockEntityLightRegistry.ts index adc5d60..76fac4a 100644 --- a/src/lib/blockEntityLightRegistry.ts +++ b/src/lib/blockEntityLightRegistry.ts @@ -40,6 +40,10 @@ export class BlockEntityLightRegistry { return this.skyLevel } + getLightmapParams(): BlockLightmapParams { + return { ...this.lightmapParams } + } + private refreshAll(): void { for (const entry of this.entries) { this.applyBrightness(entry) diff --git a/src/lib/clientLightTrace.test.ts b/src/lib/clientLightTrace.test.ts new file mode 100644 index 0000000..22ee353 --- /dev/null +++ b/src/lib/clientLightTrace.test.ts @@ -0,0 +1,270 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import { + CLIENT_LIGHT_TRACE_BATCH_MS, + CLIENT_LIGHT_TRACE_BATCH_SIZE, + CLIENT_LIGHT_TRACE_CAPACITY, + armClientLightTrace, + bumpClientLightTraceSessionEpoch, + clearClientLightTrace, + comparableNow, + dumpClientLightTrace, + enableClientLightTrace, + flushClientLightTracePosts, + getClientLightTraceAggregates, + getClientLightTraceEvents, + ingestRemoteClientLightTrace, + ingestRemoteClientLightTraceMessage, + isClientLightTraceArmed, + isClientLightTraceEnabled, + isClientLightTraceMessage, + maybeEnableClientLightTraceFromLocation, + nextClientLightEditSeq, + nextClientLightRequestId, + postClientLightTrace, + recordClientLightTrace, + recordClientLightTraceGpuSample, + requestClientLightTraceSample +} from './clientLightTrace' + +afterEach(() => { + flushClientLightTracePosts() + enableClientLightTrace(false) + clearClientLightTrace() + vi.useRealTimers() +}) + +test('clientLightTrace is disabled by default and drops records', () => { + expect(isClientLightTraceEnabled()).toBe(false) + recordClientLightTrace({ phase: 'blockChange', sectionKey: '0,64,0' }) + expect(getClientLightTraceEvents()).toEqual([]) +}) + +test('clientLightTrace stores metadata-only events in a bounded ring when enabled', () => { + enableClientLightTrace(true) + const first = recordClientLightTrace({ + phase: 'blockChange', + sectionKey: '0,64,0', + blockVersion: 3, + lightVersion: 7 + }) + expect(first).toBeDefined() + expect(first!.t).toBeGreaterThan(0) + expect(first!.timeOrigin).toBe(performance.timeOrigin) + expect(first!.t).toBe(first!.timeOrigin + first!.tLocal) + const events = getClientLightTraceEvents() + expect(events).toHaveLength(1) + expect(events[0]!.phase).toBe('blockChange') + expect(events[0]!.sectionKey).toBe('0,64,0') + expect(JSON.stringify(events[0])).not.toMatch(/positions|indices|shaderCubes|chunkJson/) +}) + +test('clientLightTrace wraps at capacity and keeps the newest events', () => { + enableClientLightTrace(true) + for (let i = 0; i < CLIENT_LIGHT_TRACE_CAPACITY + 5; i++) { + recordClientLightTrace({ phase: 'mesherEnqueue', requestId: i }) + } + const events = getClientLightTraceEvents() + expect(events).toHaveLength(CLIENT_LIGHT_TRACE_CAPACITY) + expect(events[0]!.requestId).toBe(5) + expect(events[events.length - 1]!.requestId).toBe(CLIENT_LIGHT_TRACE_CAPACITY + 4) +}) + +test('clientLightTrace comparableNow is timeOrigin plus local now', () => { + const before = performance.timeOrigin + performance.now() + const stamped = comparableNow() + const after = performance.timeOrigin + performance.now() + expect(stamped).toBeGreaterThanOrEqual(before) + expect(stamped).toBeLessThanOrEqual(after) +}) + +test('clientLightTrace increments sessionEpoch, editSeq and requestId', () => { + enableClientLightTrace(true) + const epoch0 = bumpClientLightTraceSessionEpoch() + const epoch1 = bumpClientLightTraceSessionEpoch() + expect(epoch1).toBe(epoch0 + 1) + const editA = nextClientLightEditSeq() + const editB = nextClientLightEditSeq() + expect(editB).toBe(editA + 1) + const reqA = nextClientLightRequestId() + const reqB = nextClientLightRequestId() + expect(reqB).toBe(reqA + 1) + recordClientLightTrace({ + phase: 'ownerEnqueue', + sessionEpoch: epoch1, + editSeq: editB, + requestId: reqB + }) + expect(getClientLightTraceEvents()[0]).toMatchObject({ + sessionEpoch: epoch1, + editSeq: editB, + requestId: reqB + }) +}) + +test('clientLightTrace remote ingest keeps the worker timestamp', () => { + enableClientLightTrace(true) + const remoteT = 1_700_000_000_000.5 + ingestRemoteClientLightTrace({ + phase: 'mesherStart', + t: remoteT, + tLocal: 12, + timeOrigin: remoteT - 12, + sectionKey: '16,64,16', + requestId: 9 + }) + expect(getClientLightTraceEvents()[0]).toMatchObject({ + phase: 'mesherStart', + t: remoteT, + requestId: 9 + }) +}) + +test('clientLightTrace remote ingest is a no-op while disabled', () => { + ingestRemoteClientLightTrace({ + phase: 'mesherEnd', + t: 1, + tLocal: 1, + timeOrigin: 0 + }) + expect(getClientLightTraceEvents()).toEqual([]) +}) + +test('clientLightTrace aggregates counts by phase without storing meshes', () => { + enableClientLightTrace(true) + recordClientLightTrace({ phase: 'gpuDrawn', drawableFaces: 8000, cullReason: 'frustum' }) + recordClientLightTrace({ phase: 'gpuDrawn', drawableFaces: 8000, cullReason: 'occlusion' }) + recordClientLightTrace({ phase: 'reject', sectionKey: '0,64,0' }) + const agg = getClientLightTraceAggregates() + expect(agg.byPhase.gpuDrawn).toBe(2) + expect(agg.byPhase.reject).toBe(1) + expect(agg.count).toBe(3) + expect(agg.dropped).toBe(0) +}) + +describe('clientLightTrace enable from location search', () => { + test('maybeEnableClientLightTraceFromLocation stays off without the query', () => { + maybeEnableClientLightTraceFromLocation({ search: '?clientLight=1' }) + expect(isClientLightTraceEnabled()).toBe(false) + }) + + test('maybeEnableClientLightTraceFromLocation turns on with clientLightTrace=1', () => { + maybeEnableClientLightTraceFromLocation({ search: '?clientLight=1&clientLightTrace=1' }) + expect(isClientLightTraceEnabled()).toBe(true) + expect(isClientLightTraceArmed()).toBe(false) + }) +}) + +describe('clientLightTrace arm window', () => { + test('stays disarmed when enabled', () => { + enableClientLightTrace(true) + expect(isClientLightTraceEnabled()).toBe(true) + expect(isClientLightTraceArmed()).toBe(false) + }) + + test('exposes arm and dump on the window host when enabled', () => { + enableClientLightTrace(true) + const host = globalThis as typeof globalThis & { + clientLightTraceArm?: (on?: boolean) => void + clientLightTraceDump?: () => unknown + } + expect(typeof host.clientLightTraceArm).toBe('function') + expect(typeof host.clientLightTraceDump).toBe('function') + host.clientLightTraceArm?.() + expect(isClientLightTraceArmed()).toBe(true) + recordClientLightTrace({ phase: 'blockChange', sectionKey: '0,64,0' }) + const dumped = host.clientLightTraceDump?.() as { events: unknown[]; aggregates: { count: number } } + expect(dumped.events).toHaveLength(1) + expect(dumped.aggregates.count).toBe(1) + }) + + test('disabling also disarms', () => { + enableClientLightTrace(true) + armClientLightTrace(true) + enableClientLightTrace(false) + expect(isClientLightTraceArmed()).toBe(false) + }) +}) + +describe('clientLightTrace worker post batching', () => { + test('bulk events without arm send zero inter-thread messages', () => { + enableClientLightTrace(true) + const posts: unknown[] = [] + for (let i = 0; i < 50; i++) { + postClientLightTrace(message => posts.push(message), { phase: 'mesherEnqueue', requestId: i }) + } + flushClientLightTracePosts() + expect(posts).toEqual([]) + expect(getClientLightTraceEvents()).toHaveLength(50) + }) + + test('armed posts batch so messages are far fewer than events and every event is delivered', () => { + vi.useFakeTimers() + enableClientLightTrace(true) + armClientLightTrace(true) + const posts: Array<{ type?: string; events?: Array<{ requestId?: number }> }> = [] + const eventCount = 50 + for (let i = 0; i < eventCount; i++) { + postClientLightTrace(message => posts.push(message), { phase: 'mesherEnqueue', requestId: i }) + } + expect(posts).toEqual([]) + vi.advanceTimersByTime(CLIENT_LIGHT_TRACE_BATCH_MS) + expect(posts).toHaveLength(1) + expect(posts[0]!.events).toHaveLength(eventCount) + expect(posts[0]!.events!.map(event => event.requestId)).toEqual([...Array(eventCount).keys()]) + expect(isClientLightTraceMessage(posts[0])).toBe(true) + + enableClientLightTrace(false) + clearClientLightTrace() + enableClientLightTrace(true) + for (const post of posts) ingestRemoteClientLightTraceMessage(post) + expect(getClientLightTraceEvents()).toHaveLength(eventCount) + }) + + test('armed posts flush immediately at the batch size', () => { + enableClientLightTrace(true) + armClientLightTrace(true) + const posts: Array<{ events?: unknown[] }> = [] + for (let i = 0; i < CLIENT_LIGHT_TRACE_BATCH_SIZE + 3; i++) { + postClientLightTrace(message => posts.push(message), { phase: 'ownerAdmit', requestId: i }) + } + expect(posts).toHaveLength(1) + expect(posts[0]!.events).toHaveLength(CLIENT_LIGHT_TRACE_BATCH_SIZE) + flushClientLightTracePosts() + expect(posts).toHaveLength(2) + expect(posts[1]!.events).toHaveLength(3) + }) +}) + +describe('clientLightTrace gpu sampling', () => { + test('1000 unchanged gpuDrawn frames record at most one event until a sample is requested', () => { + enableClientLightTrace(true) + for (let i = 0; i < 1000; i++) { + recordClientLightTraceGpuSample('gpuDrawn', 8000, { phase: 'gpuDrawn', drawableFaces: 8000 }) + } + expect(getClientLightTraceEvents().filter(event => event.phase === 'gpuDrawn')).toHaveLength(1) + requestClientLightTraceSample() + recordClientLightTraceGpuSample('gpuDrawn', 8000, { phase: 'gpuDrawn', drawableFaces: 8000 }) + expect(getClientLightTraceEvents().filter(event => event.phase === 'gpuDrawn')).toHaveLength(2) + recordClientLightTraceGpuSample('gpuDrawn', 9000, { phase: 'gpuDrawn', drawableFaces: 9000 }) + expect(getClientLightTraceEvents().filter(event => event.phase === 'gpuDrawn')).toHaveLength(3) + }) + + test('gpuUploaded records only when the sampled metric changes', () => { + enableClientLightTrace(true) + recordClientLightTraceGpuSample('gpuUploaded', 128, { phase: 'gpuUploaded', unuploadedRanges: 4, drawableFaces: 128 }) + recordClientLightTraceGpuSample('gpuUploaded', 128, { phase: 'gpuUploaded', unuploadedRanges: 3, drawableFaces: 128 }) + recordClientLightTraceGpuSample('gpuUploaded', 256, { phase: 'gpuUploaded', unuploadedRanges: 2, drawableFaces: 256 }) + const uploaded = getClientLightTraceEvents().filter(event => event.phase === 'gpuUploaded') + expect(uploaded).toHaveLength(2) + expect(uploaded[0]!.drawableFaces).toBe(128) + expect(uploaded[1]!.drawableFaces).toBe(256) + }) +}) + +test('dumpClientLightTrace requests a gpu sample for the next draw', () => { + enableClientLightTrace(true) + recordClientLightTraceGpuSample('gpuDrawn', 10, { phase: 'gpuDrawn', drawableFaces: 10 }) + dumpClientLightTrace() + recordClientLightTraceGpuSample('gpuDrawn', 10, { phase: 'gpuDrawn', drawableFaces: 10 }) + expect(getClientLightTraceEvents().filter(event => event.phase === 'gpuDrawn')).toHaveLength(2) +}) diff --git a/src/lib/clientLightTrace.ts b/src/lib/clientLightTrace.ts new file mode 100644 index 0000000..0df8a96 --- /dev/null +++ b/src/lib/clientLightTrace.ts @@ -0,0 +1,333 @@ +/** + * Bounded causal trace for client lighting delivery. + * Off by default. Records metadata and aggregates only — never meshes, chunks, or raw packets. + */ + +export const CLIENT_LIGHT_TRACE_CAPACITY = 2048 +export const CLIENT_LIGHT_TRACE_MESSAGE = '__clientLightTrace' as const +export const CLIENT_LIGHT_TRACE_BATCH_MS = 250 +export const CLIENT_LIGHT_TRACE_BATCH_SIZE = 200 + +export type ClientLightTracePhase = + | 'inputClick' + | 'inputHand' + | 'blockChange' + | 'ownerEnqueue' + | 'ownerAdmit' + | 'ownerComplete' + | 'mesherEnqueue' + | 'mesherStart' + | 'mesherEnd' + | 'mesherFlush' + | 'receive' + | 'reject' + | 'pending' + | 'flush' + | 'gpuStaged' + | 'gpuUploaded' + | 'gpuCommitted' + | 'gpuDrawn' + +export type ClientLightTraceEvent = { + t: number + tLocal: number + timeOrigin: number + phase: ClientLightTracePhase + sessionEpoch?: number + editSeq?: number + requestId?: number + sectionKey?: string + blockVersion?: number + lightVersion?: number + worldGeneration?: number + eventAgeMs?: number + currentColumn?: string + queueDepth?: number + txnMs?: number + displayedStart?: number + displayedCount?: number + candidateStart?: number + candidateCount?: number + pendingReplace?: boolean + pendingMove?: boolean + unuploadedRanges?: number + drawableFaces?: number + cullReason?: string + flushReason?: string + workerIndex?: number +} + +export type ClientLightTracePartial = Omit & + Partial> + +export type ClientLightTraceMessage = { + type: typeof CLIENT_LIGHT_TRACE_MESSAGE + event?: ClientLightTraceEvent + events?: ClientLightTraceEvent[] +} + +export type ClientLightTraceConfig = { + enabled: boolean + armed: boolean +} + +type ClientLightTraceConfigListener = (config: ClientLightTraceConfig) => void + +let enabled = false +let armed = false +let sessionEpoch = 0 +let editSeq = 0 +let requestId = 0 +let dropped = 0 +let start = 0 +let count = 0 +let sampleRequested = false +let lastGpuDrawnMetric: number | undefined +let lastGpuUploadedMetric: number | undefined +let pendingPost: ((message: ClientLightTraceMessage) => void) | null = null +let pendingEvents: ClientLightTraceEvent[] = [] +let pendingTimer: ReturnType | null = null +let configListener: ClientLightTraceConfigListener | undefined +const ring: Array = new Array(CLIENT_LIGHT_TRACE_CAPACITY) + +export function isClientLightTraceEnabled(): boolean { + return enabled +} + +export function isClientLightTraceArmed(): boolean { + return armed +} + +export function enableClientLightTrace(on: boolean): void { + enabled = on + if (!on) { + armed = false + dropPendingPosts() + resetGpuSampleState() + } + attachDebugDump(on) + notifyConfig() +} + +export function armClientLightTrace(on = true): void { + armed = on + if (!armed) dropPendingPosts() + notifyConfig() +} + +export function setClientLightTraceConfigListener(listener: ClientLightTraceConfigListener | undefined): void { + configListener = listener +} + +export function maybeEnableClientLightTraceFromLocation(loc: { search?: string } | undefined = typeof location === 'undefined' ? undefined : location): void { + try { + if (!loc) return + if (new URLSearchParams(loc.search ?? '').get('clientLightTrace') === '1') { + enableClientLightTrace(true) + } + } catch { + // ignore malformed URL in non-browser runtimes + } +} + +export function comparableNow(): number { + return performance.timeOrigin + performance.now() +} + +export function getClientLightTraceSessionEpoch(): number { + return sessionEpoch +} + +export function bumpClientLightTraceSessionEpoch(): number { + sessionEpoch++ + return sessionEpoch +} + +export function nextClientLightEditSeq(): number { + editSeq++ + return editSeq +} + +export function nextClientLightRequestId(): number { + requestId++ + return requestId +} + +export function currentClientLightTraceIds(): { sessionEpoch: number; editSeq: number } { + return { sessionEpoch, editSeq } +} + +export function clearClientLightTrace(): void { + start = 0 + count = 0 + dropped = 0 + ring.fill(undefined) + resetGpuSampleState() +} + +export function recordClientLightTrace(partial: ClientLightTracePartial): ClientLightTraceEvent | undefined { + if (!enabled) return undefined + const tLocal = partial.tLocal ?? performance.now() + const timeOrigin = partial.timeOrigin ?? performance.timeOrigin + const event: ClientLightTraceEvent = { + ...partial, + t: partial.t ?? timeOrigin + tLocal, + tLocal, + timeOrigin, + sessionEpoch: partial.sessionEpoch ?? sessionEpoch + } + pushEvent(event) + return event +} + +export function requestClientLightTraceSample(): void { + sampleRequested = true +} + +export function recordClientLightTraceGpuSample( + phase: 'gpuDrawn' | 'gpuUploaded', + metric: number, + partial: ClientLightTracePartial | (() => ClientLightTracePartial) +): ClientLightTraceEvent | undefined { + if (!enabled) return undefined + const last = phase === 'gpuDrawn' ? lastGpuDrawnMetric : lastGpuUploadedMetric + if (last === metric && !sampleRequested) return undefined + if (phase === 'gpuDrawn') lastGpuDrawnMetric = metric + else lastGpuUploadedMetric = metric + sampleRequested = false + return recordClientLightTrace(typeof partial === 'function' ? partial() : partial) +} + +export function ingestRemoteClientLightTrace(event: ClientLightTraceEvent | undefined): void { + if (!enabled || !event || typeof event.t !== 'number' || !event.phase) return + pushEvent(event) +} + +export function ingestRemoteClientLightTraceMessage(message: ClientLightTraceMessage | { type?: unknown; event?: unknown; events?: unknown }): void { + if (!isClientLightTraceMessage(message)) return + const events = message.events ?? (message.event ? [message.event] : []) + for (const event of events) ingestRemoteClientLightTrace(event) +} + +export function postClientLightTrace(post: (message: ClientLightTraceMessage) => void, partial: ClientLightTracePartial): void { + const event = recordClientLightTrace(partial) + if (!event || !armed) return + pendingPost = post + pendingEvents.push(event) + if (pendingEvents.length >= CLIENT_LIGHT_TRACE_BATCH_SIZE) { + flushClientLightTracePosts() + return + } + if (pendingTimer == null) { + pendingTimer = setTimeout(() => { + pendingTimer = null + flushClientLightTracePosts() + }, CLIENT_LIGHT_TRACE_BATCH_MS) + } +} + +export function flushClientLightTracePosts(): void { + if (pendingTimer != null) { + clearTimeout(pendingTimer) + pendingTimer = null + } + if (!pendingPost || pendingEvents.length === 0) { + pendingEvents = [] + pendingPost = null + return + } + const events = pendingEvents + const post = pendingPost + pendingEvents = [] + pendingPost = null + post({ type: CLIENT_LIGHT_TRACE_MESSAGE, events }) +} + +export function isClientLightTraceMessage(data: unknown): data is ClientLightTraceMessage { + if (!data || typeof data !== 'object') return false + const message = data as { type?: unknown; event?: unknown; events?: unknown } + if (message.type !== CLIENT_LIGHT_TRACE_MESSAGE) return false + if (Array.isArray(message.events)) return true + return !!message.event && typeof message.event === 'object' +} + +export function getClientLightTraceEvents(): ClientLightTraceEvent[] { + const out: ClientLightTraceEvent[] = [] + for (let i = 0; i < count; i++) { + const event = ring[(start + i) % CLIENT_LIGHT_TRACE_CAPACITY] + if (event) out.push(event) + } + return out +} + +export function getClientLightTraceAggregates(): { count: number; dropped: number; byPhase: Partial> } { + const byPhase: Partial> = {} + for (const event of getClientLightTraceEvents()) { + byPhase[event.phase] = (byPhase[event.phase] ?? 0) + 1 + } + return { count: count, dropped, byPhase } +} + +export function dumpClientLightTrace(): { + events: ClientLightTraceEvent[] + aggregates: ReturnType +} { + requestClientLightTraceSample() + return { + events: getClientLightTraceEvents(), + aggregates: getClientLightTraceAggregates() + } +} + +function dropPendingPosts(): void { + if (pendingTimer != null) { + clearTimeout(pendingTimer) + pendingTimer = null + } + pendingEvents = [] + pendingPost = null +} + +function resetGpuSampleState(): void { + sampleRequested = false + lastGpuDrawnMetric = undefined + lastGpuUploadedMetric = undefined +} + +function notifyConfig(): void { + configListener?.({ enabled, armed }) +} + +function pushEvent(event: ClientLightTraceEvent): void { + if (count === CLIENT_LIGHT_TRACE_CAPACITY) { + ring[start] = event + start = (start + 1) % CLIENT_LIGHT_TRACE_CAPACITY + dropped++ + return + } + ring[(start + count) % CLIENT_LIGHT_TRACE_CAPACITY] = event + count++ +} + +function attachDebugDump(on: boolean): void { + if (typeof globalThis === 'undefined') return + const host = globalThis as typeof globalThis & { + getClientLightTrace?: typeof getClientLightTraceEvents + getClientLightTraceAggregates?: typeof getClientLightTraceAggregates + clientLightTraceArm?: (on?: boolean) => void + clientLightTraceDump?: typeof dumpClientLightTrace + } + if (on) { + host.getClientLightTrace = getClientLightTraceEvents + host.getClientLightTraceAggregates = getClientLightTraceAggregates + host.clientLightTraceArm = (next = true) => { + armClientLightTrace(next !== false) + } + host.clientLightTraceDump = dumpClientLightTrace + } else { + delete host.getClientLightTrace + delete host.getClientLightTraceAggregates + delete host.clientLightTraceArm + delete host.clientLightTraceDump + } +} diff --git a/src/lib/clientLightVersions.test.ts b/src/lib/clientLightVersions.test.ts new file mode 100644 index 0000000..cd9496a --- /dev/null +++ b/src/lib/clientLightVersions.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, test } from 'vitest' +import { + INITIAL_TOPOLOGY_REVISION, + nextTopologyRevision, + shouldAcceptVersionedMesh, + shouldDispatchCoveringRemesh, + snapshotMeshVersions, + validateMeshResultVersions, + validateOwnerPackedSection, + validateOwnerPublicationMessage +} from './clientLightVersions' + +describe('shouldAcceptVersionedMesh', () => { + const required = { requiredVersion: 3, worldGeneration: 1, topologyRevision: 5 } + + test('legacy bootstrap still accepts unversioned geometry', () => { + expect(shouldAcceptVersionedMesh({}, required, { ownerManaged: false })).toBe(true) + }) + + test('owner-managed path rejects unversioned geometry as current', () => { + expect(shouldAcceptVersionedMesh({}, required, { ownerManaged: true })).toBe(false) + expect(shouldAcceptVersionedMesh({ meshMode: 'legacyBootstrap' }, required, { ownerManaged: true })).toBe(false) + }) + + test('fast A→B→C drops B when C is required', () => { + expect( + shouldAcceptVersionedMesh({ worldGeneration: 1, lightPublicationVersion: 2, topologyRevision: 4, sessionEpoch: 1 }, required, { + ownerManaged: true, + sessionEpoch: 1 + }) + ).toBe(false) + expect( + shouldAcceptVersionedMesh({ worldGeneration: 1, lightPublicationVersion: 3, topologyRevision: 5, sessionEpoch: 1 }, required, { + ownerManaged: true, + sessionEpoch: 1 + }) + ).toBe(true) + }) + + test('late reply from a previous incarnation is dropped', () => { + expect( + shouldAcceptVersionedMesh({ worldGeneration: 1, lightPublicationVersion: 3, topologyRevision: 5, sessionEpoch: 1, columnIncarnation: 1 }, required, { + ownerManaged: true, + sessionEpoch: 1, + columnIncarnation: 2 + }) + ).toBe(false) + }) + + test('hadErrors is never a successful empty commit', () => { + expect( + shouldAcceptVersionedMesh({ hadErrors: true, worldGeneration: 1, lightPublicationVersion: 3, topologyRevision: 5 }, required, { ownerManaged: true }) + ).toBe(false) + expect( + shouldAcceptVersionedMesh({ hadErrors: false, worldGeneration: 1, lightPublicationVersion: 3, topologyRevision: 5 }, required, { + ownerManaged: true + }) + ).toBe(true) + }) + + test('stale topology with fresh light is rejected', () => { + expect( + shouldAcceptVersionedMesh({ worldGeneration: 1, lightPublicationVersion: 3, topologyRevision: 4, sessionEpoch: 1 }, required, { + ownerManaged: true, + sessionEpoch: 1 + }) + ).toBe(false) + }) + + test('fresh topology with stale light is accepted so the block can install this frame', () => { + expect( + shouldAcceptVersionedMesh({ worldGeneration: 1, lightPublicationVersion: 2, topologyRevision: 5, sessionEpoch: 1 }, required, { + ownerManaged: true, + sessionEpoch: 1 + }) + ).toBe(true) + }) + + test('required == null accepts versioned geometry', () => { + expect( + shouldAcceptVersionedMesh({ worldGeneration: 1, lightPublicationVersion: 1, topologyRevision: 1, sessionEpoch: 1 }, null, { + ownerManaged: true, + sessionEpoch: 1 + }) + ).toBe(true) + }) + + test('legacyBootstrap with a required revision is still rejected', () => { + expect(shouldAcceptVersionedMesh({ meshMode: 'legacyBootstrap', topologyRevision: 5 }, required, { ownerManaged: true })).toBe(false) + }) + + test('legacyBootstrap without required is accepted', () => { + expect(shouldAcceptVersionedMesh({ meshMode: 'legacyBootstrap' }, null, { ownerManaged: true })).toBe(true) + }) + + test('legacyBootstrap still has to match the column life', () => { + expect( + shouldAcceptVersionedMesh({ meshMode: 'legacyBootstrap', sessionEpoch: 1, columnIncarnation: 1 }, null, { + ownerManaged: true, + sessionEpoch: 2, + columnIncarnation: 2 + }) + ).toBe(false) + }) + + test('legacyBootstrap with a proven neighbor light version covers the requirement', () => { + expect( + shouldAcceptVersionedMesh( + { meshMode: 'legacyBootstrap', worldGeneration: 1, lightPublicationVersion: 10, sessionEpoch: 1, columnIncarnation: 1 }, + { requiredVersion: 10, worldGeneration: 1 }, + { ownerManaged: true, sessionEpoch: 1, columnIncarnation: 1 } + ) + ).toBe(true) + expect( + shouldAcceptVersionedMesh( + { meshMode: 'legacyBootstrap', worldGeneration: 1, sessionEpoch: 1, columnIncarnation: 1 }, + { requiredVersion: 10, worldGeneration: 1 }, + { ownerManaged: true, sessionEpoch: 1, columnIncarnation: 1 } + ) + ).toBe(false) + }) + + test('owner mesh with an older own-column light version stays rejected', () => { + expect( + shouldAcceptVersionedMesh( + { meshMode: 'owner', worldGeneration: 1, lightPublicationVersion: 9, sessionEpoch: 1, columnIncarnation: 1 }, + { requiredVersion: 10, worldGeneration: 1 }, + { ownerManaged: true, sessionEpoch: 1, columnIncarnation: 1 } + ) + ).toBe(false) + }) + + test('without topology on the requirement, stale light still rejects (old gate)', () => { + expect( + shouldAcceptVersionedMesh( + { worldGeneration: 1, lightPublicationVersion: 2, topologyRevision: 5, sessionEpoch: 1 }, + { requiredVersion: 3, worldGeneration: 1 }, + { ownerManaged: true, sessionEpoch: 1 } + ) + ).toBe(false) + }) +}) + +describe('shouldDispatchCoveringRemesh', () => { + const required = { requiredVersion: 4, worldGeneration: 1 } + + test('dispatches when nothing is outstanding', () => { + expect(shouldDispatchCoveringRemesh({ required, outstandingCount: 0 })).toBe(true) + }) + + test('does not add another remesh when pending already covers the requirement', () => { + expect( + shouldDispatchCoveringRemesh({ + required, + outstandingCount: 1, + pendingCoveringVersion: 4, + pendingCoveringGeneration: 1 + }) + ).toBe(false) + }) + + test('dispatches when the outstanding remesh is behind the new requirement', () => { + expect( + shouldDispatchCoveringRemesh({ + required, + outstandingCount: 2, + pendingCoveringVersion: 3, + pendingCoveringGeneration: 1 + }) + ).toBe(true) + }) +}) + +describe('snapshotMeshVersions', () => { + test('freezes neighbor revisions with the task snapshot, not a later live map', () => { + const neighbors = [{ key: '16,64,0', topologyRevision: 2 }] + const snap = snapshotMeshVersions({ + sessionEpoch: 1, + columnIncarnation: 3, + requestId: 9, + topologyRevision: 4, + lightPublicationVersion: 7, + worldGeneration: 1, + neighborTopologyRevisions: neighbors + }) + neighbors[0]!.topologyRevision = 99 + expect(snap.neighborTopologyRevisions[0]!.topologyRevision).toBe(2) + expect(snap.meshMode).toBe('owner') + }) +}) + +describe('nextTopologyRevision', () => { + test('increments from zero', () => { + expect(nextTopologyRevision(undefined)).toBe(1) + expect(nextTopologyRevision(1)).toBe(2) + }) + + test('the initial revision is not the first edit', () => { + expect(INITIAL_TOPOLOGY_REVISION).toBe(0) + expect(nextTopologyRevision(undefined)).not.toBe(INITIAL_TOPOLOGY_REVISION) + expect(nextTopologyRevision(INITIAL_TOPOLOGY_REVISION)).toBe(1) + }) +}) + +describe('payload validation', () => { + test('rejects NaN and non-integer revision ids', () => { + expect(validateMeshResultVersions({ sessionEpoch: 1.5 })).toEqual({ ok: false, reason: 'sessionEpoch' }) + expect(validateMeshResultVersions({ requestId: -1 })).toEqual({ ok: false, reason: 'requestId' }) + expect(validateMeshResultVersions({ key: '0,64,0', requestId: 3 })).toEqual({ ok: true }) + }) + + test('rejects packed light that is not 2048 bytes per channel', () => { + expect( + validateOwnerPackedSection({ + sx: 0, + sy: 64, + sz: 0, + blockLight: new Uint8Array(16) + }) + ).toEqual({ ok: false, reason: 'blockLight' }) + expect( + validateOwnerPackedSection({ + sx: -16, + sy: 64, + sz: 32, + blockLight: new Uint8Array(2048) + }) + ).toEqual({ ok: true }) + }) + + test('rejects a foreign publication with bad section payload', () => { + expect( + validateOwnerPublicationMessage({ + worldGeneration: 1, + publicationVersion: 2, + sections: [{ sx: 0, sy: 64, sz: 0, blockLight: new Uint8Array(8) }] + }) + ).toEqual({ ok: false, reason: 'blockLight' }) + }) +}) diff --git a/src/lib/clientLightVersions.ts b/src/lib/clientLightVersions.ts new file mode 100644 index 0000000..9acd954 --- /dev/null +++ b/src/lib/clientLightVersions.ts @@ -0,0 +1,202 @@ +/** + * P-02: explicit mesh versions, covering coalesce, and payload validation. + * Metadata only — never copies raw meshes or light arrays into logs. + */ + +export const OWNER_STATES_PER_SECTION = 4096 +export const OWNER_PACKED_LIGHT_BYTES = 2048 + +export type MeshGeometryMode = 'owner' | 'legacyBootstrap' + +export type MeshResultVersions = { + sessionEpoch: number + columnIncarnation: number + requestId: number + topologyRevision: number + lightPublicationVersion: number + worldGeneration: number + neighborTopologyRevisions: Array<{ key: string; topologyRevision: number }> + meshMode: MeshGeometryMode +} + +export type MeshSectionLightRequirement = { + requiredVersion: number + worldGeneration: number + topologyRevision?: number +} + +export function isUnversionedMesh(mesh: { + worldGeneration?: number + lightPublicationVersion?: number + topologyRevision?: number + sessionEpoch?: number +}): boolean { + return mesh.worldGeneration == null && mesh.lightPublicationVersion == null && mesh.topologyRevision == null && mesh.sessionEpoch == null +} + +export function shouldAcceptVersionedMesh( + mesh: { + worldGeneration?: number + lightPublicationVersion?: number + topologyRevision?: number + sessionEpoch?: number + columnIncarnation?: number + hadErrors?: boolean + meshMode?: MeshGeometryMode + }, + required: MeshSectionLightRequirement | null | undefined, + opts: { ownerManaged: boolean; sessionEpoch?: number; columnIncarnation?: number } +): boolean { + if (mesh.hadErrors) return false + if (opts.ownerManaged) { + if (opts.sessionEpoch != null && mesh.sessionEpoch != null && mesh.sessionEpoch !== opts.sessionEpoch) return false + if (opts.columnIncarnation != null && mesh.columnIncarnation != null && mesh.columnIncarnation !== opts.columnIncarnation) { + return false + } + if (mesh.meshMode === 'legacyBootstrap') { + if (required == null) return true + if (mesh.lightPublicationVersion == null) return false + } else if (isUnversionedMesh(mesh)) { + return false + } + } else if (isUnversionedMesh(mesh)) { + return true + } + if (required == null) return true + if (mesh.worldGeneration != null && mesh.worldGeneration !== required.worldGeneration) return false + if (required.topologyRevision != null && mesh.topologyRevision != null && mesh.topologyRevision < required.topologyRevision) { + return false + } + const topologyFresh = required.topologyRevision != null && mesh.topologyRevision != null && mesh.topologyRevision >= required.topologyRevision + if (!topologyFresh && mesh.lightPublicationVersion != null && mesh.lightPublicationVersion < required.requiredVersion) { + return false + } + return true +} + +export function isOwnerGeometryLightStale(mesh: { lightPublicationVersion?: number }, required: MeshSectionLightRequirement | null | undefined): boolean { + return required != null && mesh.lightPublicationVersion != null && mesh.lightPublicationVersion < required.requiredVersion +} + +export function shouldDispatchCoveringRemesh(opts: { + required: MeshSectionLightRequirement + outstandingCount: number + pendingCoveringVersion?: number + pendingCoveringGeneration?: number +}): boolean { + if (opts.outstandingCount <= 0) return true + if (opts.pendingCoveringGeneration !== opts.required.worldGeneration) return true + if ((opts.pendingCoveringVersion ?? -1) < opts.required.requiredVersion) return true + return false +} + +/** Stamped on a section that has not been edited. The first edit is always greater. */ +export const INITIAL_TOPOLOGY_REVISION = 0 + +export function nextTopologyRevision(current: number | undefined): number { + return (current ?? INITIAL_TOPOLOGY_REVISION) + 1 +} + +export function snapshotMeshVersions(input: { + sessionEpoch: number + columnIncarnation: number + requestId: number + topologyRevision: number + lightPublicationVersion: number + worldGeneration: number + neighborTopologyRevisions: Array<{ key: string; topologyRevision: number }> + meshMode?: MeshGeometryMode +}): MeshResultVersions { + return { + sessionEpoch: input.sessionEpoch, + columnIncarnation: input.columnIncarnation, + requestId: input.requestId, + topologyRevision: input.topologyRevision, + lightPublicationVersion: input.lightPublicationVersion, + worldGeneration: input.worldGeneration, + neighborTopologyRevisions: input.neighborTopologyRevisions.map(entry => ({ + key: entry.key, + topologyRevision: entry.topologyRevision + })), + meshMode: input.meshMode ?? 'owner' + } +} + +export function isSafeIntegerId(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && Number.isSafeInteger(value) && value >= 0 +} + +export function isSafeCoord(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && Number.isSafeInteger(value) +} + +export function validateSectionKey(key: unknown): boolean { + if (typeof key !== 'string') return false + const parts = key.split(',') + if (parts.length !== 3) return false + return parts.every(part => { + const n = Number(part) + return Number.isInteger(n) && Number.isFinite(n) + }) +} + +export function validateOwnerPackedSection(section: { + sx?: unknown + sy?: unknown + sz?: unknown + blockLight?: unknown + skyLight?: unknown + states?: unknown +}): { ok: true } | { ok: false; reason: string } { + if (!isSafeCoord(section.sx)) return { ok: false, reason: 'sx' } + if (!isSafeCoord(section.sy)) return { ok: false, reason: 'sy' } + if (!isSafeCoord(section.sz)) return { ok: false, reason: 'sz' } + if (section.blockLight != null) { + if (!(section.blockLight instanceof Uint8Array) || section.blockLight.length !== OWNER_PACKED_LIGHT_BYTES) { + return { ok: false, reason: 'blockLight' } + } + } + if (section.skyLight != null) { + if (!(section.skyLight instanceof Uint8Array) || section.skyLight.length !== OWNER_PACKED_LIGHT_BYTES) { + return { ok: false, reason: 'skyLight' } + } + } + if (section.states != null) { + if (!(section.states instanceof Uint16Array) || section.states.length !== OWNER_STATES_PER_SECTION) { + return { ok: false, reason: 'states' } + } + } + return { ok: true } +} + +export function validateMeshResultVersions(data: { + sessionEpoch?: unknown + columnIncarnation?: unknown + requestId?: unknown + topologyRevision?: unknown + lightPublicationVersion?: unknown + worldGeneration?: unknown + key?: unknown +}): { ok: true } | { ok: false; reason: string } { + if (data.key != null && !validateSectionKey(data.key)) return { ok: false, reason: 'key' } + for (const field of ['sessionEpoch', 'columnIncarnation', 'requestId', 'topologyRevision', 'lightPublicationVersion', 'worldGeneration'] as const) { + if (data[field] == null) continue + if (!isSafeIntegerId(data[field])) return { ok: false, reason: field } + } + return { ok: true } +} + +export function validateOwnerPublicationMessage(data: { + worldGeneration?: unknown + publicationVersion?: unknown + sections?: unknown +}): { ok: true } | { ok: false; reason: string } { + if (!isSafeIntegerId(data.worldGeneration)) return { ok: false, reason: 'worldGeneration' } + if (!isSafeIntegerId(data.publicationVersion)) return { ok: false, reason: 'publicationVersion' } + if (!Array.isArray(data.sections)) return { ok: false, reason: 'sections' } + for (const section of data.sections) { + const checked = validateOwnerPackedSection(section ?? {}) + if (!checked.ok) return checked + } + return { ok: true } +} diff --git a/src/lib/lightTables1171.generated.json b/src/lib/lightTables1171.generated.json new file mode 100644 index 0000000..3d8b6c8 --- /dev/null +++ b/src/lib/lightTables1171.generated.json @@ -0,0 +1,10 @@ +{ + "version": "1.17.1", + "vanillaWorldVersion": 2730, + "source": "vanilla-1.17.1-blocks-java", + "vanillaCommit": "72447031d70fca0cfc9087de2704da83bc12fa79", + "vanillaRegistryComplete": true, + "emissionB64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPDw8PDw8PDw8PDw8PDw8PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAADg4ODg4PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDwoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0ADQANAA0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAkAAAAHAAcABwAHAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACgoKCgoPCwsAAAAADw8PDwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQEBAQEBAQEAAAAADwAAAA8BAQEBAQEBAQABDwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwcHBwcHBwcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAQICAwMEBAUFBgYHBwgICQkKCgsLDAwNDQ4ODw8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAODg4ODg4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGAAkADAAPAAAPDwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANAA0ADQANAA0ADQANAA0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8PDw8KCgoKDw8PDwAAAAAPDw8PAAAAAA8PDw8AAAAADw8PDwAAAAAKCgoKAAAAAAoKCgoAAAAACgoKCgAAAAAKCgoKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKAAMHCw8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwMAAAYGAAAJCQAADAwAAAMDAAAGBgAACQkAAAwMAAADAwAABgYAAAkJAAAMDAAAAwMAAAYGAAAJCQAADAwAAAMDAAAGBgAACQkAAAwMAAADAwAABgYAAAkJAAAMDAAAAwMAAAYGAAAJCQAADAwAAAMDAAAGBgAACQkAAAwMAAADAwAABgYAAAkJAAAMDAAAAwMAAAYGAAAJCQAADAwAAAMDAAAGBgAACQkAAAwMAAADAwAABgYAAAkJAAAMDAAAAwMAAAYGAAAJCQAADAwAAAMDAAAGBgAACQkAAAwMAAADAwAABgYAAAkJAAAMDAAAAwMAAAYGAAAJCQAADAwAAAMDAAAGBgAACQkAAAwMAAADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFBQUFBQUFBQUFBQUEBAQEBAQEBAQEBAQCAgICAgICAgICAgIBAQEBAQEBAQEBAQEAAAAAAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "opacityB64": "AA8PDw8PDw8PDw8PDw8PDw8PDw8PAAAAAAAAAAAAAAAADwEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDwEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEPDwAPDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPDw8PDw8BAAAAAQEBAAAAAAAADw8PDw8PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADw8PDw8PDw8PDw8PDw8PDwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8PDw8PDw8PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPDw8PAAAAAAAAAAAAAAAAAAAAAA8PDw8PDw8PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8PDw8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8BDwAAAAAAAAAAAAAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAAA8PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPDw8PDw8PDw8PAAAAAAAPAAAPDw8PDw8PDwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAPDwAPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8ADw8AAAAAAAAAAAAAAAABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAA8PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEADw8PDw8PDw8PDw8PAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPDwAAAAAAAAAAAAAPDw8PDwEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADw8PAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAA8PAQABAA8PAQABAA8PDw8PDwAAAAAAAAAAAAAAAAAAAAAPDw8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8PDwEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAA8PAQABAA8PAQABAA8PAQABAA8PAQABAA8PAQABAA8PAQABAA8PAQABAA8PAQABAA8PAQABAA8PAQABAA8PAQABAA8PAQABAA8PAQABAA8PAQABAA8PAQABAA8PAQABAA8PAQABAA8PAQABAA8PDw8PDwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBDw8PDwEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEADwAAAAAAAQ8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDwEBAQEPDw8PDw8ADw8PDw8PDw8PDw8PAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBDwAAAAAAAAAAAAAAAA8PDw8PDw8PDw8BAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQ8BAQAAAAAAAAAAAAAAAAAAAAABAQEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEADw8BAAEADw8BAAEADw8BAAEADw8BAAEADw8BAAEADw8BAAEADw8BAAEADw8BAAEADw8BAAEADw8BAAEADw8BAAEADw8BAAEADw8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPDw8PDw8PDw8PDw8PAA8AAA8PDw8PDw8PDw8PDw8ADwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPDwEAAQAPDwEAAQAPDwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPDw8PDw8PDw8PDw8PDw8PAAAAAAAAAAAADw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDwEPDw8PDw8PDw8AAAAADw8BAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAQAPDw8PDw8BAAEADw8BAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8BAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAPDwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADw8PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADw8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPDw8BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADw8PDw8PDw8PDwEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAA8PAQABAA8PAQABAA8PAQABAA8PDw8PDw8PDw8BAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAPDwEAAQAPDwEAAQAPDwEAAQAPDwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8PDw8PAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEADw8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEADw8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEADw8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEADw8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPDw8PDw8PDw8PAAA=", + "occupancyB64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8/P39/v78fHy8j8/f3+/vx8fLy/8/P7+/f34+PT0z8/v79/fj49PT/X1/f339/T08fFfX9/ff39PTx8f+vr7+/7+8vL4+K+vv7/v7y8vj48AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADz8/f3+/vx8fLyPz9/f7+/Hx8vL/z8/v79/fj49PTPz+/v39+Pj09P9fX9/ff39PTx8V9f399/f09PHx/6+vv7/v7y8vj4r6+/v+/vLy+PjwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPPz9/f7+/Hx8vI/P39/v78fHy8v/Pz+/v39+Pj09M/P7+/f34+PT0/19f399/f09PHxX1/f339/T08fH/r6+/v+/vLy+Pivr7+/7+8vL4+P8/P39/v78fHy8j8/f3+/vx8fLy/8/P7+/f34+PT0z8/v79/fj49PT/X1/f339/T08fFfX9/ff39PTx8f+vr7+/7+8vL4+K+vv7/v7y8vj48AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADz8/f3+/vx8fLyPz9/f7+/Hx8vL/z8/v79/fj49PTPz+/v39+Pj09P9fX9/ff39PTx8V9f399/f09PHx/6+vv7/v7y8vj4r6+/v+/vLy+PjwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADz8/f3+/vx8fLyPz9/f7+/Hx8vL/z8/v79/fj49PTPz+/v39+Pj09P9fX9/ff39PTx8V9f399/f09PHx/6+vv7/v7y8vj4r6+/v+/vLy+PjwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8/P39/v78fHy8j8/f3+/vx8fLy/8/P7+/f34+PT0z8/v79/fj49PT/X1/f339/T08fFfX9/ff39PTx8f+vr7+/7+8vL4+K+vv7/v7y8vj4/z8/f3+/vx8fLyPz9/f7+/Hx8vL/z8/v79/fj49PTPz+/v39+Pj09P9fX9/ff39PTx8V9f399/f09PHx/6+vv7/v7y8vj4r6+/v+/vLy+Pj/Pz9/f7+/Hx8vI/P39/v78fHy8v/Pz+/v39+Pj09M/P7+/f34+PT0/19f399/f09PHxX1/f339/T08fH/r6+/v+/vLy+Pivr7+/7+8vL4+PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPPz9/f7+/Hx8vI/P39/v78fHy8v/Pz+/v39+Pj09M/P7+/f34+PT0/19f399/f09PHxX1/f339/T08fH/r6+/v+/vLy+Pivr7+/7+8vL4+PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8/P39/v78fHy8j8/f3+/vx8fLy/8/P7+/f34+PT0z8/v79/fj49PT/X1/f339/T08fFfX9/ff39PTx8f+vr7+/7+8vL4+K+vv7/v7y8vj4/z8/f3+/vx8fLyPz9/f7+/Hx8vL/z8/v79/fj49PTPz+/v39+Pj09P9fX9/ff39PTx8V9f399/f09PHx/6+vv7/v7y8vj4r6+/v+/vLy+PjwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8/P39/v78fHy8j8/f3+/vx8fLy/8/P7+/f34+PT0z8/v79/fj49PT/X1/f339/T08fFfX9/ff39PTx8f+vr7+/7+8vL4+K+vv7/v7y8vj4/z8/f3+/vx8fLyPz9/f7+/Hx8vL/z8/v79/fj49PTPz+/v39+Pj09P9fX9/ff39PTx8V9f399/f09PHx/6+vv7/v7y8vj4r6+/v+/vLy+Pj/Pz9/f7+/Hx8vI/P39/v78fHy8v/Pz+/v39+Pj09M/P7+/f34+PT0/19f399/f09PHxX1/f339/T08fH/r6+/v+/vLy+Pivr7+/7+8vL4+P8PAPDwAA8PAPDwAA8PAPDwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPPz9/f7+/Hx8vI/P39/v78fHy8v/Pz+/v39+Pj09M/P7+/f34+PT0/19f399/f09PHxX1/f339/T08fH/r6+/v+/vLy+Pivr7+/7+8vL4+P8PAPDwAA8PAPDwAA8PAPDwAA8PAPDwAA8PAPDwAA8PAPDwAA8PAPDwAA8PAPDwAA8PAPDwAA8PAPDwAA8PAPDwAA8PAPDwAA8PAPDwAA8PAPDwAA8PAPDwAA8PAPDwAA8PAPDwAA8PAPDwAA8PAPDwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPPz9/f7+/Hx8vI/P39/v78fHy8v/Pz+/v39+Pj09M/P7+/f34+PT0/19f399/f09PHxX1/f339/T08fH/r6+/v+/vLy+Pivr7+/7+8vL4+PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPPz9/f7+/Hx8vI/P39/v78fHy8v/Pz+/v39+Pj09M/P7+/f34+PT0/19f399/f09PHxX1/f339/T08fH/r6+/v+/vLy+Pivr7+/7+8vL4+P8/P39/v78fHy8j8/f3+/vx8fLy/8/P7+/f34+PT0z8/v79/fj49PT/X1/f339/T08fFfX9/ff39PTx8f+vr7+/7+8vL4+K+vv7/v7y8vj4/z8/f3+/vx8fLyPz9/f7+/Hx8vL/z8/v79/fj49PTPz+/v39+Pj09P9fX9/ff39PTx8V9f399/f09PHx/6+vv7/v7y8vj4r6+/v+/vLy+Pj/Pz9/f7+/Hx8vI/P39/v78fHy8v/Pz+/v39+Pj09M/P7+/f34+PT0/19f399/f09PHxX1/f339/T08fH/r6+/v+/vLy+Pivr7+/7+8vL4+P8/P39/v78fHy8j8/f3+/vx8fLy/8/P7+/f34+PT0z8/v79/fj49PT/X1/f339/T08fFfX9/ff39PTx8f+vr7+/7+8vL4+K+vv7/v7y8vj4/z8/f3+/vx8fLyPz9/f7+/Hx8vL/z8/v79/fj49PTPz+/v39+Pj09P9fX9/ff39PTx8V9f399/f09PHx/6+vv7/v7y8vj4r6+/v+/vLy+Pj/Pz9/f7+/Hx8vI/P39/v78fHy8v/Pz+/v39+Pj09M/P7+/f34+PT0/19f399/f09PHxX1/f339/T08fH/r6+/v+/vLy+Pivr7+/7+8vL4+P8/P39/v78fHy8j8/f3+/vx8fLy/8/P7+/f34+PT0z8/v79/fj49PT/X1/f339/T08fFfX9/ff39PTx8f+vr7+/7+8vL4+K+vv7/v7y8vj4/z8/f3+/vx8fLyPz9/f7+/Hx8vL/z8/v79/fj49PTPz+/v39+Pj09P9fX9/ff39PTx8V9f399/f09PHx/6+vv7/v7y8vj4r6+/v+/vLy+Pj/Pz9/f7+/Hx8vI/P39/v78fHy8v/Pz+/v39+Pj09M/P7+/f34+PT0/19f399/f09PHxX1/f339/T08fH/r6+/v+/vLy+Pivr7+/7+8vL4+P8/P39/v78fHy8j8/f3+/vx8fLy/8/P7+/f34+PT0z8/v79/fj49PT/X1/f339/T08fFfX9/ff39PTx8f+vr7+/7+8vL4+K+vv7/v7y8vj4/z8/f3+/vx8fLyPz9/f7+/Hx8vL/z8/v79/fj49PTPz+/v39+Pj09P9fX9/ff39PTx8V9f399/f09PHx/6+vv7/v7y8vj4r6+/v+/vLy+Pj/Pz9/f7+/Hx8vI/P39/v78fHy8v/Pz+/v39+Pj09M/P7+/f34+PT0/19f399/f09PHxX1/f339/T08fH/r6+/v+/vLy+Pivr7+/7+8vL4+P8/P39/v78fHy8j8/f3+/vx8fLy/8/P7+/f34+PT0z8/v79/fj49PT/X1/f339/T08fFfX9/ff39PTx8f+vr7+/7+8vL4+K+vv7/v7y8vj4/w8A8PAADw8A8PAADw8A8PAADw8A8PAADw8A8PAADw8A8PAADw8A8PAADw8A8PAADw8A8PAADw8A8PAADw8A8PAADw8A8PAADw8A8PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPDwDw8AAPDwDw8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8/P39/v78fHy8j8/f3+/vx8fLy/8/P7+/f34+PT0z8/v79/fj49PT/X1/f339/T08fFfX9/ff39PTx8f+vr7+/7+8vL4+K+vv7/v7y8vj4/z8/f3+/vx8fLyPz9/f7+/Hx8vL/z8/v79/fj49PTPz+/v39+Pj09P9fX9/ff39PTx8V9f399/f09PHx/6+vv7/v7y8vj4r6+/v+/vLy+PjwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADz8/f3+/vx8fLyPz9/f7+/Hx8vL/z8/v79/fj49PTPz+/v39+Pj09P9fX9/ff39PTx8V9f399/f09PHx/6+vv7/v7y8vj4r6+/v+/vLy+PjwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPDwDw8AAAAAAADw8A8PAADz8/f3+/vx8fLyPz9/f7+/Hx8vL/z8/v79/fj49PTPz+/v39+Pj09P9fX9/ff39PTx8V9f399/f09PHx/6+vv7/v7y8vj4r6+/v+/vLy+PjwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADz8/f3+/vx8fLyPz9/f7+/Hx8vL/z8/v79/fj49PTPz+/v39+Pj09P9fX9/ff39PTx8V9f399/f09PHx/6+vv7/v7y8vj4r6+/v+/vLy+Pj/DwDw8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPPz9/f7+/Hx8vI/P39/v78fHy8v/Pz+/v39+Pj09M/P7+/f34+PT0/19f399/f09PHxX1/f339/T08fH/r6+/v+/vLy+Pivr7+/7+8vL4+P8/P39/v78fHy8j8/f3+/vx8fLy/8/P7+/f34+PT0z8/v79/fj49PT/X1/f339/T08fFfX9/ff39PTx8f+vr7+/7+8vL4+K+vv7/v7y8vj4/z8/f3+/vx8fLyPz9/f7+/Hx8vL/z8/v79/fj49PTPz+/v39+Pj09P9fX9/ff39PTx8V9f399/f09PHx/6+vv7/v7y8vj4r6+/v+/vLy+Pj/Pz9/f7+/Hx8vI/P39/v78fHy8v/Pz+/v39+Pj09M/P7+/f34+PT0/19f399/f09PHxX1/f339/T08fH/r6+/v+/vLy+Pivr7+/7+8vL4+P8PAPDwAA8PAPDwAA8PAPDwAA8PAPDwAAAAAAAAAAAADz8/f3+/vx8fLyPz9/f7+/Hx8vL/z8/v79/fj49PTPz+/v39+Pj09P9fX9/ff39PTx8V9f399/f09PHx/6+vv7/v7y8vj4r6+/v+/vLy+Pj/Pz9/f7+/Hx8vI/P39/v78fHy8v/Pz+/v39+Pj09M/P7+/f34+PT0/19f399/f09PHxX1/f339/T08fH/r6+/v+/vLy+Pivr7+/7+8vL4+P8/P39/v78fHy8j8/f3+/vx8fLy/8/P7+/f34+PT0z8/v79/fj49PT/X1/f339/T08fFfX9/ff39PTx8f+vr7+/7+8vL4+K+vv7/v7y8vj4/z8/f3+/vx8fLyPz9/f7+/Hx8vL/z8/v79/fj49PTPz+/v39+Pj09P9fX9/ff39PTx8V9f399/f09PHx/6+vv7/v7y8vj4r6+/v+/vLy+Pj/DwDw8AAPDwDw8AAPDwDw8AAPDwDw8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8/P39/v78fHy8j8/f3+/vx8fLy/8/P7+/f34+PT0z8/v79/fj49PT/X1/f339/T08fFfX9/ff39PTx8f+vr7+/7+8vL4+K+vv7/v7y8vj4/w8A8PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8/P39/v78fHy8j8/f3+/vx8fLy/8/P7+/f34+PT0z8/v79/fj49PT/X1/f339/T08fFfX9/ff39PTx8f+vr7+/7+8vL4+K+vv7/v7y8vj4/w8A8PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8/P39/v78fHy8j8/f3+/vx8fLy/8/P7+/f34+PT0z8/v79/fj49PT/X1/f339/T08fFfX9/ff39PTx8f+vr7+/7+8vL4+K+vv7/v7y8vj4/w8A8PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8/P39/v78fHy8j8/f3+/vx8fLy/8/P7+/f34+PT0z8/v79/fj49PT/X1/f339/T08fFfX9/ff39PTx8f+vr7+/7+8vL4+K+vv7/v7y8vj4/w8A8PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" +} diff --git a/src/lib/vanilla-1.17.1/Blocks.java b/src/lib/vanilla-1.17.1/Blocks.java new file mode 100644 index 0000000..ab32d3e --- /dev/null +++ b/src/lib/vanilla-1.17.1/Blocks.java @@ -0,0 +1,3553 @@ +package net.minecraft.world.level.block; + +import java.util.function.ToIntFunction; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.Registry; +import net.minecraft.core.cauldron.CauldronInteraction; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.data.worldgen.Features; +import net.minecraft.util.valueproviders.UniformInt; +import net.minecraft.world.effect.MobEffects; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.item.DyeColor; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.entity.ShulkerBoxBlockEntity; +import net.minecraft.world.level.block.grower.AcaciaTreeGrower; +import net.minecraft.world.level.block.grower.BirchTreeGrower; +import net.minecraft.world.level.block.grower.DarkOakTreeGrower; +import net.minecraft.world.level.block.grower.JungleTreeGrower; +import net.minecraft.world.level.block.grower.OakTreeGrower; +import net.minecraft.world.level.block.grower.SpruceTreeGrower; +import net.minecraft.world.level.block.piston.MovingPistonBlock; +import net.minecraft.world.level.block.piston.PistonBaseBlock; +import net.minecraft.world.level.block.piston.PistonHeadBlock; +import net.minecraft.world.level.block.state.BlockBehaviour; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.BedPart; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.block.state.properties.SculkSensorPhase; +import net.minecraft.world.level.block.state.properties.WoodType; +import net.minecraft.world.level.material.Fluids; +import net.minecraft.world.level.material.Material; +import net.minecraft.world.level.material.MaterialColor; + +public class Blocks { + public static final Block AIR = register("air", new AirBlock(BlockBehaviour.Properties.of(Material.AIR).noCollission().noDrops().air())); + public static final Block STONE = register( + "stone", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.STONE).requiresCorrectToolForDrops().strength(1.5F, 6.0F)) + ); + public static final Block GRANITE = register( + "granite", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.DIRT).requiresCorrectToolForDrops().strength(1.5F, 6.0F)) + ); + public static final Block POLISHED_GRANITE = register( + "polished_granite", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.DIRT).requiresCorrectToolForDrops().strength(1.5F, 6.0F)) + ); + public static final Block DIORITE = register( + "diorite", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.QUARTZ).requiresCorrectToolForDrops().strength(1.5F, 6.0F)) + ); + public static final Block POLISHED_DIORITE = register( + "polished_diorite", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.QUARTZ).requiresCorrectToolForDrops().strength(1.5F, 6.0F)) + ); + public static final Block ANDESITE = register( + "andesite", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.STONE).requiresCorrectToolForDrops().strength(1.5F, 6.0F)) + ); + public static final Block POLISHED_ANDESITE = register( + "polished_andesite", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.STONE).requiresCorrectToolForDrops().strength(1.5F, 6.0F)) + ); + public static final Block GRASS_BLOCK = register( + "grass_block", new GrassBlock(BlockBehaviour.Properties.of(Material.GRASS).randomTicks().strength(0.6F).sound(SoundType.GRASS)) + ); + public static final Block DIRT = register( + "dirt", new Block(BlockBehaviour.Properties.of(Material.DIRT, MaterialColor.DIRT).strength(0.5F).sound(SoundType.GRAVEL)) + ); + public static final Block COARSE_DIRT = register( + "coarse_dirt", new Block(BlockBehaviour.Properties.of(Material.DIRT, MaterialColor.DIRT).strength(0.5F).sound(SoundType.GRAVEL)) + ); + public static final Block PODZOL = register( + "podzol", new SnowyDirtBlock(BlockBehaviour.Properties.of(Material.DIRT, MaterialColor.PODZOL).strength(0.5F).sound(SoundType.GRAVEL)) + ); + public static final Block COBBLESTONE = register( + "cobblestone", new Block(BlockBehaviour.Properties.of(Material.STONE).requiresCorrectToolForDrops().strength(2.0F, 6.0F)) + ); + public static final Block OAK_PLANKS = register( + "oak_planks", new Block(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.WOOD).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block SPRUCE_PLANKS = register( + "spruce_planks", new Block(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.PODZOL).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block BIRCH_PLANKS = register( + "birch_planks", new Block(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.SAND).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block JUNGLE_PLANKS = register( + "jungle_planks", new Block(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.DIRT).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block ACACIA_PLANKS = register( + "acacia_planks", new Block(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.COLOR_ORANGE).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block DARK_OAK_PLANKS = register( + "dark_oak_planks", new Block(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.COLOR_BROWN).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block OAK_SAPLING = register( + "oak_sapling", + new SaplingBlock(new OakTreeGrower(), BlockBehaviour.Properties.of(Material.PLANT).noCollission().randomTicks().instabreak().sound(SoundType.GRASS)) + ); + public static final Block SPRUCE_SAPLING = register( + "spruce_sapling", + new SaplingBlock(new SpruceTreeGrower(), BlockBehaviour.Properties.of(Material.PLANT).noCollission().randomTicks().instabreak().sound(SoundType.GRASS)) + ); + public static final Block BIRCH_SAPLING = register( + "birch_sapling", + new SaplingBlock(new BirchTreeGrower(), BlockBehaviour.Properties.of(Material.PLANT).noCollission().randomTicks().instabreak().sound(SoundType.GRASS)) + ); + public static final Block JUNGLE_SAPLING = register( + "jungle_sapling", + new SaplingBlock(new JungleTreeGrower(), BlockBehaviour.Properties.of(Material.PLANT).noCollission().randomTicks().instabreak().sound(SoundType.GRASS)) + ); + public static final Block ACACIA_SAPLING = register( + "acacia_sapling", + new SaplingBlock(new AcaciaTreeGrower(), BlockBehaviour.Properties.of(Material.PLANT).noCollission().randomTicks().instabreak().sound(SoundType.GRASS)) + ); + public static final Block DARK_OAK_SAPLING = register( + "dark_oak_sapling", + new SaplingBlock(new DarkOakTreeGrower(), BlockBehaviour.Properties.of(Material.PLANT).noCollission().randomTicks().instabreak().sound(SoundType.GRASS)) + ); + public static final Block BEDROCK = register( + "bedrock", new Block(BlockBehaviour.Properties.of(Material.STONE).strength(-1.0F, 3600000.0F).noDrops().isValidSpawn(Blocks::never)) + ); + public static final Block WATER = register( + "water", new LiquidBlock(Fluids.WATER, BlockBehaviour.Properties.of(Material.WATER).noCollission().strength(100.0F).noDrops()) + ); + public static final Block LAVA = register( + "lava", + new LiquidBlock( + Fluids.LAVA, BlockBehaviour.Properties.of(Material.LAVA).noCollission().randomTicks().strength(100.0F).lightLevel(blockStatex -> 15).noDrops() + ) + ); + public static final Block SAND = register( + "sand", new SandBlock(14406560, BlockBehaviour.Properties.of(Material.SAND, MaterialColor.SAND).strength(0.5F).sound(SoundType.SAND)) + ); + public static final Block RED_SAND = register( + "red_sand", new SandBlock(11098145, BlockBehaviour.Properties.of(Material.SAND, MaterialColor.COLOR_ORANGE).strength(0.5F).sound(SoundType.SAND)) + ); + public static final Block GRAVEL = register( + "gravel", new GravelBlock(BlockBehaviour.Properties.of(Material.SAND, MaterialColor.STONE).strength(0.6F).sound(SoundType.GRAVEL)) + ); + public static final Block GOLD_ORE = register( + "gold_ore", new OreBlock(BlockBehaviour.Properties.of(Material.STONE).requiresCorrectToolForDrops().strength(3.0F, 3.0F)) + ); + public static final Block DEEPSLATE_GOLD_ORE = register( + "deepslate_gold_ore", new OreBlock(BlockBehaviour.Properties.copy(GOLD_ORE).color(MaterialColor.DEEPSLATE).strength(4.5F, 3.0F).sound(SoundType.DEEPSLATE)) + ); + public static final Block IRON_ORE = register( + "iron_ore", new OreBlock(BlockBehaviour.Properties.of(Material.STONE).requiresCorrectToolForDrops().strength(3.0F, 3.0F)) + ); + public static final Block DEEPSLATE_IRON_ORE = register( + "deepslate_iron_ore", new OreBlock(BlockBehaviour.Properties.copy(IRON_ORE).color(MaterialColor.DEEPSLATE).strength(4.5F, 3.0F).sound(SoundType.DEEPSLATE)) + ); + public static final Block COAL_ORE = register( + "coal_ore", new OreBlock(BlockBehaviour.Properties.of(Material.STONE).requiresCorrectToolForDrops().strength(3.0F, 3.0F), UniformInt.of(0, 2)) + ); + public static final Block DEEPSLATE_COAL_ORE = register( + "deepslate_coal_ore", + new OreBlock(BlockBehaviour.Properties.copy(COAL_ORE).color(MaterialColor.DEEPSLATE).strength(4.5F, 3.0F).sound(SoundType.DEEPSLATE), UniformInt.of(0, 2)) + ); + public static final Block NETHER_GOLD_ORE = register( + "nether_gold_ore", + new OreBlock( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.NETHER).requiresCorrectToolForDrops().strength(3.0F, 3.0F).sound(SoundType.NETHER_GOLD_ORE), + UniformInt.of(0, 1) + ) + ); + public static final Block OAK_LOG = register("oak_log", log(MaterialColor.WOOD, MaterialColor.PODZOL)); + public static final Block SPRUCE_LOG = register("spruce_log", log(MaterialColor.PODZOL, MaterialColor.COLOR_BROWN)); + public static final Block BIRCH_LOG = register("birch_log", log(MaterialColor.SAND, MaterialColor.QUARTZ)); + public static final Block JUNGLE_LOG = register("jungle_log", log(MaterialColor.DIRT, MaterialColor.PODZOL)); + public static final Block ACACIA_LOG = register("acacia_log", log(MaterialColor.COLOR_ORANGE, MaterialColor.STONE)); + public static final Block DARK_OAK_LOG = register("dark_oak_log", log(MaterialColor.COLOR_BROWN, MaterialColor.COLOR_BROWN)); + public static final Block STRIPPED_SPRUCE_LOG = register("stripped_spruce_log", log(MaterialColor.PODZOL, MaterialColor.PODZOL)); + public static final Block STRIPPED_BIRCH_LOG = register("stripped_birch_log", log(MaterialColor.SAND, MaterialColor.SAND)); + public static final Block STRIPPED_JUNGLE_LOG = register("stripped_jungle_log", log(MaterialColor.DIRT, MaterialColor.DIRT)); + public static final Block STRIPPED_ACACIA_LOG = register("stripped_acacia_log", log(MaterialColor.COLOR_ORANGE, MaterialColor.COLOR_ORANGE)); + public static final Block STRIPPED_DARK_OAK_LOG = register("stripped_dark_oak_log", log(MaterialColor.COLOR_BROWN, MaterialColor.COLOR_BROWN)); + public static final Block STRIPPED_OAK_LOG = register("stripped_oak_log", log(MaterialColor.WOOD, MaterialColor.WOOD)); + public static final Block OAK_WOOD = register( + "oak_wood", new RotatedPillarBlock(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.WOOD).strength(2.0F).sound(SoundType.WOOD)) + ); + public static final Block SPRUCE_WOOD = register( + "spruce_wood", new RotatedPillarBlock(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.PODZOL).strength(2.0F).sound(SoundType.WOOD)) + ); + public static final Block BIRCH_WOOD = register( + "birch_wood", new RotatedPillarBlock(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.SAND).strength(2.0F).sound(SoundType.WOOD)) + ); + public static final Block JUNGLE_WOOD = register( + "jungle_wood", new RotatedPillarBlock(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.DIRT).strength(2.0F).sound(SoundType.WOOD)) + ); + public static final Block ACACIA_WOOD = register( + "acacia_wood", new RotatedPillarBlock(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.COLOR_GRAY).strength(2.0F).sound(SoundType.WOOD)) + ); + public static final Block DARK_OAK_WOOD = register( + "dark_oak_wood", new RotatedPillarBlock(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.COLOR_BROWN).strength(2.0F).sound(SoundType.WOOD)) + ); + public static final Block STRIPPED_OAK_WOOD = register( + "stripped_oak_wood", new RotatedPillarBlock(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.WOOD).strength(2.0F).sound(SoundType.WOOD)) + ); + public static final Block STRIPPED_SPRUCE_WOOD = register( + "stripped_spruce_wood", new RotatedPillarBlock(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.PODZOL).strength(2.0F).sound(SoundType.WOOD)) + ); + public static final Block STRIPPED_BIRCH_WOOD = register( + "stripped_birch_wood", new RotatedPillarBlock(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.SAND).strength(2.0F).sound(SoundType.WOOD)) + ); + public static final Block STRIPPED_JUNGLE_WOOD = register( + "stripped_jungle_wood", new RotatedPillarBlock(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.DIRT).strength(2.0F).sound(SoundType.WOOD)) + ); + public static final Block STRIPPED_ACACIA_WOOD = register( + "stripped_acacia_wood", new RotatedPillarBlock(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.COLOR_ORANGE).strength(2.0F).sound(SoundType.WOOD)) + ); + public static final Block STRIPPED_DARK_OAK_WOOD = register( + "stripped_dark_oak_wood", new RotatedPillarBlock(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.COLOR_BROWN).strength(2.0F).sound(SoundType.WOOD)) + ); + public static final Block OAK_LEAVES = register("oak_leaves", leaves(SoundType.GRASS)); + public static final Block SPRUCE_LEAVES = register("spruce_leaves", leaves(SoundType.GRASS)); + public static final Block BIRCH_LEAVES = register("birch_leaves", leaves(SoundType.GRASS)); + public static final Block JUNGLE_LEAVES = register("jungle_leaves", leaves(SoundType.GRASS)); + public static final Block ACACIA_LEAVES = register("acacia_leaves", leaves(SoundType.GRASS)); + public static final Block DARK_OAK_LEAVES = register("dark_oak_leaves", leaves(SoundType.GRASS)); + public static final Block AZALEA_LEAVES = register("azalea_leaves", leaves(SoundType.AZALEA_LEAVES)); + public static final Block FLOWERING_AZALEA_LEAVES = register("flowering_azalea_leaves", leaves(SoundType.AZALEA_LEAVES)); + public static final Block SPONGE = register("sponge", new SpongeBlock(BlockBehaviour.Properties.of(Material.SPONGE).strength(0.6F).sound(SoundType.GRASS))); + public static final Block WET_SPONGE = register( + "wet_sponge", new WetSpongeBlock(BlockBehaviour.Properties.of(Material.SPONGE).strength(0.6F).sound(SoundType.GRASS)) + ); + public static final Block GLASS = register( + "glass", + new GlassBlock( + BlockBehaviour.Properties.of(Material.GLASS) + .strength(0.3F) + .sound(SoundType.GLASS) + .noOcclusion() + .isValidSpawn(Blocks::never) + .isRedstoneConductor(Blocks::never) + .isSuffocating(Blocks::never) + .isViewBlocking(Blocks::never) + ) + ); + public static final Block LAPIS_ORE = register( + "lapis_ore", new OreBlock(BlockBehaviour.Properties.of(Material.STONE).requiresCorrectToolForDrops().strength(3.0F, 3.0F), UniformInt.of(2, 5)) + ); + public static final Block DEEPSLATE_LAPIS_ORE = register( + "deepslate_lapis_ore", + new OreBlock(BlockBehaviour.Properties.copy(LAPIS_ORE).color(MaterialColor.DEEPSLATE).strength(4.5F, 3.0F).sound(SoundType.DEEPSLATE), UniformInt.of(2, 5)) + ); + public static final Block LAPIS_BLOCK = register( + "lapis_block", new Block(BlockBehaviour.Properties.of(Material.METAL, MaterialColor.LAPIS).requiresCorrectToolForDrops().strength(3.0F, 3.0F)) + ); + public static final Block DISPENSER = register( + "dispenser", new DispenserBlock(BlockBehaviour.Properties.of(Material.STONE).requiresCorrectToolForDrops().strength(3.5F)) + ); + public static final Block SANDSTONE = register( + "sandstone", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.SAND).requiresCorrectToolForDrops().strength(0.8F)) + ); + public static final Block CHISELED_SANDSTONE = register( + "chiseled_sandstone", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.SAND).requiresCorrectToolForDrops().strength(0.8F)) + ); + public static final Block CUT_SANDSTONE = register( + "cut_sandstone", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.SAND).requiresCorrectToolForDrops().strength(0.8F)) + ); + public static final Block NOTE_BLOCK = register("note_block", new NoteBlock(BlockBehaviour.Properties.of(Material.WOOD).sound(SoundType.WOOD).strength(0.8F))); + public static final Block WHITE_BED = register("white_bed", bed(DyeColor.WHITE)); + public static final Block ORANGE_BED = register("orange_bed", bed(DyeColor.ORANGE)); + public static final Block MAGENTA_BED = register("magenta_bed", bed(DyeColor.MAGENTA)); + public static final Block LIGHT_BLUE_BED = register("light_blue_bed", bed(DyeColor.LIGHT_BLUE)); + public static final Block YELLOW_BED = register("yellow_bed", bed(DyeColor.YELLOW)); + public static final Block LIME_BED = register("lime_bed", bed(DyeColor.LIME)); + public static final Block PINK_BED = register("pink_bed", bed(DyeColor.PINK)); + public static final Block GRAY_BED = register("gray_bed", bed(DyeColor.GRAY)); + public static final Block LIGHT_GRAY_BED = register("light_gray_bed", bed(DyeColor.LIGHT_GRAY)); + public static final Block CYAN_BED = register("cyan_bed", bed(DyeColor.CYAN)); + public static final Block PURPLE_BED = register("purple_bed", bed(DyeColor.PURPLE)); + public static final Block BLUE_BED = register("blue_bed", bed(DyeColor.BLUE)); + public static final Block BROWN_BED = register("brown_bed", bed(DyeColor.BROWN)); + public static final Block GREEN_BED = register("green_bed", bed(DyeColor.GREEN)); + public static final Block RED_BED = register("red_bed", bed(DyeColor.RED)); + public static final Block BLACK_BED = register("black_bed", bed(DyeColor.BLACK)); + public static final Block POWERED_RAIL = register( + "powered_rail", new PoweredRailBlock(BlockBehaviour.Properties.of(Material.DECORATION).noCollission().strength(0.7F).sound(SoundType.METAL)) + ); + public static final Block DETECTOR_RAIL = register( + "detector_rail", new DetectorRailBlock(BlockBehaviour.Properties.of(Material.DECORATION).noCollission().strength(0.7F).sound(SoundType.METAL)) + ); + public static final Block STICKY_PISTON = register("sticky_piston", pistonBase(true)); + public static final Block COBWEB = register( + "cobweb", new WebBlock(BlockBehaviour.Properties.of(Material.WEB).noCollission().requiresCorrectToolForDrops().strength(4.0F)) + ); + public static final Block GRASS = register( + "grass", new TallGrassBlock(BlockBehaviour.Properties.of(Material.REPLACEABLE_PLANT).noCollission().instabreak().sound(SoundType.GRASS)) + ); + public static final Block FERN = register( + "fern", new TallGrassBlock(BlockBehaviour.Properties.of(Material.REPLACEABLE_PLANT).noCollission().instabreak().sound(SoundType.GRASS)) + ); + public static final Block DEAD_BUSH = register( + "dead_bush", + new DeadBushBlock(BlockBehaviour.Properties.of(Material.REPLACEABLE_PLANT, MaterialColor.WOOD).noCollission().instabreak().sound(SoundType.GRASS)) + ); + public static final Block SEAGRASS = register( + "seagrass", new SeagrassBlock(BlockBehaviour.Properties.of(Material.REPLACEABLE_WATER_PLANT).noCollission().instabreak().sound(SoundType.WET_GRASS)) + ); + public static final Block TALL_SEAGRASS = register( + "tall_seagrass", new TallSeagrassBlock(BlockBehaviour.Properties.of(Material.REPLACEABLE_WATER_PLANT).noCollission().instabreak().sound(SoundType.WET_GRASS)) + ); + public static final Block PISTON = register("piston", pistonBase(false)); + public static final Block PISTON_HEAD = register("piston_head", new PistonHeadBlock(BlockBehaviour.Properties.of(Material.PISTON).strength(1.5F).noDrops())); + public static final Block WHITE_WOOL = register( + "white_wool", new Block(BlockBehaviour.Properties.of(Material.WOOL, MaterialColor.SNOW).strength(0.8F).sound(SoundType.WOOL)) + ); + public static final Block ORANGE_WOOL = register( + "orange_wool", new Block(BlockBehaviour.Properties.of(Material.WOOL, MaterialColor.COLOR_ORANGE).strength(0.8F).sound(SoundType.WOOL)) + ); + public static final Block MAGENTA_WOOL = register( + "magenta_wool", new Block(BlockBehaviour.Properties.of(Material.WOOL, MaterialColor.COLOR_MAGENTA).strength(0.8F).sound(SoundType.WOOL)) + ); + public static final Block LIGHT_BLUE_WOOL = register( + "light_blue_wool", new Block(BlockBehaviour.Properties.of(Material.WOOL, MaterialColor.COLOR_LIGHT_BLUE).strength(0.8F).sound(SoundType.WOOL)) + ); + public static final Block YELLOW_WOOL = register( + "yellow_wool", new Block(BlockBehaviour.Properties.of(Material.WOOL, MaterialColor.COLOR_YELLOW).strength(0.8F).sound(SoundType.WOOL)) + ); + public static final Block LIME_WOOL = register( + "lime_wool", new Block(BlockBehaviour.Properties.of(Material.WOOL, MaterialColor.COLOR_LIGHT_GREEN).strength(0.8F).sound(SoundType.WOOL)) + ); + public static final Block PINK_WOOL = register( + "pink_wool", new Block(BlockBehaviour.Properties.of(Material.WOOL, MaterialColor.COLOR_PINK).strength(0.8F).sound(SoundType.WOOL)) + ); + public static final Block GRAY_WOOL = register( + "gray_wool", new Block(BlockBehaviour.Properties.of(Material.WOOL, MaterialColor.COLOR_GRAY).strength(0.8F).sound(SoundType.WOOL)) + ); + public static final Block LIGHT_GRAY_WOOL = register( + "light_gray_wool", new Block(BlockBehaviour.Properties.of(Material.WOOL, MaterialColor.COLOR_LIGHT_GRAY).strength(0.8F).sound(SoundType.WOOL)) + ); + public static final Block CYAN_WOOL = register( + "cyan_wool", new Block(BlockBehaviour.Properties.of(Material.WOOL, MaterialColor.COLOR_CYAN).strength(0.8F).sound(SoundType.WOOL)) + ); + public static final Block PURPLE_WOOL = register( + "purple_wool", new Block(BlockBehaviour.Properties.of(Material.WOOL, MaterialColor.COLOR_PURPLE).strength(0.8F).sound(SoundType.WOOL)) + ); + public static final Block BLUE_WOOL = register( + "blue_wool", new Block(BlockBehaviour.Properties.of(Material.WOOL, MaterialColor.COLOR_BLUE).strength(0.8F).sound(SoundType.WOOL)) + ); + public static final Block BROWN_WOOL = register( + "brown_wool", new Block(BlockBehaviour.Properties.of(Material.WOOL, MaterialColor.COLOR_BROWN).strength(0.8F).sound(SoundType.WOOL)) + ); + public static final Block GREEN_WOOL = register( + "green_wool", new Block(BlockBehaviour.Properties.of(Material.WOOL, MaterialColor.COLOR_GREEN).strength(0.8F).sound(SoundType.WOOL)) + ); + public static final Block RED_WOOL = register( + "red_wool", new Block(BlockBehaviour.Properties.of(Material.WOOL, MaterialColor.COLOR_RED).strength(0.8F).sound(SoundType.WOOL)) + ); + public static final Block BLACK_WOOL = register( + "black_wool", new Block(BlockBehaviour.Properties.of(Material.WOOL, MaterialColor.COLOR_BLACK).strength(0.8F).sound(SoundType.WOOL)) + ); + public static final Block MOVING_PISTON = register( + "moving_piston", + new MovingPistonBlock( + BlockBehaviour.Properties.of(Material.PISTON) + .strength(-1.0F) + .dynamicShape() + .noDrops() + .noOcclusion() + .isRedstoneConductor(Blocks::never) + .isSuffocating(Blocks::never) + .isViewBlocking(Blocks::never) + ) + ); + public static final Block DANDELION = register( + "dandelion", new FlowerBlock(MobEffects.SATURATION, 7, BlockBehaviour.Properties.of(Material.PLANT).noCollission().instabreak().sound(SoundType.GRASS)) + ); + public static final Block POPPY = register( + "poppy", new FlowerBlock(MobEffects.NIGHT_VISION, 5, BlockBehaviour.Properties.of(Material.PLANT).noCollission().instabreak().sound(SoundType.GRASS)) + ); + public static final Block BLUE_ORCHID = register( + "blue_orchid", new FlowerBlock(MobEffects.SATURATION, 7, BlockBehaviour.Properties.of(Material.PLANT).noCollission().instabreak().sound(SoundType.GRASS)) + ); + public static final Block ALLIUM = register( + "allium", new FlowerBlock(MobEffects.FIRE_RESISTANCE, 4, BlockBehaviour.Properties.of(Material.PLANT).noCollission().instabreak().sound(SoundType.GRASS)) + ); + public static final Block AZURE_BLUET = register( + "azure_bluet", new FlowerBlock(MobEffects.BLINDNESS, 8, BlockBehaviour.Properties.of(Material.PLANT).noCollission().instabreak().sound(SoundType.GRASS)) + ); + public static final Block RED_TULIP = register( + "red_tulip", new FlowerBlock(MobEffects.WEAKNESS, 9, BlockBehaviour.Properties.of(Material.PLANT).noCollission().instabreak().sound(SoundType.GRASS)) + ); + public static final Block ORANGE_TULIP = register( + "orange_tulip", new FlowerBlock(MobEffects.WEAKNESS, 9, BlockBehaviour.Properties.of(Material.PLANT).noCollission().instabreak().sound(SoundType.GRASS)) + ); + public static final Block WHITE_TULIP = register( + "white_tulip", new FlowerBlock(MobEffects.WEAKNESS, 9, BlockBehaviour.Properties.of(Material.PLANT).noCollission().instabreak().sound(SoundType.GRASS)) + ); + public static final Block PINK_TULIP = register( + "pink_tulip", new FlowerBlock(MobEffects.WEAKNESS, 9, BlockBehaviour.Properties.of(Material.PLANT).noCollission().instabreak().sound(SoundType.GRASS)) + ); + public static final Block OXEYE_DAISY = register( + "oxeye_daisy", new FlowerBlock(MobEffects.REGENERATION, 8, BlockBehaviour.Properties.of(Material.PLANT).noCollission().instabreak().sound(SoundType.GRASS)) + ); + public static final Block CORNFLOWER = register( + "cornflower", new FlowerBlock(MobEffects.JUMP, 6, BlockBehaviour.Properties.of(Material.PLANT).noCollission().instabreak().sound(SoundType.GRASS)) + ); + public static final Block WITHER_ROSE = register( + "wither_rose", new WitherRoseBlock(MobEffects.WITHER, BlockBehaviour.Properties.of(Material.PLANT).noCollission().instabreak().sound(SoundType.GRASS)) + ); + public static final Block LILY_OF_THE_VALLEY = register( + "lily_of_the_valley", new FlowerBlock(MobEffects.POISON, 12, BlockBehaviour.Properties.of(Material.PLANT).noCollission().instabreak().sound(SoundType.GRASS)) + ); + public static final Block BROWN_MUSHROOM = register( + "brown_mushroom", + new MushroomBlock( + BlockBehaviour.Properties.of(Material.PLANT, MaterialColor.COLOR_BROWN) + .noCollission() + .randomTicks() + .instabreak() + .sound(SoundType.GRASS) + .lightLevel(blockStatex -> 1) + .hasPostProcess(Blocks::always), + () -> Features.HUGE_BROWN_MUSHROOM + ) + ); + public static final Block RED_MUSHROOM = register( + "red_mushroom", + new MushroomBlock( + BlockBehaviour.Properties.of(Material.PLANT, MaterialColor.COLOR_RED) + .noCollission() + .randomTicks() + .instabreak() + .sound(SoundType.GRASS) + .hasPostProcess(Blocks::always), + () -> Features.HUGE_RED_MUSHROOM + ) + ); + public static final Block GOLD_BLOCK = register( + "gold_block", + new Block(BlockBehaviour.Properties.of(Material.METAL, MaterialColor.GOLD).requiresCorrectToolForDrops().strength(3.0F, 6.0F).sound(SoundType.METAL)) + ); + public static final Block IRON_BLOCK = register( + "iron_block", + new Block(BlockBehaviour.Properties.of(Material.METAL, MaterialColor.METAL).requiresCorrectToolForDrops().strength(5.0F, 6.0F).sound(SoundType.METAL)) + ); + public static final Block BRICKS = register( + "bricks", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_RED).requiresCorrectToolForDrops().strength(2.0F, 6.0F)) + ); + public static final Block TNT = register("tnt", new TntBlock(BlockBehaviour.Properties.of(Material.EXPLOSIVE).instabreak().sound(SoundType.GRASS))); + public static final Block BOOKSHELF = register("bookshelf", new Block(BlockBehaviour.Properties.of(Material.WOOD).strength(1.5F).sound(SoundType.WOOD))); + public static final Block MOSSY_COBBLESTONE = register( + "mossy_cobblestone", new Block(BlockBehaviour.Properties.of(Material.STONE).requiresCorrectToolForDrops().strength(2.0F, 6.0F)) + ); + public static final Block OBSIDIAN = register( + "obsidian", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_BLACK).requiresCorrectToolForDrops().strength(50.0F, 1200.0F)) + ); + public static final Block TORCH = register( + "torch", + new TorchBlock( + BlockBehaviour.Properties.of(Material.DECORATION).noCollission().instabreak().lightLevel(blockStatex -> 14).sound(SoundType.WOOD), ParticleTypes.FLAME + ) + ); + public static final Block WALL_TORCH = register( + "wall_torch", + new WallTorchBlock( + BlockBehaviour.Properties.of(Material.DECORATION).noCollission().instabreak().lightLevel(blockStatex -> 14).sound(SoundType.WOOD).dropsLike(TORCH), + ParticleTypes.FLAME + ) + ); + public static final Block FIRE = register( + "fire", + new FireBlock(BlockBehaviour.Properties.of(Material.FIRE, MaterialColor.FIRE).noCollission().instabreak().lightLevel(blockStatex -> 15).sound(SoundType.WOOL)) + ); + public static final Block SOUL_FIRE = register( + "soul_fire", + new SoulFireBlock( + BlockBehaviour.Properties.of(Material.FIRE, MaterialColor.COLOR_LIGHT_BLUE).noCollission().instabreak().lightLevel(blockStatex -> 10).sound(SoundType.WOOL) + ) + ); + public static final Block SPAWNER = register( + "spawner", new SpawnerBlock(BlockBehaviour.Properties.of(Material.STONE).requiresCorrectToolForDrops().strength(5.0F).sound(SoundType.METAL).noOcclusion()) + ); + public static final Block OAK_STAIRS = register("oak_stairs", new StairBlock(OAK_PLANKS.defaultBlockState(), BlockBehaviour.Properties.copy(OAK_PLANKS))); + public static final Block CHEST = register( + "chest", new ChestBlock(BlockBehaviour.Properties.of(Material.WOOD).strength(2.5F).sound(SoundType.WOOD), () -> BlockEntityType.CHEST) + ); + public static final Block REDSTONE_WIRE = register( + "redstone_wire", new RedStoneWireBlock(BlockBehaviour.Properties.of(Material.DECORATION).noCollission().instabreak()) + ); + public static final Block DIAMOND_ORE = register( + "diamond_ore", new OreBlock(BlockBehaviour.Properties.of(Material.STONE).requiresCorrectToolForDrops().strength(3.0F, 3.0F), UniformInt.of(3, 7)) + ); + public static final Block DEEPSLATE_DIAMOND_ORE = register( + "deepslate_diamond_ore", + new OreBlock(BlockBehaviour.Properties.copy(DIAMOND_ORE).color(MaterialColor.DEEPSLATE).strength(4.5F, 3.0F).sound(SoundType.DEEPSLATE), UniformInt.of(3, 7)) + ); + public static final Block DIAMOND_BLOCK = register( + "diamond_block", + new Block(BlockBehaviour.Properties.of(Material.METAL, MaterialColor.DIAMOND).requiresCorrectToolForDrops().strength(5.0F, 6.0F).sound(SoundType.METAL)) + ); + public static final Block CRAFTING_TABLE = register( + "crafting_table", new CraftingTableBlock(BlockBehaviour.Properties.of(Material.WOOD).strength(2.5F).sound(SoundType.WOOD)) + ); + public static final Block WHEAT = register( + "wheat", new CropBlock(BlockBehaviour.Properties.of(Material.PLANT).noCollission().randomTicks().instabreak().sound(SoundType.CROP)) + ); + public static final Block FARMLAND = register( + "farmland", + new FarmBlock( + BlockBehaviour.Properties.of(Material.DIRT) + .randomTicks() + .strength(0.6F) + .sound(SoundType.GRAVEL) + .isViewBlocking(Blocks::always) + .isSuffocating(Blocks::always) + ) + ); + public static final Block FURNACE = register( + "furnace", new FurnaceBlock(BlockBehaviour.Properties.of(Material.STONE).requiresCorrectToolForDrops().strength(3.5F).lightLevel(litBlockEmission(13))) + ); + public static final Block OAK_SIGN = register( + "oak_sign", new StandingSignBlock(BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD), WoodType.OAK) + ); + public static final Block SPRUCE_SIGN = register( + "spruce_sign", + new StandingSignBlock( + BlockBehaviour.Properties.of(Material.WOOD, SPRUCE_LOG.defaultMaterialColor()).noCollission().strength(1.0F).sound(SoundType.WOOD), WoodType.SPRUCE + ) + ); + public static final Block BIRCH_SIGN = register( + "birch_sign", + new StandingSignBlock(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.SAND).noCollission().strength(1.0F).sound(SoundType.WOOD), WoodType.BIRCH) + ); + public static final Block ACACIA_SIGN = register( + "acacia_sign", + new StandingSignBlock( + BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.COLOR_ORANGE).noCollission().strength(1.0F).sound(SoundType.WOOD), WoodType.ACACIA + ) + ); + public static final Block JUNGLE_SIGN = register( + "jungle_sign", + new StandingSignBlock( + BlockBehaviour.Properties.of(Material.WOOD, JUNGLE_LOG.defaultMaterialColor()).noCollission().strength(1.0F).sound(SoundType.WOOD), WoodType.JUNGLE + ) + ); + public static final Block DARK_OAK_SIGN = register( + "dark_oak_sign", + new StandingSignBlock( + BlockBehaviour.Properties.of(Material.WOOD, DARK_OAK_LOG.defaultMaterialColor()).noCollission().strength(1.0F).sound(SoundType.WOOD), WoodType.DARK_OAK + ) + ); + public static final Block OAK_DOOR = register( + "oak_door", new DoorBlock(BlockBehaviour.Properties.of(Material.WOOD, OAK_PLANKS.defaultMaterialColor()).strength(3.0F).sound(SoundType.WOOD).noOcclusion()) + ); + public static final Block LADDER = register( + "ladder", new LadderBlock(BlockBehaviour.Properties.of(Material.DECORATION).strength(0.4F).sound(SoundType.LADDER).noOcclusion()) + ); + public static final Block RAIL = register( + "rail", new RailBlock(BlockBehaviour.Properties.of(Material.DECORATION).noCollission().strength(0.7F).sound(SoundType.METAL)) + ); + public static final Block COBBLESTONE_STAIRS = register( + "cobblestone_stairs", new StairBlock(COBBLESTONE.defaultBlockState(), BlockBehaviour.Properties.copy(COBBLESTONE)) + ); + public static final Block OAK_WALL_SIGN = register( + "oak_wall_sign", + new WallSignBlock(BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD).dropsLike(OAK_SIGN), WoodType.OAK) + ); + public static final Block SPRUCE_WALL_SIGN = register( + "spruce_wall_sign", + new WallSignBlock( + BlockBehaviour.Properties.of(Material.WOOD, SPRUCE_LOG.defaultMaterialColor()).noCollission().strength(1.0F).sound(SoundType.WOOD).dropsLike(SPRUCE_SIGN), + WoodType.SPRUCE + ) + ); + public static final Block BIRCH_WALL_SIGN = register( + "birch_wall_sign", + new WallSignBlock( + BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.SAND).noCollission().strength(1.0F).sound(SoundType.WOOD).dropsLike(BIRCH_SIGN), WoodType.BIRCH + ) + ); + public static final Block ACACIA_WALL_SIGN = register( + "acacia_wall_sign", + new WallSignBlock( + BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.COLOR_ORANGE).noCollission().strength(1.0F).sound(SoundType.WOOD).dropsLike(ACACIA_SIGN), + WoodType.ACACIA + ) + ); + public static final Block JUNGLE_WALL_SIGN = register( + "jungle_wall_sign", + new WallSignBlock( + BlockBehaviour.Properties.of(Material.WOOD, JUNGLE_LOG.defaultMaterialColor()).noCollission().strength(1.0F).sound(SoundType.WOOD).dropsLike(JUNGLE_SIGN), + WoodType.JUNGLE + ) + ); + public static final Block DARK_OAK_WALL_SIGN = register( + "dark_oak_wall_sign", + new WallSignBlock( + BlockBehaviour.Properties.of(Material.WOOD, DARK_OAK_LOG.defaultMaterialColor()) + .noCollission() + .strength(1.0F) + .sound(SoundType.WOOD) + .dropsLike(DARK_OAK_SIGN), + WoodType.DARK_OAK + ) + ); + public static final Block LEVER = register( + "lever", new LeverBlock(BlockBehaviour.Properties.of(Material.DECORATION).noCollission().strength(0.5F).sound(SoundType.WOOD)) + ); + public static final Block STONE_PRESSURE_PLATE = register( + "stone_pressure_plate", + new PressurePlateBlock( + PressurePlateBlock.Sensitivity.MOBS, BlockBehaviour.Properties.of(Material.STONE).requiresCorrectToolForDrops().noCollission().strength(0.5F) + ) + ); + public static final Block IRON_DOOR = register( + "iron_door", + new DoorBlock( + BlockBehaviour.Properties.of(Material.METAL, MaterialColor.METAL).requiresCorrectToolForDrops().strength(5.0F).sound(SoundType.METAL).noOcclusion() + ) + ); + public static final Block OAK_PRESSURE_PLATE = register( + "oak_pressure_plate", + new PressurePlateBlock( + PressurePlateBlock.Sensitivity.EVERYTHING, + BlockBehaviour.Properties.of(Material.WOOD, OAK_PLANKS.defaultMaterialColor()).noCollission().strength(0.5F).sound(SoundType.WOOD) + ) + ); + public static final Block SPRUCE_PRESSURE_PLATE = register( + "spruce_pressure_plate", + new PressurePlateBlock( + PressurePlateBlock.Sensitivity.EVERYTHING, + BlockBehaviour.Properties.of(Material.WOOD, SPRUCE_PLANKS.defaultMaterialColor()).noCollission().strength(0.5F).sound(SoundType.WOOD) + ) + ); + public static final Block BIRCH_PRESSURE_PLATE = register( + "birch_pressure_plate", + new PressurePlateBlock( + PressurePlateBlock.Sensitivity.EVERYTHING, + BlockBehaviour.Properties.of(Material.WOOD, BIRCH_PLANKS.defaultMaterialColor()).noCollission().strength(0.5F).sound(SoundType.WOOD) + ) + ); + public static final Block JUNGLE_PRESSURE_PLATE = register( + "jungle_pressure_plate", + new PressurePlateBlock( + PressurePlateBlock.Sensitivity.EVERYTHING, + BlockBehaviour.Properties.of(Material.WOOD, JUNGLE_PLANKS.defaultMaterialColor()).noCollission().strength(0.5F).sound(SoundType.WOOD) + ) + ); + public static final Block ACACIA_PRESSURE_PLATE = register( + "acacia_pressure_plate", + new PressurePlateBlock( + PressurePlateBlock.Sensitivity.EVERYTHING, + BlockBehaviour.Properties.of(Material.WOOD, ACACIA_PLANKS.defaultMaterialColor()).noCollission().strength(0.5F).sound(SoundType.WOOD) + ) + ); + public static final Block DARK_OAK_PRESSURE_PLATE = register( + "dark_oak_pressure_plate", + new PressurePlateBlock( + PressurePlateBlock.Sensitivity.EVERYTHING, + BlockBehaviour.Properties.of(Material.WOOD, DARK_OAK_PLANKS.defaultMaterialColor()).noCollission().strength(0.5F).sound(SoundType.WOOD) + ) + ); + public static final Block REDSTONE_ORE = register( + "redstone_ore", + new RedStoneOreBlock( + BlockBehaviour.Properties.of(Material.STONE).requiresCorrectToolForDrops().randomTicks().lightLevel(litBlockEmission(9)).strength(3.0F, 3.0F) + ) + ); + public static final Block DEEPSLATE_REDSTONE_ORE = register( + "deepslate_redstone_ore", + new RedStoneOreBlock(BlockBehaviour.Properties.copy(REDSTONE_ORE).color(MaterialColor.DEEPSLATE).strength(4.5F, 3.0F).sound(SoundType.DEEPSLATE)) + ); + public static final Block REDSTONE_TORCH = register( + "redstone_torch", + new RedstoneTorchBlock(BlockBehaviour.Properties.of(Material.DECORATION).noCollission().instabreak().lightLevel(litBlockEmission(7)).sound(SoundType.WOOD)) + ); + public static final Block REDSTONE_WALL_TORCH = register( + "redstone_wall_torch", + new RedstoneWallTorchBlock( + BlockBehaviour.Properties.of(Material.DECORATION) + .noCollission() + .instabreak() + .lightLevel(litBlockEmission(7)) + .sound(SoundType.WOOD) + .dropsLike(REDSTONE_TORCH) + ) + ); + public static final Block STONE_BUTTON = register( + "stone_button", new StoneButtonBlock(BlockBehaviour.Properties.of(Material.DECORATION).noCollission().strength(0.5F)) + ); + public static final Block SNOW = register( + "snow", new SnowLayerBlock(BlockBehaviour.Properties.of(Material.TOP_SNOW).randomTicks().strength(0.1F).requiresCorrectToolForDrops().sound(SoundType.SNOW)) + ); + public static final Block ICE = register( + "ice", + new IceBlock( + BlockBehaviour.Properties.of(Material.ICE) + .friction(0.98F) + .randomTicks() + .strength(0.5F) + .sound(SoundType.GLASS) + .noOcclusion() + .isValidSpawn((blockStatex, blockGetter, blockPos, entityType) -> entityType == EntityType.POLAR_BEAR) + ) + ); + public static final Block SNOW_BLOCK = register( + "snow_block", new Block(BlockBehaviour.Properties.of(Material.SNOW).requiresCorrectToolForDrops().strength(0.2F).sound(SoundType.SNOW)) + ); + public static final Block CACTUS = register( + "cactus", new CactusBlock(BlockBehaviour.Properties.of(Material.CACTUS).randomTicks().strength(0.4F).sound(SoundType.WOOL)) + ); + public static final Block CLAY = register("clay", new Block(BlockBehaviour.Properties.of(Material.CLAY).strength(0.6F).sound(SoundType.GRAVEL))); + public static final Block SUGAR_CANE = register( + "sugar_cane", new SugarCaneBlock(BlockBehaviour.Properties.of(Material.PLANT).noCollission().randomTicks().instabreak().sound(SoundType.GRASS)) + ); + public static final Block JUKEBOX = register("jukebox", new JukeboxBlock(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.DIRT).strength(2.0F, 6.0F))); + public static final Block OAK_FENCE = register( + "oak_fence", new FenceBlock(BlockBehaviour.Properties.of(Material.WOOD, OAK_PLANKS.defaultMaterialColor()).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block PUMPKIN = register( + "pumpkin", new PumpkinBlock(BlockBehaviour.Properties.of(Material.VEGETABLE, MaterialColor.COLOR_ORANGE).strength(1.0F).sound(SoundType.WOOD)) + ); + public static final Block NETHERRACK = register( + "netherrack", + new NetherrackBlock( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.NETHER).requiresCorrectToolForDrops().strength(0.4F).sound(SoundType.NETHERRACK) + ) + ); + public static final Block SOUL_SAND = register( + "soul_sand", + new SoulSandBlock( + BlockBehaviour.Properties.of(Material.SAND, MaterialColor.COLOR_BROWN) + .strength(0.5F) + .speedFactor(0.4F) + .sound(SoundType.SOUL_SAND) + .isValidSpawn(Blocks::always) + .isRedstoneConductor(Blocks::always) + .isViewBlocking(Blocks::always) + .isSuffocating(Blocks::always) + ) + ); + public static final Block SOUL_SOIL = register( + "soul_soil", new Block(BlockBehaviour.Properties.of(Material.DIRT, MaterialColor.COLOR_BROWN).strength(0.5F).sound(SoundType.SOUL_SOIL)) + ); + public static final Block BASALT = register( + "basalt", + new RotatedPillarBlock( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_BLACK).requiresCorrectToolForDrops().strength(1.25F, 4.2F).sound(SoundType.BASALT) + ) + ); + public static final Block POLISHED_BASALT = register( + "polished_basalt", + new RotatedPillarBlock( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_BLACK).requiresCorrectToolForDrops().strength(1.25F, 4.2F).sound(SoundType.BASALT) + ) + ); + public static final Block SOUL_TORCH = register( + "soul_torch", + new TorchBlock( + BlockBehaviour.Properties.of(Material.DECORATION).noCollission().instabreak().lightLevel(blockStatex -> 10).sound(SoundType.WOOD), + ParticleTypes.SOUL_FIRE_FLAME + ) + ); + public static final Block SOUL_WALL_TORCH = register( + "soul_wall_torch", + new WallTorchBlock( + BlockBehaviour.Properties.of(Material.DECORATION).noCollission().instabreak().lightLevel(blockStatex -> 10).sound(SoundType.WOOD).dropsLike(SOUL_TORCH), + ParticleTypes.SOUL_FIRE_FLAME + ) + ); + public static final Block GLOWSTONE = register( + "glowstone", new Block(BlockBehaviour.Properties.of(Material.GLASS, MaterialColor.SAND).strength(0.3F).sound(SoundType.GLASS).lightLevel(blockStatex -> 15)) + ); + public static final Block NETHER_PORTAL = register( + "nether_portal", + new NetherPortalBlock( + BlockBehaviour.Properties.of(Material.PORTAL).noCollission().randomTicks().strength(-1.0F).sound(SoundType.GLASS).lightLevel(blockStatex -> 11) + ) + ); + public static final Block CARVED_PUMPKIN = register( + "carved_pumpkin", + new CarvedPumpkinBlock( + BlockBehaviour.Properties.of(Material.VEGETABLE, MaterialColor.COLOR_ORANGE).strength(1.0F).sound(SoundType.WOOD).isValidSpawn(Blocks::always) + ) + ); + public static final Block JACK_O_LANTERN = register( + "jack_o_lantern", + new CarvedPumpkinBlock( + BlockBehaviour.Properties.of(Material.VEGETABLE, MaterialColor.COLOR_ORANGE) + .strength(1.0F) + .sound(SoundType.WOOD) + .lightLevel(blockStatex -> 15) + .isValidSpawn(Blocks::always) + ) + ); + public static final Block CAKE = register("cake", new CakeBlock(BlockBehaviour.Properties.of(Material.CAKE).strength(0.5F).sound(SoundType.WOOL))); + public static final Block REPEATER = register( + "repeater", new RepeaterBlock(BlockBehaviour.Properties.of(Material.DECORATION).instabreak().sound(SoundType.WOOD)) + ); + public static final Block WHITE_STAINED_GLASS = register("white_stained_glass", stainedGlass(DyeColor.WHITE)); + public static final Block ORANGE_STAINED_GLASS = register("orange_stained_glass", stainedGlass(DyeColor.ORANGE)); + public static final Block MAGENTA_STAINED_GLASS = register("magenta_stained_glass", stainedGlass(DyeColor.MAGENTA)); + public static final Block LIGHT_BLUE_STAINED_GLASS = register("light_blue_stained_glass", stainedGlass(DyeColor.LIGHT_BLUE)); + public static final Block YELLOW_STAINED_GLASS = register("yellow_stained_glass", stainedGlass(DyeColor.YELLOW)); + public static final Block LIME_STAINED_GLASS = register("lime_stained_glass", stainedGlass(DyeColor.LIME)); + public static final Block PINK_STAINED_GLASS = register("pink_stained_glass", stainedGlass(DyeColor.PINK)); + public static final Block GRAY_STAINED_GLASS = register("gray_stained_glass", stainedGlass(DyeColor.GRAY)); + public static final Block LIGHT_GRAY_STAINED_GLASS = register("light_gray_stained_glass", stainedGlass(DyeColor.LIGHT_GRAY)); + public static final Block CYAN_STAINED_GLASS = register("cyan_stained_glass", stainedGlass(DyeColor.CYAN)); + public static final Block PURPLE_STAINED_GLASS = register("purple_stained_glass", stainedGlass(DyeColor.PURPLE)); + public static final Block BLUE_STAINED_GLASS = register("blue_stained_glass", stainedGlass(DyeColor.BLUE)); + public static final Block BROWN_STAINED_GLASS = register("brown_stained_glass", stainedGlass(DyeColor.BROWN)); + public static final Block GREEN_STAINED_GLASS = register("green_stained_glass", stainedGlass(DyeColor.GREEN)); + public static final Block RED_STAINED_GLASS = register("red_stained_glass", stainedGlass(DyeColor.RED)); + public static final Block BLACK_STAINED_GLASS = register("black_stained_glass", stainedGlass(DyeColor.BLACK)); + public static final Block OAK_TRAPDOOR = register( + "oak_trapdoor", + new TrapDoorBlock( + BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.WOOD).strength(3.0F).sound(SoundType.WOOD).noOcclusion().isValidSpawn(Blocks::never) + ) + ); + public static final Block SPRUCE_TRAPDOOR = register( + "spruce_trapdoor", + new TrapDoorBlock( + BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.PODZOL).strength(3.0F).sound(SoundType.WOOD).noOcclusion().isValidSpawn(Blocks::never) + ) + ); + public static final Block BIRCH_TRAPDOOR = register( + "birch_trapdoor", + new TrapDoorBlock( + BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.SAND).strength(3.0F).sound(SoundType.WOOD).noOcclusion().isValidSpawn(Blocks::never) + ) + ); + public static final Block JUNGLE_TRAPDOOR = register( + "jungle_trapdoor", + new TrapDoorBlock( + BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.DIRT).strength(3.0F).sound(SoundType.WOOD).noOcclusion().isValidSpawn(Blocks::never) + ) + ); + public static final Block ACACIA_TRAPDOOR = register( + "acacia_trapdoor", + new TrapDoorBlock( + BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.COLOR_ORANGE).strength(3.0F).sound(SoundType.WOOD).noOcclusion().isValidSpawn(Blocks::never) + ) + ); + public static final Block DARK_OAK_TRAPDOOR = register( + "dark_oak_trapdoor", + new TrapDoorBlock( + BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.COLOR_BROWN).strength(3.0F).sound(SoundType.WOOD).noOcclusion().isValidSpawn(Blocks::never) + ) + ); + public static final Block STONE_BRICKS = register( + "stone_bricks", new Block(BlockBehaviour.Properties.of(Material.STONE).requiresCorrectToolForDrops().strength(1.5F, 6.0F)) + ); + public static final Block MOSSY_STONE_BRICKS = register( + "mossy_stone_bricks", new Block(BlockBehaviour.Properties.of(Material.STONE).requiresCorrectToolForDrops().strength(1.5F, 6.0F)) + ); + public static final Block CRACKED_STONE_BRICKS = register( + "cracked_stone_bricks", new Block(BlockBehaviour.Properties.of(Material.STONE).requiresCorrectToolForDrops().strength(1.5F, 6.0F)) + ); + public static final Block CHISELED_STONE_BRICKS = register( + "chiseled_stone_bricks", new Block(BlockBehaviour.Properties.of(Material.STONE).requiresCorrectToolForDrops().strength(1.5F, 6.0F)) + ); + public static final Block INFESTED_STONE = register("infested_stone", new InfestedBlock(STONE, BlockBehaviour.Properties.of(Material.CLAY))); + public static final Block INFESTED_COBBLESTONE = register("infested_cobblestone", new InfestedBlock(COBBLESTONE, BlockBehaviour.Properties.of(Material.CLAY))); + public static final Block INFESTED_STONE_BRICKS = register( + "infested_stone_bricks", new InfestedBlock(STONE_BRICKS, BlockBehaviour.Properties.of(Material.CLAY)) + ); + public static final Block INFESTED_MOSSY_STONE_BRICKS = register( + "infested_mossy_stone_bricks", new InfestedBlock(MOSSY_STONE_BRICKS, BlockBehaviour.Properties.of(Material.CLAY)) + ); + public static final Block INFESTED_CRACKED_STONE_BRICKS = register( + "infested_cracked_stone_bricks", new InfestedBlock(CRACKED_STONE_BRICKS, BlockBehaviour.Properties.of(Material.CLAY)) + ); + public static final Block INFESTED_CHISELED_STONE_BRICKS = register( + "infested_chiseled_stone_bricks", new InfestedBlock(CHISELED_STONE_BRICKS, BlockBehaviour.Properties.of(Material.CLAY)) + ); + public static final Block BROWN_MUSHROOM_BLOCK = register( + "brown_mushroom_block", new HugeMushroomBlock(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.DIRT).strength(0.2F).sound(SoundType.WOOD)) + ); + public static final Block RED_MUSHROOM_BLOCK = register( + "red_mushroom_block", new HugeMushroomBlock(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.COLOR_RED).strength(0.2F).sound(SoundType.WOOD)) + ); + public static final Block MUSHROOM_STEM = register( + "mushroom_stem", new HugeMushroomBlock(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.WOOL).strength(0.2F).sound(SoundType.WOOD)) + ); + public static final Block IRON_BARS = register( + "iron_bars", + new IronBarsBlock( + BlockBehaviour.Properties.of(Material.METAL, MaterialColor.NONE).requiresCorrectToolForDrops().strength(5.0F, 6.0F).sound(SoundType.METAL).noOcclusion() + ) + ); + public static final Block CHAIN = register( + "chain", + new ChainBlock( + BlockBehaviour.Properties.of(Material.METAL, MaterialColor.NONE).requiresCorrectToolForDrops().strength(5.0F, 6.0F).sound(SoundType.CHAIN).noOcclusion() + ) + ); + public static final Block GLASS_PANE = register( + "glass_pane", new IronBarsBlock(BlockBehaviour.Properties.of(Material.GLASS).strength(0.3F).sound(SoundType.GLASS).noOcclusion()) + ); + public static final Block MELON = register( + "melon", new MelonBlock(BlockBehaviour.Properties.of(Material.VEGETABLE, MaterialColor.COLOR_LIGHT_GREEN).strength(1.0F).sound(SoundType.WOOD)) + ); + public static final Block ATTACHED_PUMPKIN_STEM = register( + "attached_pumpkin_stem", + new AttachedStemBlock( + (StemGrownBlock)PUMPKIN, () -> Items.PUMPKIN_SEEDS, BlockBehaviour.Properties.of(Material.PLANT).noCollission().instabreak().sound(SoundType.WOOD) + ) + ); + public static final Block ATTACHED_MELON_STEM = register( + "attached_melon_stem", + new AttachedStemBlock( + (StemGrownBlock)MELON, () -> Items.MELON_SEEDS, BlockBehaviour.Properties.of(Material.PLANT).noCollission().instabreak().sound(SoundType.WOOD) + ) + ); + public static final Block PUMPKIN_STEM = register( + "pumpkin_stem", + new StemBlock( + (StemGrownBlock)PUMPKIN, + () -> Items.PUMPKIN_SEEDS, + BlockBehaviour.Properties.of(Material.PLANT).noCollission().randomTicks().instabreak().sound(SoundType.HARD_CROP) + ) + ); + public static final Block MELON_STEM = register( + "melon_stem", + new StemBlock( + (StemGrownBlock)MELON, + () -> Items.MELON_SEEDS, + BlockBehaviour.Properties.of(Material.PLANT).noCollission().randomTicks().instabreak().sound(SoundType.HARD_CROP) + ) + ); + public static final Block VINE = register( + "vine", new VineBlock(BlockBehaviour.Properties.of(Material.REPLACEABLE_PLANT).noCollission().randomTicks().strength(0.2F).sound(SoundType.VINE)) + ); + public static final Block GLOW_LICHEN = register( + "glow_lichen", + new GlowLichenBlock( + BlockBehaviour.Properties.of(Material.REPLACEABLE_PLANT, MaterialColor.GLOW_LICHEN) + .noCollission() + .strength(0.2F) + .sound(SoundType.GLOW_LICHEN) + .lightLevel(GlowLichenBlock.emission(7)) + ) + ); + public static final Block OAK_FENCE_GATE = register( + "oak_fence_gate", + new FenceGateBlock(BlockBehaviour.Properties.of(Material.WOOD, OAK_PLANKS.defaultMaterialColor()).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block BRICK_STAIRS = register("brick_stairs", new StairBlock(BRICKS.defaultBlockState(), BlockBehaviour.Properties.copy(BRICKS))); + public static final Block STONE_BRICK_STAIRS = register( + "stone_brick_stairs", new StairBlock(STONE_BRICKS.defaultBlockState(), BlockBehaviour.Properties.copy(STONE_BRICKS)) + ); + public static final Block MYCELIUM = register( + "mycelium", new MyceliumBlock(BlockBehaviour.Properties.of(Material.GRASS, MaterialColor.COLOR_PURPLE).randomTicks().strength(0.6F).sound(SoundType.GRASS)) + ); + public static final Block LILY_PAD = register( + "lily_pad", new WaterlilyBlock(BlockBehaviour.Properties.of(Material.PLANT).instabreak().sound(SoundType.LILY_PAD).noOcclusion()) + ); + public static final Block NETHER_BRICKS = register( + "nether_bricks", + new Block( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.NETHER).requiresCorrectToolForDrops().strength(2.0F, 6.0F).sound(SoundType.NETHER_BRICKS) + ) + ); + public static final Block NETHER_BRICK_FENCE = register( + "nether_brick_fence", + new FenceBlock( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.NETHER).requiresCorrectToolForDrops().strength(2.0F, 6.0F).sound(SoundType.NETHER_BRICKS) + ) + ); + public static final Block NETHER_BRICK_STAIRS = register( + "nether_brick_stairs", new StairBlock(NETHER_BRICKS.defaultBlockState(), BlockBehaviour.Properties.copy(NETHER_BRICKS)) + ); + public static final Block NETHER_WART = register( + "nether_wart", + new NetherWartBlock(BlockBehaviour.Properties.of(Material.PLANT, MaterialColor.COLOR_RED).noCollission().randomTicks().sound(SoundType.NETHER_WART)) + ); + public static final Block ENCHANTING_TABLE = register( + "enchanting_table", + new EnchantmentTableBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_RED).requiresCorrectToolForDrops().strength(5.0F, 1200.0F)) + ); + public static final Block BREWING_STAND = register( + "brewing_stand", + new BrewingStandBlock(BlockBehaviour.Properties.of(Material.METAL).requiresCorrectToolForDrops().strength(0.5F).lightLevel(blockStatex -> 1).noOcclusion()) + ); + public static final Block CAULDRON = register( + "cauldron", new CauldronBlock(BlockBehaviour.Properties.of(Material.METAL, MaterialColor.STONE).requiresCorrectToolForDrops().strength(2.0F).noOcclusion()) + ); + public static final Block WATER_CAULDRON = register( + "water_cauldron", new LayeredCauldronBlock(BlockBehaviour.Properties.copy(CAULDRON), LayeredCauldronBlock.RAIN, CauldronInteraction.WATER) + ); + public static final Block LAVA_CAULDRON = register( + "lava_cauldron", new LavaCauldronBlock(BlockBehaviour.Properties.copy(CAULDRON).lightLevel(blockStatex -> 15)) + ); + public static final Block POWDER_SNOW_CAULDRON = register( + "powder_snow_cauldron", new PowderSnowCauldronBlock(BlockBehaviour.Properties.copy(CAULDRON), LayeredCauldronBlock.SNOW, CauldronInteraction.POWDER_SNOW) + ); + public static final Block END_PORTAL = register( + "end_portal", + new EndPortalBlock( + BlockBehaviour.Properties.of(Material.PORTAL, MaterialColor.COLOR_BLACK).noCollission().lightLevel(blockStatex -> 15).strength(-1.0F, 3600000.0F).noDrops() + ) + ); + public static final Block END_PORTAL_FRAME = register( + "end_portal_frame", + new EndPortalFrameBlock( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_GREEN) + .sound(SoundType.GLASS) + .lightLevel(blockStatex -> 1) + .strength(-1.0F, 3600000.0F) + .noDrops() + ) + ); + public static final Block END_STONE = register( + "end_stone", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.SAND).requiresCorrectToolForDrops().strength(3.0F, 9.0F)) + ); + public static final Block DRAGON_EGG = register( + "dragon_egg", + new DragonEggBlock(BlockBehaviour.Properties.of(Material.EGG, MaterialColor.COLOR_BLACK).strength(3.0F, 9.0F).lightLevel(blockStatex -> 1).noOcclusion()) + ); + public static final Block REDSTONE_LAMP = register( + "redstone_lamp", + new RedstoneLampBlock( + BlockBehaviour.Properties.of(Material.BUILDABLE_GLASS).lightLevel(litBlockEmission(15)).strength(0.3F).sound(SoundType.GLASS).isValidSpawn(Blocks::always) + ) + ); + public static final Block COCOA = register( + "cocoa", new CocoaBlock(BlockBehaviour.Properties.of(Material.PLANT).randomTicks().strength(0.2F, 3.0F).sound(SoundType.WOOD).noOcclusion()) + ); + public static final Block SANDSTONE_STAIRS = register( + "sandstone_stairs", new StairBlock(SANDSTONE.defaultBlockState(), BlockBehaviour.Properties.copy(SANDSTONE)) + ); + public static final Block EMERALD_ORE = register( + "emerald_ore", new OreBlock(BlockBehaviour.Properties.of(Material.STONE).requiresCorrectToolForDrops().strength(3.0F, 3.0F), UniformInt.of(3, 7)) + ); + public static final Block DEEPSLATE_EMERALD_ORE = register( + "deepslate_emerald_ore", + new OreBlock(BlockBehaviour.Properties.copy(EMERALD_ORE).color(MaterialColor.DEEPSLATE).strength(4.5F, 3.0F).sound(SoundType.DEEPSLATE), UniformInt.of(3, 7)) + ); + public static final Block ENDER_CHEST = register( + "ender_chest", + new EnderChestBlock(BlockBehaviour.Properties.of(Material.STONE).requiresCorrectToolForDrops().strength(22.5F, 600.0F).lightLevel(blockStatex -> 7)) + ); + public static final Block TRIPWIRE_HOOK = register("tripwire_hook", new TripWireHookBlock(BlockBehaviour.Properties.of(Material.DECORATION).noCollission())); + public static final Block TRIPWIRE = register( + "tripwire", new TripWireBlock((TripWireHookBlock)TRIPWIRE_HOOK, BlockBehaviour.Properties.of(Material.DECORATION).noCollission()) + ); + public static final Block EMERALD_BLOCK = register( + "emerald_block", + new Block(BlockBehaviour.Properties.of(Material.METAL, MaterialColor.EMERALD).requiresCorrectToolForDrops().strength(5.0F, 6.0F).sound(SoundType.METAL)) + ); + public static final Block SPRUCE_STAIRS = register( + "spruce_stairs", new StairBlock(SPRUCE_PLANKS.defaultBlockState(), BlockBehaviour.Properties.copy(SPRUCE_PLANKS)) + ); + public static final Block BIRCH_STAIRS = register( + "birch_stairs", new StairBlock(BIRCH_PLANKS.defaultBlockState(), BlockBehaviour.Properties.copy(BIRCH_PLANKS)) + ); + public static final Block JUNGLE_STAIRS = register( + "jungle_stairs", new StairBlock(JUNGLE_PLANKS.defaultBlockState(), BlockBehaviour.Properties.copy(JUNGLE_PLANKS)) + ); + public static final Block COMMAND_BLOCK = register( + "command_block", + new CommandBlock( + BlockBehaviour.Properties.of(Material.METAL, MaterialColor.COLOR_BROWN).requiresCorrectToolForDrops().strength(-1.0F, 3600000.0F).noDrops(), false + ) + ); + public static final Block BEACON = register( + "beacon", + new BeaconBlock( + BlockBehaviour.Properties.of(Material.GLASS, MaterialColor.DIAMOND) + .strength(3.0F) + .lightLevel(blockStatex -> 15) + .noOcclusion() + .isRedstoneConductor(Blocks::never) + ) + ); + public static final Block COBBLESTONE_WALL = register("cobblestone_wall", new WallBlock(BlockBehaviour.Properties.copy(COBBLESTONE))); + public static final Block MOSSY_COBBLESTONE_WALL = register("mossy_cobblestone_wall", new WallBlock(BlockBehaviour.Properties.copy(COBBLESTONE))); + public static final Block FLOWER_POT = register( + "flower_pot", new FlowerPotBlock(AIR, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_OAK_SAPLING = register( + "potted_oak_sapling", new FlowerPotBlock(OAK_SAPLING, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_SPRUCE_SAPLING = register( + "potted_spruce_sapling", new FlowerPotBlock(SPRUCE_SAPLING, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_BIRCH_SAPLING = register( + "potted_birch_sapling", new FlowerPotBlock(BIRCH_SAPLING, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_JUNGLE_SAPLING = register( + "potted_jungle_sapling", new FlowerPotBlock(JUNGLE_SAPLING, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_ACACIA_SAPLING = register( + "potted_acacia_sapling", new FlowerPotBlock(ACACIA_SAPLING, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_DARK_OAK_SAPLING = register( + "potted_dark_oak_sapling", new FlowerPotBlock(DARK_OAK_SAPLING, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_FERN = register( + "potted_fern", new FlowerPotBlock(FERN, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_DANDELION = register( + "potted_dandelion", new FlowerPotBlock(DANDELION, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_POPPY = register( + "potted_poppy", new FlowerPotBlock(POPPY, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_BLUE_ORCHID = register( + "potted_blue_orchid", new FlowerPotBlock(BLUE_ORCHID, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_ALLIUM = register( + "potted_allium", new FlowerPotBlock(ALLIUM, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_AZURE_BLUET = register( + "potted_azure_bluet", new FlowerPotBlock(AZURE_BLUET, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_RED_TULIP = register( + "potted_red_tulip", new FlowerPotBlock(RED_TULIP, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_ORANGE_TULIP = register( + "potted_orange_tulip", new FlowerPotBlock(ORANGE_TULIP, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_WHITE_TULIP = register( + "potted_white_tulip", new FlowerPotBlock(WHITE_TULIP, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_PINK_TULIP = register( + "potted_pink_tulip", new FlowerPotBlock(PINK_TULIP, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_OXEYE_DAISY = register( + "potted_oxeye_daisy", new FlowerPotBlock(OXEYE_DAISY, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_CORNFLOWER = register( + "potted_cornflower", new FlowerPotBlock(CORNFLOWER, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_LILY_OF_THE_VALLEY = register( + "potted_lily_of_the_valley", new FlowerPotBlock(LILY_OF_THE_VALLEY, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_WITHER_ROSE = register( + "potted_wither_rose", new FlowerPotBlock(WITHER_ROSE, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_RED_MUSHROOM = register( + "potted_red_mushroom", new FlowerPotBlock(RED_MUSHROOM, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_BROWN_MUSHROOM = register( + "potted_brown_mushroom", new FlowerPotBlock(BROWN_MUSHROOM, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_DEAD_BUSH = register( + "potted_dead_bush", new FlowerPotBlock(DEAD_BUSH, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_CACTUS = register( + "potted_cactus", new FlowerPotBlock(CACTUS, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block CARROTS = register( + "carrots", new CarrotBlock(BlockBehaviour.Properties.of(Material.PLANT).noCollission().randomTicks().instabreak().sound(SoundType.CROP)) + ); + public static final Block POTATOES = register( + "potatoes", new PotatoBlock(BlockBehaviour.Properties.of(Material.PLANT).noCollission().randomTicks().instabreak().sound(SoundType.CROP)) + ); + public static final Block OAK_BUTTON = register( + "oak_button", new WoodButtonBlock(BlockBehaviour.Properties.of(Material.DECORATION).noCollission().strength(0.5F).sound(SoundType.WOOD)) + ); + public static final Block SPRUCE_BUTTON = register( + "spruce_button", new WoodButtonBlock(BlockBehaviour.Properties.of(Material.DECORATION).noCollission().strength(0.5F).sound(SoundType.WOOD)) + ); + public static final Block BIRCH_BUTTON = register( + "birch_button", new WoodButtonBlock(BlockBehaviour.Properties.of(Material.DECORATION).noCollission().strength(0.5F).sound(SoundType.WOOD)) + ); + public static final Block JUNGLE_BUTTON = register( + "jungle_button", new WoodButtonBlock(BlockBehaviour.Properties.of(Material.DECORATION).noCollission().strength(0.5F).sound(SoundType.WOOD)) + ); + public static final Block ACACIA_BUTTON = register( + "acacia_button", new WoodButtonBlock(BlockBehaviour.Properties.of(Material.DECORATION).noCollission().strength(0.5F).sound(SoundType.WOOD)) + ); + public static final Block DARK_OAK_BUTTON = register( + "dark_oak_button", new WoodButtonBlock(BlockBehaviour.Properties.of(Material.DECORATION).noCollission().strength(0.5F).sound(SoundType.WOOD)) + ); + public static final Block SKELETON_SKULL = register( + "skeleton_skull", new SkullBlock(SkullBlock.Types.SKELETON, BlockBehaviour.Properties.of(Material.DECORATION).strength(1.0F)) + ); + public static final Block SKELETON_WALL_SKULL = register( + "skeleton_wall_skull", + new WallSkullBlock(SkullBlock.Types.SKELETON, BlockBehaviour.Properties.of(Material.DECORATION).strength(1.0F).dropsLike(SKELETON_SKULL)) + ); + public static final Block WITHER_SKELETON_SKULL = register( + "wither_skeleton_skull", new WitherSkullBlock(BlockBehaviour.Properties.of(Material.DECORATION).strength(1.0F)) + ); + public static final Block WITHER_SKELETON_WALL_SKULL = register( + "wither_skeleton_wall_skull", new WitherWallSkullBlock(BlockBehaviour.Properties.of(Material.DECORATION).strength(1.0F).dropsLike(WITHER_SKELETON_SKULL)) + ); + public static final Block ZOMBIE_HEAD = register( + "zombie_head", new SkullBlock(SkullBlock.Types.ZOMBIE, BlockBehaviour.Properties.of(Material.DECORATION).strength(1.0F)) + ); + public static final Block ZOMBIE_WALL_HEAD = register( + "zombie_wall_head", new WallSkullBlock(SkullBlock.Types.ZOMBIE, BlockBehaviour.Properties.of(Material.DECORATION).strength(1.0F).dropsLike(ZOMBIE_HEAD)) + ); + public static final Block PLAYER_HEAD = register("player_head", new PlayerHeadBlock(BlockBehaviour.Properties.of(Material.DECORATION).strength(1.0F))); + public static final Block PLAYER_WALL_HEAD = register( + "player_wall_head", new PlayerWallHeadBlock(BlockBehaviour.Properties.of(Material.DECORATION).strength(1.0F).dropsLike(PLAYER_HEAD)) + ); + public static final Block CREEPER_HEAD = register( + "creeper_head", new SkullBlock(SkullBlock.Types.CREEPER, BlockBehaviour.Properties.of(Material.DECORATION).strength(1.0F)) + ); + public static final Block CREEPER_WALL_HEAD = register( + "creeper_wall_head", new WallSkullBlock(SkullBlock.Types.CREEPER, BlockBehaviour.Properties.of(Material.DECORATION).strength(1.0F).dropsLike(CREEPER_HEAD)) + ); + public static final Block DRAGON_HEAD = register( + "dragon_head", new SkullBlock(SkullBlock.Types.DRAGON, BlockBehaviour.Properties.of(Material.DECORATION).strength(1.0F)) + ); + public static final Block DRAGON_WALL_HEAD = register( + "dragon_wall_head", new WallSkullBlock(SkullBlock.Types.DRAGON, BlockBehaviour.Properties.of(Material.DECORATION).strength(1.0F).dropsLike(DRAGON_HEAD)) + ); + public static final Block ANVIL = register( + "anvil", + new AnvilBlock( + BlockBehaviour.Properties.of(Material.HEAVY_METAL, MaterialColor.METAL).requiresCorrectToolForDrops().strength(5.0F, 1200.0F).sound(SoundType.ANVIL) + ) + ); + public static final Block CHIPPED_ANVIL = register( + "chipped_anvil", + new AnvilBlock( + BlockBehaviour.Properties.of(Material.HEAVY_METAL, MaterialColor.METAL).requiresCorrectToolForDrops().strength(5.0F, 1200.0F).sound(SoundType.ANVIL) + ) + ); + public static final Block DAMAGED_ANVIL = register( + "damaged_anvil", + new AnvilBlock( + BlockBehaviour.Properties.of(Material.HEAVY_METAL, MaterialColor.METAL).requiresCorrectToolForDrops().strength(5.0F, 1200.0F).sound(SoundType.ANVIL) + ) + ); + public static final Block TRAPPED_CHEST = register( + "trapped_chest", new TrappedChestBlock(BlockBehaviour.Properties.of(Material.WOOD).strength(2.5F).sound(SoundType.WOOD)) + ); + public static final Block LIGHT_WEIGHTED_PRESSURE_PLATE = register( + "light_weighted_pressure_plate", + new WeightedPressurePlateBlock( + 15, BlockBehaviour.Properties.of(Material.METAL, MaterialColor.GOLD).requiresCorrectToolForDrops().noCollission().strength(0.5F).sound(SoundType.WOOD) + ) + ); + public static final Block HEAVY_WEIGHTED_PRESSURE_PLATE = register( + "heavy_weighted_pressure_plate", + new WeightedPressurePlateBlock( + 150, BlockBehaviour.Properties.of(Material.METAL).requiresCorrectToolForDrops().noCollission().strength(0.5F).sound(SoundType.WOOD) + ) + ); + public static final Block COMPARATOR = register( + "comparator", new ComparatorBlock(BlockBehaviour.Properties.of(Material.DECORATION).instabreak().sound(SoundType.WOOD)) + ); + public static final Block DAYLIGHT_DETECTOR = register( + "daylight_detector", new DaylightDetectorBlock(BlockBehaviour.Properties.of(Material.WOOD).strength(0.2F).sound(SoundType.WOOD)) + ); + public static final Block REDSTONE_BLOCK = register( + "redstone_block", + new PoweredBlock( + BlockBehaviour.Properties.of(Material.METAL, MaterialColor.FIRE) + .requiresCorrectToolForDrops() + .strength(5.0F, 6.0F) + .sound(SoundType.METAL) + .isRedstoneConductor(Blocks::never) + ) + ); + public static final Block NETHER_QUARTZ_ORE = register( + "nether_quartz_ore", + new OreBlock( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.NETHER).requiresCorrectToolForDrops().strength(3.0F, 3.0F).sound(SoundType.NETHER_ORE), + UniformInt.of(2, 5) + ) + ); + public static final Block HOPPER = register( + "hopper", + new HopperBlock( + BlockBehaviour.Properties.of(Material.METAL, MaterialColor.STONE).requiresCorrectToolForDrops().strength(3.0F, 4.8F).sound(SoundType.METAL).noOcclusion() + ) + ); + public static final Block QUARTZ_BLOCK = register( + "quartz_block", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.QUARTZ).requiresCorrectToolForDrops().strength(0.8F)) + ); + public static final Block CHISELED_QUARTZ_BLOCK = register( + "chiseled_quartz_block", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.QUARTZ).requiresCorrectToolForDrops().strength(0.8F)) + ); + public static final Block QUARTZ_PILLAR = register( + "quartz_pillar", new RotatedPillarBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.QUARTZ).requiresCorrectToolForDrops().strength(0.8F)) + ); + public static final Block QUARTZ_STAIRS = register( + "quartz_stairs", new StairBlock(QUARTZ_BLOCK.defaultBlockState(), BlockBehaviour.Properties.copy(QUARTZ_BLOCK)) + ); + public static final Block ACTIVATOR_RAIL = register( + "activator_rail", new PoweredRailBlock(BlockBehaviour.Properties.of(Material.DECORATION).noCollission().strength(0.7F).sound(SoundType.METAL)) + ); + public static final Block DROPPER = register( + "dropper", new DropperBlock(BlockBehaviour.Properties.of(Material.STONE).requiresCorrectToolForDrops().strength(3.5F)) + ); + public static final Block WHITE_TERRACOTTA = register( + "white_terracotta", + new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.TERRACOTTA_WHITE).requiresCorrectToolForDrops().strength(1.25F, 4.2F)) + ); + public static final Block ORANGE_TERRACOTTA = register( + "orange_terracotta", + new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.TERRACOTTA_ORANGE).requiresCorrectToolForDrops().strength(1.25F, 4.2F)) + ); + public static final Block MAGENTA_TERRACOTTA = register( + "magenta_terracotta", + new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.TERRACOTTA_MAGENTA).requiresCorrectToolForDrops().strength(1.25F, 4.2F)) + ); + public static final Block LIGHT_BLUE_TERRACOTTA = register( + "light_blue_terracotta", + new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.TERRACOTTA_LIGHT_BLUE).requiresCorrectToolForDrops().strength(1.25F, 4.2F)) + ); + public static final Block YELLOW_TERRACOTTA = register( + "yellow_terracotta", + new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.TERRACOTTA_YELLOW).requiresCorrectToolForDrops().strength(1.25F, 4.2F)) + ); + public static final Block LIME_TERRACOTTA = register( + "lime_terracotta", + new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.TERRACOTTA_LIGHT_GREEN).requiresCorrectToolForDrops().strength(1.25F, 4.2F)) + ); + public static final Block PINK_TERRACOTTA = register( + "pink_terracotta", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.TERRACOTTA_PINK).requiresCorrectToolForDrops().strength(1.25F, 4.2F)) + ); + public static final Block GRAY_TERRACOTTA = register( + "gray_terracotta", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.TERRACOTTA_GRAY).requiresCorrectToolForDrops().strength(1.25F, 4.2F)) + ); + public static final Block LIGHT_GRAY_TERRACOTTA = register( + "light_gray_terracotta", + new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.TERRACOTTA_LIGHT_GRAY).requiresCorrectToolForDrops().strength(1.25F, 4.2F)) + ); + public static final Block CYAN_TERRACOTTA = register( + "cyan_terracotta", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.TERRACOTTA_CYAN).requiresCorrectToolForDrops().strength(1.25F, 4.2F)) + ); + public static final Block PURPLE_TERRACOTTA = register( + "purple_terracotta", + new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.TERRACOTTA_PURPLE).requiresCorrectToolForDrops().strength(1.25F, 4.2F)) + ); + public static final Block BLUE_TERRACOTTA = register( + "blue_terracotta", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.TERRACOTTA_BLUE).requiresCorrectToolForDrops().strength(1.25F, 4.2F)) + ); + public static final Block BROWN_TERRACOTTA = register( + "brown_terracotta", + new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.TERRACOTTA_BROWN).requiresCorrectToolForDrops().strength(1.25F, 4.2F)) + ); + public static final Block GREEN_TERRACOTTA = register( + "green_terracotta", + new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.TERRACOTTA_GREEN).requiresCorrectToolForDrops().strength(1.25F, 4.2F)) + ); + public static final Block RED_TERRACOTTA = register( + "red_terracotta", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.TERRACOTTA_RED).requiresCorrectToolForDrops().strength(1.25F, 4.2F)) + ); + public static final Block BLACK_TERRACOTTA = register( + "black_terracotta", + new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.TERRACOTTA_BLACK).requiresCorrectToolForDrops().strength(1.25F, 4.2F)) + ); + public static final Block WHITE_STAINED_GLASS_PANE = register( + "white_stained_glass_pane", + new StainedGlassPaneBlock(DyeColor.WHITE, BlockBehaviour.Properties.of(Material.GLASS).strength(0.3F).sound(SoundType.GLASS).noOcclusion()) + ); + public static final Block ORANGE_STAINED_GLASS_PANE = register( + "orange_stained_glass_pane", + new StainedGlassPaneBlock(DyeColor.ORANGE, BlockBehaviour.Properties.of(Material.GLASS).strength(0.3F).sound(SoundType.GLASS).noOcclusion()) + ); + public static final Block MAGENTA_STAINED_GLASS_PANE = register( + "magenta_stained_glass_pane", + new StainedGlassPaneBlock(DyeColor.MAGENTA, BlockBehaviour.Properties.of(Material.GLASS).strength(0.3F).sound(SoundType.GLASS).noOcclusion()) + ); + public static final Block LIGHT_BLUE_STAINED_GLASS_PANE = register( + "light_blue_stained_glass_pane", + new StainedGlassPaneBlock(DyeColor.LIGHT_BLUE, BlockBehaviour.Properties.of(Material.GLASS).strength(0.3F).sound(SoundType.GLASS).noOcclusion()) + ); + public static final Block YELLOW_STAINED_GLASS_PANE = register( + "yellow_stained_glass_pane", + new StainedGlassPaneBlock(DyeColor.YELLOW, BlockBehaviour.Properties.of(Material.GLASS).strength(0.3F).sound(SoundType.GLASS).noOcclusion()) + ); + public static final Block LIME_STAINED_GLASS_PANE = register( + "lime_stained_glass_pane", + new StainedGlassPaneBlock(DyeColor.LIME, BlockBehaviour.Properties.of(Material.GLASS).strength(0.3F).sound(SoundType.GLASS).noOcclusion()) + ); + public static final Block PINK_STAINED_GLASS_PANE = register( + "pink_stained_glass_pane", + new StainedGlassPaneBlock(DyeColor.PINK, BlockBehaviour.Properties.of(Material.GLASS).strength(0.3F).sound(SoundType.GLASS).noOcclusion()) + ); + public static final Block GRAY_STAINED_GLASS_PANE = register( + "gray_stained_glass_pane", + new StainedGlassPaneBlock(DyeColor.GRAY, BlockBehaviour.Properties.of(Material.GLASS).strength(0.3F).sound(SoundType.GLASS).noOcclusion()) + ); + public static final Block LIGHT_GRAY_STAINED_GLASS_PANE = register( + "light_gray_stained_glass_pane", + new StainedGlassPaneBlock(DyeColor.LIGHT_GRAY, BlockBehaviour.Properties.of(Material.GLASS).strength(0.3F).sound(SoundType.GLASS).noOcclusion()) + ); + public static final Block CYAN_STAINED_GLASS_PANE = register( + "cyan_stained_glass_pane", + new StainedGlassPaneBlock(DyeColor.CYAN, BlockBehaviour.Properties.of(Material.GLASS).strength(0.3F).sound(SoundType.GLASS).noOcclusion()) + ); + public static final Block PURPLE_STAINED_GLASS_PANE = register( + "purple_stained_glass_pane", + new StainedGlassPaneBlock(DyeColor.PURPLE, BlockBehaviour.Properties.of(Material.GLASS).strength(0.3F).sound(SoundType.GLASS).noOcclusion()) + ); + public static final Block BLUE_STAINED_GLASS_PANE = register( + "blue_stained_glass_pane", + new StainedGlassPaneBlock(DyeColor.BLUE, BlockBehaviour.Properties.of(Material.GLASS).strength(0.3F).sound(SoundType.GLASS).noOcclusion()) + ); + public static final Block BROWN_STAINED_GLASS_PANE = register( + "brown_stained_glass_pane", + new StainedGlassPaneBlock(DyeColor.BROWN, BlockBehaviour.Properties.of(Material.GLASS).strength(0.3F).sound(SoundType.GLASS).noOcclusion()) + ); + public static final Block GREEN_STAINED_GLASS_PANE = register( + "green_stained_glass_pane", + new StainedGlassPaneBlock(DyeColor.GREEN, BlockBehaviour.Properties.of(Material.GLASS).strength(0.3F).sound(SoundType.GLASS).noOcclusion()) + ); + public static final Block RED_STAINED_GLASS_PANE = register( + "red_stained_glass_pane", + new StainedGlassPaneBlock(DyeColor.RED, BlockBehaviour.Properties.of(Material.GLASS).strength(0.3F).sound(SoundType.GLASS).noOcclusion()) + ); + public static final Block BLACK_STAINED_GLASS_PANE = register( + "black_stained_glass_pane", + new StainedGlassPaneBlock(DyeColor.BLACK, BlockBehaviour.Properties.of(Material.GLASS).strength(0.3F).sound(SoundType.GLASS).noOcclusion()) + ); + public static final Block ACACIA_STAIRS = register( + "acacia_stairs", new StairBlock(ACACIA_PLANKS.defaultBlockState(), BlockBehaviour.Properties.copy(ACACIA_PLANKS)) + ); + public static final Block DARK_OAK_STAIRS = register( + "dark_oak_stairs", new StairBlock(DARK_OAK_PLANKS.defaultBlockState(), BlockBehaviour.Properties.copy(DARK_OAK_PLANKS)) + ); + public static final Block SLIME_BLOCK = register( + "slime_block", new SlimeBlock(BlockBehaviour.Properties.of(Material.CLAY, MaterialColor.GRASS).friction(0.8F).sound(SoundType.SLIME_BLOCK).noOcclusion()) + ); + public static final Block BARRIER = register( + "barrier", new BarrierBlock(BlockBehaviour.Properties.of(Material.BARRIER).strength(-1.0F, 3600000.8F).noDrops().noOcclusion().isValidSpawn(Blocks::never)) + ); + public static final Block LIGHT = register( + "light", new LightBlock(BlockBehaviour.Properties.of(Material.AIR).strength(-1.0F, 3600000.8F).noDrops().noOcclusion().lightLevel(LightBlock.LIGHT_EMISSION)) + ); + public static final Block IRON_TRAPDOOR = register( + "iron_trapdoor", + new TrapDoorBlock( + BlockBehaviour.Properties.of(Material.METAL).requiresCorrectToolForDrops().strength(5.0F).sound(SoundType.METAL).noOcclusion().isValidSpawn(Blocks::never) + ) + ); + public static final Block PRISMARINE = register( + "prismarine", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_CYAN).requiresCorrectToolForDrops().strength(1.5F, 6.0F)) + ); + public static final Block PRISMARINE_BRICKS = register( + "prismarine_bricks", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.DIAMOND).requiresCorrectToolForDrops().strength(1.5F, 6.0F)) + ); + public static final Block DARK_PRISMARINE = register( + "dark_prismarine", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.DIAMOND).requiresCorrectToolForDrops().strength(1.5F, 6.0F)) + ); + public static final Block PRISMARINE_STAIRS = register( + "prismarine_stairs", new StairBlock(PRISMARINE.defaultBlockState(), BlockBehaviour.Properties.copy(PRISMARINE)) + ); + public static final Block PRISMARINE_BRICK_STAIRS = register( + "prismarine_brick_stairs", new StairBlock(PRISMARINE_BRICKS.defaultBlockState(), BlockBehaviour.Properties.copy(PRISMARINE_BRICKS)) + ); + public static final Block DARK_PRISMARINE_STAIRS = register( + "dark_prismarine_stairs", new StairBlock(DARK_PRISMARINE.defaultBlockState(), BlockBehaviour.Properties.copy(DARK_PRISMARINE)) + ); + public static final Block PRISMARINE_SLAB = register( + "prismarine_slab", new SlabBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_CYAN).requiresCorrectToolForDrops().strength(1.5F, 6.0F)) + ); + public static final Block PRISMARINE_BRICK_SLAB = register( + "prismarine_brick_slab", + new SlabBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.DIAMOND).requiresCorrectToolForDrops().strength(1.5F, 6.0F)) + ); + public static final Block DARK_PRISMARINE_SLAB = register( + "dark_prismarine_slab", new SlabBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.DIAMOND).requiresCorrectToolForDrops().strength(1.5F, 6.0F)) + ); + public static final Block SEA_LANTERN = register( + "sea_lantern", + new Block(BlockBehaviour.Properties.of(Material.GLASS, MaterialColor.QUARTZ).strength(0.3F).sound(SoundType.GLASS).lightLevel(blockStatex -> 15)) + ); + public static final Block HAY_BLOCK = register( + "hay_block", new HayBlock(BlockBehaviour.Properties.of(Material.GRASS, MaterialColor.COLOR_YELLOW).strength(0.5F).sound(SoundType.GRASS)) + ); + public static final Block WHITE_CARPET = register( + "white_carpet", + new WoolCarpetBlock(DyeColor.WHITE, BlockBehaviour.Properties.of(Material.CLOTH_DECORATION, MaterialColor.SNOW).strength(0.1F).sound(SoundType.WOOL)) + ); + public static final Block ORANGE_CARPET = register( + "orange_carpet", + new WoolCarpetBlock(DyeColor.ORANGE, BlockBehaviour.Properties.of(Material.CLOTH_DECORATION, MaterialColor.COLOR_ORANGE).strength(0.1F).sound(SoundType.WOOL)) + ); + public static final Block MAGENTA_CARPET = register( + "magenta_carpet", + new WoolCarpetBlock( + DyeColor.MAGENTA, BlockBehaviour.Properties.of(Material.CLOTH_DECORATION, MaterialColor.COLOR_MAGENTA).strength(0.1F).sound(SoundType.WOOL) + ) + ); + public static final Block LIGHT_BLUE_CARPET = register( + "light_blue_carpet", + new WoolCarpetBlock( + DyeColor.LIGHT_BLUE, BlockBehaviour.Properties.of(Material.CLOTH_DECORATION, MaterialColor.COLOR_LIGHT_BLUE).strength(0.1F).sound(SoundType.WOOL) + ) + ); + public static final Block YELLOW_CARPET = register( + "yellow_carpet", + new WoolCarpetBlock(DyeColor.YELLOW, BlockBehaviour.Properties.of(Material.CLOTH_DECORATION, MaterialColor.COLOR_YELLOW).strength(0.1F).sound(SoundType.WOOL)) + ); + public static final Block LIME_CARPET = register( + "lime_carpet", + new WoolCarpetBlock( + DyeColor.LIME, BlockBehaviour.Properties.of(Material.CLOTH_DECORATION, MaterialColor.COLOR_LIGHT_GREEN).strength(0.1F).sound(SoundType.WOOL) + ) + ); + public static final Block PINK_CARPET = register( + "pink_carpet", + new WoolCarpetBlock(DyeColor.PINK, BlockBehaviour.Properties.of(Material.CLOTH_DECORATION, MaterialColor.COLOR_PINK).strength(0.1F).sound(SoundType.WOOL)) + ); + public static final Block GRAY_CARPET = register( + "gray_carpet", + new WoolCarpetBlock(DyeColor.GRAY, BlockBehaviour.Properties.of(Material.CLOTH_DECORATION, MaterialColor.COLOR_GRAY).strength(0.1F).sound(SoundType.WOOL)) + ); + public static final Block LIGHT_GRAY_CARPET = register( + "light_gray_carpet", + new WoolCarpetBlock( + DyeColor.LIGHT_GRAY, BlockBehaviour.Properties.of(Material.CLOTH_DECORATION, MaterialColor.COLOR_LIGHT_GRAY).strength(0.1F).sound(SoundType.WOOL) + ) + ); + public static final Block CYAN_CARPET = register( + "cyan_carpet", + new WoolCarpetBlock(DyeColor.CYAN, BlockBehaviour.Properties.of(Material.CLOTH_DECORATION, MaterialColor.COLOR_CYAN).strength(0.1F).sound(SoundType.WOOL)) + ); + public static final Block PURPLE_CARPET = register( + "purple_carpet", + new WoolCarpetBlock(DyeColor.PURPLE, BlockBehaviour.Properties.of(Material.CLOTH_DECORATION, MaterialColor.COLOR_PURPLE).strength(0.1F).sound(SoundType.WOOL)) + ); + public static final Block BLUE_CARPET = register( + "blue_carpet", + new WoolCarpetBlock(DyeColor.BLUE, BlockBehaviour.Properties.of(Material.CLOTH_DECORATION, MaterialColor.COLOR_BLUE).strength(0.1F).sound(SoundType.WOOL)) + ); + public static final Block BROWN_CARPET = register( + "brown_carpet", + new WoolCarpetBlock(DyeColor.BROWN, BlockBehaviour.Properties.of(Material.CLOTH_DECORATION, MaterialColor.COLOR_BROWN).strength(0.1F).sound(SoundType.WOOL)) + ); + public static final Block GREEN_CARPET = register( + "green_carpet", + new WoolCarpetBlock(DyeColor.GREEN, BlockBehaviour.Properties.of(Material.CLOTH_DECORATION, MaterialColor.COLOR_GREEN).strength(0.1F).sound(SoundType.WOOL)) + ); + public static final Block RED_CARPET = register( + "red_carpet", + new WoolCarpetBlock(DyeColor.RED, BlockBehaviour.Properties.of(Material.CLOTH_DECORATION, MaterialColor.COLOR_RED).strength(0.1F).sound(SoundType.WOOL)) + ); + public static final Block BLACK_CARPET = register( + "black_carpet", + new WoolCarpetBlock(DyeColor.BLACK, BlockBehaviour.Properties.of(Material.CLOTH_DECORATION, MaterialColor.COLOR_BLACK).strength(0.1F).sound(SoundType.WOOL)) + ); + public static final Block TERRACOTTA = register( + "terracotta", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_ORANGE).requiresCorrectToolForDrops().strength(1.25F, 4.2F)) + ); + public static final Block COAL_BLOCK = register( + "coal_block", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_BLACK).requiresCorrectToolForDrops().strength(5.0F, 6.0F)) + ); + public static final Block PACKED_ICE = register( + "packed_ice", new Block(BlockBehaviour.Properties.of(Material.ICE_SOLID).friction(0.98F).strength(0.5F).sound(SoundType.GLASS)) + ); + public static final Block SUNFLOWER = register( + "sunflower", new TallFlowerBlock(BlockBehaviour.Properties.of(Material.REPLACEABLE_PLANT).noCollission().instabreak().sound(SoundType.GRASS)) + ); + public static final Block LILAC = register( + "lilac", new TallFlowerBlock(BlockBehaviour.Properties.of(Material.REPLACEABLE_PLANT).noCollission().instabreak().sound(SoundType.GRASS)) + ); + public static final Block ROSE_BUSH = register( + "rose_bush", new TallFlowerBlock(BlockBehaviour.Properties.of(Material.REPLACEABLE_PLANT).noCollission().instabreak().sound(SoundType.GRASS)) + ); + public static final Block PEONY = register( + "peony", new TallFlowerBlock(BlockBehaviour.Properties.of(Material.REPLACEABLE_PLANT).noCollission().instabreak().sound(SoundType.GRASS)) + ); + public static final Block TALL_GRASS = register( + "tall_grass", new DoublePlantBlock(BlockBehaviour.Properties.of(Material.REPLACEABLE_PLANT).noCollission().instabreak().sound(SoundType.GRASS)) + ); + public static final Block LARGE_FERN = register( + "large_fern", new DoublePlantBlock(BlockBehaviour.Properties.of(Material.REPLACEABLE_PLANT).noCollission().instabreak().sound(SoundType.GRASS)) + ); + public static final Block WHITE_BANNER = register( + "white_banner", new BannerBlock(DyeColor.WHITE, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD)) + ); + public static final Block ORANGE_BANNER = register( + "orange_banner", new BannerBlock(DyeColor.ORANGE, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD)) + ); + public static final Block MAGENTA_BANNER = register( + "magenta_banner", new BannerBlock(DyeColor.MAGENTA, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD)) + ); + public static final Block LIGHT_BLUE_BANNER = register( + "light_blue_banner", new BannerBlock(DyeColor.LIGHT_BLUE, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD)) + ); + public static final Block YELLOW_BANNER = register( + "yellow_banner", new BannerBlock(DyeColor.YELLOW, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD)) + ); + public static final Block LIME_BANNER = register( + "lime_banner", new BannerBlock(DyeColor.LIME, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD)) + ); + public static final Block PINK_BANNER = register( + "pink_banner", new BannerBlock(DyeColor.PINK, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD)) + ); + public static final Block GRAY_BANNER = register( + "gray_banner", new BannerBlock(DyeColor.GRAY, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD)) + ); + public static final Block LIGHT_GRAY_BANNER = register( + "light_gray_banner", new BannerBlock(DyeColor.LIGHT_GRAY, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD)) + ); + public static final Block CYAN_BANNER = register( + "cyan_banner", new BannerBlock(DyeColor.CYAN, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD)) + ); + public static final Block PURPLE_BANNER = register( + "purple_banner", new BannerBlock(DyeColor.PURPLE, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD)) + ); + public static final Block BLUE_BANNER = register( + "blue_banner", new BannerBlock(DyeColor.BLUE, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD)) + ); + public static final Block BROWN_BANNER = register( + "brown_banner", new BannerBlock(DyeColor.BROWN, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD)) + ); + public static final Block GREEN_BANNER = register( + "green_banner", new BannerBlock(DyeColor.GREEN, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD)) + ); + public static final Block RED_BANNER = register( + "red_banner", new BannerBlock(DyeColor.RED, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD)) + ); + public static final Block BLACK_BANNER = register( + "black_banner", new BannerBlock(DyeColor.BLACK, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD)) + ); + public static final Block WHITE_WALL_BANNER = register( + "white_wall_banner", + new WallBannerBlock(DyeColor.WHITE, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD).dropsLike(WHITE_BANNER)) + ); + public static final Block ORANGE_WALL_BANNER = register( + "orange_wall_banner", + new WallBannerBlock(DyeColor.ORANGE, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD).dropsLike(ORANGE_BANNER)) + ); + public static final Block MAGENTA_WALL_BANNER = register( + "magenta_wall_banner", + new WallBannerBlock( + DyeColor.MAGENTA, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD).dropsLike(MAGENTA_BANNER) + ) + ); + public static final Block LIGHT_BLUE_WALL_BANNER = register( + "light_blue_wall_banner", + new WallBannerBlock( + DyeColor.LIGHT_BLUE, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD).dropsLike(LIGHT_BLUE_BANNER) + ) + ); + public static final Block YELLOW_WALL_BANNER = register( + "yellow_wall_banner", + new WallBannerBlock(DyeColor.YELLOW, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD).dropsLike(YELLOW_BANNER)) + ); + public static final Block LIME_WALL_BANNER = register( + "lime_wall_banner", + new WallBannerBlock(DyeColor.LIME, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD).dropsLike(LIME_BANNER)) + ); + public static final Block PINK_WALL_BANNER = register( + "pink_wall_banner", + new WallBannerBlock(DyeColor.PINK, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD).dropsLike(PINK_BANNER)) + ); + public static final Block GRAY_WALL_BANNER = register( + "gray_wall_banner", + new WallBannerBlock(DyeColor.GRAY, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD).dropsLike(GRAY_BANNER)) + ); + public static final Block LIGHT_GRAY_WALL_BANNER = register( + "light_gray_wall_banner", + new WallBannerBlock( + DyeColor.LIGHT_GRAY, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD).dropsLike(LIGHT_GRAY_BANNER) + ) + ); + public static final Block CYAN_WALL_BANNER = register( + "cyan_wall_banner", + new WallBannerBlock(DyeColor.CYAN, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD).dropsLike(CYAN_BANNER)) + ); + public static final Block PURPLE_WALL_BANNER = register( + "purple_wall_banner", + new WallBannerBlock(DyeColor.PURPLE, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD).dropsLike(PURPLE_BANNER)) + ); + public static final Block BLUE_WALL_BANNER = register( + "blue_wall_banner", + new WallBannerBlock(DyeColor.BLUE, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD).dropsLike(BLUE_BANNER)) + ); + public static final Block BROWN_WALL_BANNER = register( + "brown_wall_banner", + new WallBannerBlock(DyeColor.BROWN, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD).dropsLike(BROWN_BANNER)) + ); + public static final Block GREEN_WALL_BANNER = register( + "green_wall_banner", + new WallBannerBlock(DyeColor.GREEN, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD).dropsLike(GREEN_BANNER)) + ); + public static final Block RED_WALL_BANNER = register( + "red_wall_banner", + new WallBannerBlock(DyeColor.RED, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD).dropsLike(RED_BANNER)) + ); + public static final Block BLACK_WALL_BANNER = register( + "black_wall_banner", + new WallBannerBlock(DyeColor.BLACK, BlockBehaviour.Properties.of(Material.WOOD).noCollission().strength(1.0F).sound(SoundType.WOOD).dropsLike(BLACK_BANNER)) + ); + public static final Block RED_SANDSTONE = register( + "red_sandstone", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_ORANGE).requiresCorrectToolForDrops().strength(0.8F)) + ); + public static final Block CHISELED_RED_SANDSTONE = register( + "chiseled_red_sandstone", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_ORANGE).requiresCorrectToolForDrops().strength(0.8F)) + ); + public static final Block CUT_RED_SANDSTONE = register( + "cut_red_sandstone", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_ORANGE).requiresCorrectToolForDrops().strength(0.8F)) + ); + public static final Block RED_SANDSTONE_STAIRS = register( + "red_sandstone_stairs", new StairBlock(RED_SANDSTONE.defaultBlockState(), BlockBehaviour.Properties.copy(RED_SANDSTONE)) + ); + public static final Block OAK_SLAB = register( + "oak_slab", new SlabBlock(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.WOOD).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block SPRUCE_SLAB = register( + "spruce_slab", new SlabBlock(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.PODZOL).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block BIRCH_SLAB = register( + "birch_slab", new SlabBlock(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.SAND).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block JUNGLE_SLAB = register( + "jungle_slab", new SlabBlock(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.DIRT).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block ACACIA_SLAB = register( + "acacia_slab", new SlabBlock(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.COLOR_ORANGE).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block DARK_OAK_SLAB = register( + "dark_oak_slab", new SlabBlock(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.COLOR_BROWN).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block STONE_SLAB = register( + "stone_slab", new SlabBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.STONE).requiresCorrectToolForDrops().strength(2.0F, 6.0F)) + ); + public static final Block SMOOTH_STONE_SLAB = register( + "smooth_stone_slab", new SlabBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.STONE).requiresCorrectToolForDrops().strength(2.0F, 6.0F)) + ); + public static final Block SANDSTONE_SLAB = register( + "sandstone_slab", new SlabBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.SAND).requiresCorrectToolForDrops().strength(2.0F, 6.0F)) + ); + public static final Block CUT_SANDSTONE_SLAB = register( + "cut_sandstone_slab", new SlabBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.SAND).requiresCorrectToolForDrops().strength(2.0F, 6.0F)) + ); + public static final Block PETRIFIED_OAK_SLAB = register( + "petrified_oak_slab", new SlabBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.WOOD).requiresCorrectToolForDrops().strength(2.0F, 6.0F)) + ); + public static final Block COBBLESTONE_SLAB = register( + "cobblestone_slab", new SlabBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.STONE).requiresCorrectToolForDrops().strength(2.0F, 6.0F)) + ); + public static final Block BRICK_SLAB = register( + "brick_slab", new SlabBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_RED).requiresCorrectToolForDrops().strength(2.0F, 6.0F)) + ); + public static final Block STONE_BRICK_SLAB = register( + "stone_brick_slab", new SlabBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.STONE).requiresCorrectToolForDrops().strength(2.0F, 6.0F)) + ); + public static final Block NETHER_BRICK_SLAB = register( + "nether_brick_slab", + new SlabBlock( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.NETHER).requiresCorrectToolForDrops().strength(2.0F, 6.0F).sound(SoundType.NETHER_BRICKS) + ) + ); + public static final Block QUARTZ_SLAB = register( + "quartz_slab", new SlabBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.QUARTZ).requiresCorrectToolForDrops().strength(2.0F, 6.0F)) + ); + public static final Block RED_SANDSTONE_SLAB = register( + "red_sandstone_slab", + new SlabBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_ORANGE).requiresCorrectToolForDrops().strength(2.0F, 6.0F)) + ); + public static final Block CUT_RED_SANDSTONE_SLAB = register( + "cut_red_sandstone_slab", + new SlabBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_ORANGE).requiresCorrectToolForDrops().strength(2.0F, 6.0F)) + ); + public static final Block PURPUR_SLAB = register( + "purpur_slab", new SlabBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_MAGENTA).requiresCorrectToolForDrops().strength(2.0F, 6.0F)) + ); + public static final Block SMOOTH_STONE = register( + "smooth_stone", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.STONE).requiresCorrectToolForDrops().strength(2.0F, 6.0F)) + ); + public static final Block SMOOTH_SANDSTONE = register( + "smooth_sandstone", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.SAND).requiresCorrectToolForDrops().strength(2.0F, 6.0F)) + ); + public static final Block SMOOTH_QUARTZ = register( + "smooth_quartz", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.QUARTZ).requiresCorrectToolForDrops().strength(2.0F, 6.0F)) + ); + public static final Block SMOOTH_RED_SANDSTONE = register( + "smooth_red_sandstone", + new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_ORANGE).requiresCorrectToolForDrops().strength(2.0F, 6.0F)) + ); + public static final Block SPRUCE_FENCE_GATE = register( + "spruce_fence_gate", + new FenceGateBlock(BlockBehaviour.Properties.of(Material.WOOD, SPRUCE_PLANKS.defaultMaterialColor()).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block BIRCH_FENCE_GATE = register( + "birch_fence_gate", + new FenceGateBlock(BlockBehaviour.Properties.of(Material.WOOD, BIRCH_PLANKS.defaultMaterialColor()).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block JUNGLE_FENCE_GATE = register( + "jungle_fence_gate", + new FenceGateBlock(BlockBehaviour.Properties.of(Material.WOOD, JUNGLE_PLANKS.defaultMaterialColor()).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block ACACIA_FENCE_GATE = register( + "acacia_fence_gate", + new FenceGateBlock(BlockBehaviour.Properties.of(Material.WOOD, ACACIA_PLANKS.defaultMaterialColor()).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block DARK_OAK_FENCE_GATE = register( + "dark_oak_fence_gate", + new FenceGateBlock(BlockBehaviour.Properties.of(Material.WOOD, DARK_OAK_PLANKS.defaultMaterialColor()).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block SPRUCE_FENCE = register( + "spruce_fence", new FenceBlock(BlockBehaviour.Properties.of(Material.WOOD, SPRUCE_PLANKS.defaultMaterialColor()).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block BIRCH_FENCE = register( + "birch_fence", new FenceBlock(BlockBehaviour.Properties.of(Material.WOOD, BIRCH_PLANKS.defaultMaterialColor()).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block JUNGLE_FENCE = register( + "jungle_fence", new FenceBlock(BlockBehaviour.Properties.of(Material.WOOD, JUNGLE_PLANKS.defaultMaterialColor()).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block ACACIA_FENCE = register( + "acacia_fence", new FenceBlock(BlockBehaviour.Properties.of(Material.WOOD, ACACIA_PLANKS.defaultMaterialColor()).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block DARK_OAK_FENCE = register( + "dark_oak_fence", + new FenceBlock(BlockBehaviour.Properties.of(Material.WOOD, DARK_OAK_PLANKS.defaultMaterialColor()).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block SPRUCE_DOOR = register( + "spruce_door", + new DoorBlock(BlockBehaviour.Properties.of(Material.WOOD, SPRUCE_PLANKS.defaultMaterialColor()).strength(3.0F).sound(SoundType.WOOD).noOcclusion()) + ); + public static final Block BIRCH_DOOR = register( + "birch_door", + new DoorBlock(BlockBehaviour.Properties.of(Material.WOOD, BIRCH_PLANKS.defaultMaterialColor()).strength(3.0F).sound(SoundType.WOOD).noOcclusion()) + ); + public static final Block JUNGLE_DOOR = register( + "jungle_door", + new DoorBlock(BlockBehaviour.Properties.of(Material.WOOD, JUNGLE_PLANKS.defaultMaterialColor()).strength(3.0F).sound(SoundType.WOOD).noOcclusion()) + ); + public static final Block ACACIA_DOOR = register( + "acacia_door", + new DoorBlock(BlockBehaviour.Properties.of(Material.WOOD, ACACIA_PLANKS.defaultMaterialColor()).strength(3.0F).sound(SoundType.WOOD).noOcclusion()) + ); + public static final Block DARK_OAK_DOOR = register( + "dark_oak_door", + new DoorBlock(BlockBehaviour.Properties.of(Material.WOOD, DARK_OAK_PLANKS.defaultMaterialColor()).strength(3.0F).sound(SoundType.WOOD).noOcclusion()) + ); + public static final Block END_ROD = register( + "end_rod", new EndRodBlock(BlockBehaviour.Properties.of(Material.DECORATION).instabreak().lightLevel(blockStatex -> 14).sound(SoundType.WOOD).noOcclusion()) + ); + public static final Block CHORUS_PLANT = register( + "chorus_plant", + new ChorusPlantBlock(BlockBehaviour.Properties.of(Material.PLANT, MaterialColor.COLOR_PURPLE).strength(0.4F).sound(SoundType.WOOD).noOcclusion()) + ); + public static final Block CHORUS_FLOWER = register( + "chorus_flower", + new ChorusFlowerBlock( + (ChorusPlantBlock)CHORUS_PLANT, + BlockBehaviour.Properties.of(Material.PLANT, MaterialColor.COLOR_PURPLE).randomTicks().strength(0.4F).sound(SoundType.WOOD).noOcclusion() + ) + ); + public static final Block PURPUR_BLOCK = register( + "purpur_block", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_MAGENTA).requiresCorrectToolForDrops().strength(1.5F, 6.0F)) + ); + public static final Block PURPUR_PILLAR = register( + "purpur_pillar", + new RotatedPillarBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_MAGENTA).requiresCorrectToolForDrops().strength(1.5F, 6.0F)) + ); + public static final Block PURPUR_STAIRS = register( + "purpur_stairs", new StairBlock(PURPUR_BLOCK.defaultBlockState(), BlockBehaviour.Properties.copy(PURPUR_BLOCK)) + ); + public static final Block END_STONE_BRICKS = register( + "end_stone_bricks", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.SAND).requiresCorrectToolForDrops().strength(3.0F, 9.0F)) + ); + public static final Block BEETROOTS = register( + "beetroots", new BeetrootBlock(BlockBehaviour.Properties.of(Material.PLANT).noCollission().randomTicks().instabreak().sound(SoundType.CROP)) + ); + public static final Block DIRT_PATH = register( + "dirt_path", + new DirtPathBlock( + BlockBehaviour.Properties.of(Material.DIRT).strength(0.65F).sound(SoundType.GRASS).isViewBlocking(Blocks::always).isSuffocating(Blocks::always) + ) + ); + public static final Block END_GATEWAY = register( + "end_gateway", + new EndGatewayBlock( + BlockBehaviour.Properties.of(Material.PORTAL, MaterialColor.COLOR_BLACK).noCollission().lightLevel(blockStatex -> 15).strength(-1.0F, 3600000.0F).noDrops() + ) + ); + public static final Block REPEATING_COMMAND_BLOCK = register( + "repeating_command_block", + new CommandBlock( + BlockBehaviour.Properties.of(Material.METAL, MaterialColor.COLOR_PURPLE).requiresCorrectToolForDrops().strength(-1.0F, 3600000.0F).noDrops(), false + ) + ); + public static final Block CHAIN_COMMAND_BLOCK = register( + "chain_command_block", + new CommandBlock( + BlockBehaviour.Properties.of(Material.METAL, MaterialColor.COLOR_GREEN).requiresCorrectToolForDrops().strength(-1.0F, 3600000.0F).noDrops(), true + ) + ); + public static final Block FROSTED_ICE = register( + "frosted_ice", + new FrostedIceBlock( + BlockBehaviour.Properties.of(Material.ICE) + .friction(0.98F) + .randomTicks() + .strength(0.5F) + .sound(SoundType.GLASS) + .noOcclusion() + .isValidSpawn((blockStatex, blockGetter, blockPos, entityType) -> entityType == EntityType.POLAR_BEAR) + ) + ); + public static final Block MAGMA_BLOCK = register( + "magma_block", + new MagmaBlock( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.NETHER) + .requiresCorrectToolForDrops() + .lightLevel(blockStatex -> 3) + .randomTicks() + .strength(0.5F) + .isValidSpawn((blockStatex, blockGetter, blockPos, entityType) -> entityType.fireImmune()) + .hasPostProcess(Blocks::always) + .emissiveRendering(Blocks::always) + ) + ); + public static final Block NETHER_WART_BLOCK = register( + "nether_wart_block", new Block(BlockBehaviour.Properties.of(Material.GRASS, MaterialColor.COLOR_RED).strength(1.0F).sound(SoundType.WART_BLOCK)) + ); + public static final Block RED_NETHER_BRICKS = register( + "red_nether_bricks", + new Block( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.NETHER).requiresCorrectToolForDrops().strength(2.0F, 6.0F).sound(SoundType.NETHER_BRICKS) + ) + ); + public static final Block BONE_BLOCK = register( + "bone_block", + new RotatedPillarBlock( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.SAND).requiresCorrectToolForDrops().strength(2.0F).sound(SoundType.BONE_BLOCK) + ) + ); + public static final Block STRUCTURE_VOID = register( + "structure_void", new StructureVoidBlock(BlockBehaviour.Properties.of(Material.STRUCTURAL_AIR).noCollission().noDrops()) + ); + public static final Block OBSERVER = register( + "observer", new ObserverBlock(BlockBehaviour.Properties.of(Material.STONE).strength(3.0F).requiresCorrectToolForDrops().isRedstoneConductor(Blocks::never)) + ); + public static final Block SHULKER_BOX = register("shulker_box", shulkerBox(null, BlockBehaviour.Properties.of(Material.SHULKER_SHELL))); + public static final Block WHITE_SHULKER_BOX = register( + "white_shulker_box", shulkerBox(DyeColor.WHITE, BlockBehaviour.Properties.of(Material.SHULKER_SHELL, MaterialColor.SNOW)) + ); + public static final Block ORANGE_SHULKER_BOX = register( + "orange_shulker_box", shulkerBox(DyeColor.ORANGE, BlockBehaviour.Properties.of(Material.SHULKER_SHELL, MaterialColor.COLOR_ORANGE)) + ); + public static final Block MAGENTA_SHULKER_BOX = register( + "magenta_shulker_box", shulkerBox(DyeColor.MAGENTA, BlockBehaviour.Properties.of(Material.SHULKER_SHELL, MaterialColor.COLOR_MAGENTA)) + ); + public static final Block LIGHT_BLUE_SHULKER_BOX = register( + "light_blue_shulker_box", shulkerBox(DyeColor.LIGHT_BLUE, BlockBehaviour.Properties.of(Material.SHULKER_SHELL, MaterialColor.COLOR_LIGHT_BLUE)) + ); + public static final Block YELLOW_SHULKER_BOX = register( + "yellow_shulker_box", shulkerBox(DyeColor.YELLOW, BlockBehaviour.Properties.of(Material.SHULKER_SHELL, MaterialColor.COLOR_YELLOW)) + ); + public static final Block LIME_SHULKER_BOX = register( + "lime_shulker_box", shulkerBox(DyeColor.LIME, BlockBehaviour.Properties.of(Material.SHULKER_SHELL, MaterialColor.COLOR_LIGHT_GREEN)) + ); + public static final Block PINK_SHULKER_BOX = register( + "pink_shulker_box", shulkerBox(DyeColor.PINK, BlockBehaviour.Properties.of(Material.SHULKER_SHELL, MaterialColor.COLOR_PINK)) + ); + public static final Block GRAY_SHULKER_BOX = register( + "gray_shulker_box", shulkerBox(DyeColor.GRAY, BlockBehaviour.Properties.of(Material.SHULKER_SHELL, MaterialColor.COLOR_GRAY)) + ); + public static final Block LIGHT_GRAY_SHULKER_BOX = register( + "light_gray_shulker_box", shulkerBox(DyeColor.LIGHT_GRAY, BlockBehaviour.Properties.of(Material.SHULKER_SHELL, MaterialColor.COLOR_LIGHT_GRAY)) + ); + public static final Block CYAN_SHULKER_BOX = register( + "cyan_shulker_box", shulkerBox(DyeColor.CYAN, BlockBehaviour.Properties.of(Material.SHULKER_SHELL, MaterialColor.COLOR_CYAN)) + ); + public static final Block PURPLE_SHULKER_BOX = register( + "purple_shulker_box", shulkerBox(DyeColor.PURPLE, BlockBehaviour.Properties.of(Material.SHULKER_SHELL, MaterialColor.TERRACOTTA_PURPLE)) + ); + public static final Block BLUE_SHULKER_BOX = register( + "blue_shulker_box", shulkerBox(DyeColor.BLUE, BlockBehaviour.Properties.of(Material.SHULKER_SHELL, MaterialColor.COLOR_BLUE)) + ); + public static final Block BROWN_SHULKER_BOX = register( + "brown_shulker_box", shulkerBox(DyeColor.BROWN, BlockBehaviour.Properties.of(Material.SHULKER_SHELL, MaterialColor.COLOR_BROWN)) + ); + public static final Block GREEN_SHULKER_BOX = register( + "green_shulker_box", shulkerBox(DyeColor.GREEN, BlockBehaviour.Properties.of(Material.SHULKER_SHELL, MaterialColor.COLOR_GREEN)) + ); + public static final Block RED_SHULKER_BOX = register( + "red_shulker_box", shulkerBox(DyeColor.RED, BlockBehaviour.Properties.of(Material.SHULKER_SHELL, MaterialColor.COLOR_RED)) + ); + public static final Block BLACK_SHULKER_BOX = register( + "black_shulker_box", shulkerBox(DyeColor.BLACK, BlockBehaviour.Properties.of(Material.SHULKER_SHELL, MaterialColor.COLOR_BLACK)) + ); + public static final Block WHITE_GLAZED_TERRACOTTA = register( + "white_glazed_terracotta", + new GlazedTerracottaBlock(BlockBehaviour.Properties.of(Material.STONE, DyeColor.WHITE).requiresCorrectToolForDrops().strength(1.4F)) + ); + public static final Block ORANGE_GLAZED_TERRACOTTA = register( + "orange_glazed_terracotta", + new GlazedTerracottaBlock(BlockBehaviour.Properties.of(Material.STONE, DyeColor.ORANGE).requiresCorrectToolForDrops().strength(1.4F)) + ); + public static final Block MAGENTA_GLAZED_TERRACOTTA = register( + "magenta_glazed_terracotta", + new GlazedTerracottaBlock(BlockBehaviour.Properties.of(Material.STONE, DyeColor.MAGENTA).requiresCorrectToolForDrops().strength(1.4F)) + ); + public static final Block LIGHT_BLUE_GLAZED_TERRACOTTA = register( + "light_blue_glazed_terracotta", + new GlazedTerracottaBlock(BlockBehaviour.Properties.of(Material.STONE, DyeColor.LIGHT_BLUE).requiresCorrectToolForDrops().strength(1.4F)) + ); + public static final Block YELLOW_GLAZED_TERRACOTTA = register( + "yellow_glazed_terracotta", + new GlazedTerracottaBlock(BlockBehaviour.Properties.of(Material.STONE, DyeColor.YELLOW).requiresCorrectToolForDrops().strength(1.4F)) + ); + public static final Block LIME_GLAZED_TERRACOTTA = register( + "lime_glazed_terracotta", new GlazedTerracottaBlock(BlockBehaviour.Properties.of(Material.STONE, DyeColor.LIME).requiresCorrectToolForDrops().strength(1.4F)) + ); + public static final Block PINK_GLAZED_TERRACOTTA = register( + "pink_glazed_terracotta", new GlazedTerracottaBlock(BlockBehaviour.Properties.of(Material.STONE, DyeColor.PINK).requiresCorrectToolForDrops().strength(1.4F)) + ); + public static final Block GRAY_GLAZED_TERRACOTTA = register( + "gray_glazed_terracotta", new GlazedTerracottaBlock(BlockBehaviour.Properties.of(Material.STONE, DyeColor.GRAY).requiresCorrectToolForDrops().strength(1.4F)) + ); + public static final Block LIGHT_GRAY_GLAZED_TERRACOTTA = register( + "light_gray_glazed_terracotta", + new GlazedTerracottaBlock(BlockBehaviour.Properties.of(Material.STONE, DyeColor.LIGHT_GRAY).requiresCorrectToolForDrops().strength(1.4F)) + ); + public static final Block CYAN_GLAZED_TERRACOTTA = register( + "cyan_glazed_terracotta", new GlazedTerracottaBlock(BlockBehaviour.Properties.of(Material.STONE, DyeColor.CYAN).requiresCorrectToolForDrops().strength(1.4F)) + ); + public static final Block PURPLE_GLAZED_TERRACOTTA = register( + "purple_glazed_terracotta", + new GlazedTerracottaBlock(BlockBehaviour.Properties.of(Material.STONE, DyeColor.PURPLE).requiresCorrectToolForDrops().strength(1.4F)) + ); + public static final Block BLUE_GLAZED_TERRACOTTA = register( + "blue_glazed_terracotta", new GlazedTerracottaBlock(BlockBehaviour.Properties.of(Material.STONE, DyeColor.BLUE).requiresCorrectToolForDrops().strength(1.4F)) + ); + public static final Block BROWN_GLAZED_TERRACOTTA = register( + "brown_glazed_terracotta", + new GlazedTerracottaBlock(BlockBehaviour.Properties.of(Material.STONE, DyeColor.BROWN).requiresCorrectToolForDrops().strength(1.4F)) + ); + public static final Block GREEN_GLAZED_TERRACOTTA = register( + "green_glazed_terracotta", + new GlazedTerracottaBlock(BlockBehaviour.Properties.of(Material.STONE, DyeColor.GREEN).requiresCorrectToolForDrops().strength(1.4F)) + ); + public static final Block RED_GLAZED_TERRACOTTA = register( + "red_glazed_terracotta", new GlazedTerracottaBlock(BlockBehaviour.Properties.of(Material.STONE, DyeColor.RED).requiresCorrectToolForDrops().strength(1.4F)) + ); + public static final Block BLACK_GLAZED_TERRACOTTA = register( + "black_glazed_terracotta", + new GlazedTerracottaBlock(BlockBehaviour.Properties.of(Material.STONE, DyeColor.BLACK).requiresCorrectToolForDrops().strength(1.4F)) + ); + public static final Block WHITE_CONCRETE = register( + "white_concrete", new Block(BlockBehaviour.Properties.of(Material.STONE, DyeColor.WHITE).requiresCorrectToolForDrops().strength(1.8F)) + ); + public static final Block ORANGE_CONCRETE = register( + "orange_concrete", new Block(BlockBehaviour.Properties.of(Material.STONE, DyeColor.ORANGE).requiresCorrectToolForDrops().strength(1.8F)) + ); + public static final Block MAGENTA_CONCRETE = register( + "magenta_concrete", new Block(BlockBehaviour.Properties.of(Material.STONE, DyeColor.MAGENTA).requiresCorrectToolForDrops().strength(1.8F)) + ); + public static final Block LIGHT_BLUE_CONCRETE = register( + "light_blue_concrete", new Block(BlockBehaviour.Properties.of(Material.STONE, DyeColor.LIGHT_BLUE).requiresCorrectToolForDrops().strength(1.8F)) + ); + public static final Block YELLOW_CONCRETE = register( + "yellow_concrete", new Block(BlockBehaviour.Properties.of(Material.STONE, DyeColor.YELLOW).requiresCorrectToolForDrops().strength(1.8F)) + ); + public static final Block LIME_CONCRETE = register( + "lime_concrete", new Block(BlockBehaviour.Properties.of(Material.STONE, DyeColor.LIME).requiresCorrectToolForDrops().strength(1.8F)) + ); + public static final Block PINK_CONCRETE = register( + "pink_concrete", new Block(BlockBehaviour.Properties.of(Material.STONE, DyeColor.PINK).requiresCorrectToolForDrops().strength(1.8F)) + ); + public static final Block GRAY_CONCRETE = register( + "gray_concrete", new Block(BlockBehaviour.Properties.of(Material.STONE, DyeColor.GRAY).requiresCorrectToolForDrops().strength(1.8F)) + ); + public static final Block LIGHT_GRAY_CONCRETE = register( + "light_gray_concrete", new Block(BlockBehaviour.Properties.of(Material.STONE, DyeColor.LIGHT_GRAY).requiresCorrectToolForDrops().strength(1.8F)) + ); + public static final Block CYAN_CONCRETE = register( + "cyan_concrete", new Block(BlockBehaviour.Properties.of(Material.STONE, DyeColor.CYAN).requiresCorrectToolForDrops().strength(1.8F)) + ); + public static final Block PURPLE_CONCRETE = register( + "purple_concrete", new Block(BlockBehaviour.Properties.of(Material.STONE, DyeColor.PURPLE).requiresCorrectToolForDrops().strength(1.8F)) + ); + public static final Block BLUE_CONCRETE = register( + "blue_concrete", new Block(BlockBehaviour.Properties.of(Material.STONE, DyeColor.BLUE).requiresCorrectToolForDrops().strength(1.8F)) + ); + public static final Block BROWN_CONCRETE = register( + "brown_concrete", new Block(BlockBehaviour.Properties.of(Material.STONE, DyeColor.BROWN).requiresCorrectToolForDrops().strength(1.8F)) + ); + public static final Block GREEN_CONCRETE = register( + "green_concrete", new Block(BlockBehaviour.Properties.of(Material.STONE, DyeColor.GREEN).requiresCorrectToolForDrops().strength(1.8F)) + ); + public static final Block RED_CONCRETE = register( + "red_concrete", new Block(BlockBehaviour.Properties.of(Material.STONE, DyeColor.RED).requiresCorrectToolForDrops().strength(1.8F)) + ); + public static final Block BLACK_CONCRETE = register( + "black_concrete", new Block(BlockBehaviour.Properties.of(Material.STONE, DyeColor.BLACK).requiresCorrectToolForDrops().strength(1.8F)) + ); + public static final Block WHITE_CONCRETE_POWDER = register( + "white_concrete_powder", + new ConcretePowderBlock(WHITE_CONCRETE, BlockBehaviour.Properties.of(Material.SAND, DyeColor.WHITE).strength(0.5F).sound(SoundType.SAND)) + ); + public static final Block ORANGE_CONCRETE_POWDER = register( + "orange_concrete_powder", + new ConcretePowderBlock(ORANGE_CONCRETE, BlockBehaviour.Properties.of(Material.SAND, DyeColor.ORANGE).strength(0.5F).sound(SoundType.SAND)) + ); + public static final Block MAGENTA_CONCRETE_POWDER = register( + "magenta_concrete_powder", + new ConcretePowderBlock(MAGENTA_CONCRETE, BlockBehaviour.Properties.of(Material.SAND, DyeColor.MAGENTA).strength(0.5F).sound(SoundType.SAND)) + ); + public static final Block LIGHT_BLUE_CONCRETE_POWDER = register( + "light_blue_concrete_powder", + new ConcretePowderBlock(LIGHT_BLUE_CONCRETE, BlockBehaviour.Properties.of(Material.SAND, DyeColor.LIGHT_BLUE).strength(0.5F).sound(SoundType.SAND)) + ); + public static final Block YELLOW_CONCRETE_POWDER = register( + "yellow_concrete_powder", + new ConcretePowderBlock(YELLOW_CONCRETE, BlockBehaviour.Properties.of(Material.SAND, DyeColor.YELLOW).strength(0.5F).sound(SoundType.SAND)) + ); + public static final Block LIME_CONCRETE_POWDER = register( + "lime_concrete_powder", + new ConcretePowderBlock(LIME_CONCRETE, BlockBehaviour.Properties.of(Material.SAND, DyeColor.LIME).strength(0.5F).sound(SoundType.SAND)) + ); + public static final Block PINK_CONCRETE_POWDER = register( + "pink_concrete_powder", + new ConcretePowderBlock(PINK_CONCRETE, BlockBehaviour.Properties.of(Material.SAND, DyeColor.PINK).strength(0.5F).sound(SoundType.SAND)) + ); + public static final Block GRAY_CONCRETE_POWDER = register( + "gray_concrete_powder", + new ConcretePowderBlock(GRAY_CONCRETE, BlockBehaviour.Properties.of(Material.SAND, DyeColor.GRAY).strength(0.5F).sound(SoundType.SAND)) + ); + public static final Block LIGHT_GRAY_CONCRETE_POWDER = register( + "light_gray_concrete_powder", + new ConcretePowderBlock(LIGHT_GRAY_CONCRETE, BlockBehaviour.Properties.of(Material.SAND, DyeColor.LIGHT_GRAY).strength(0.5F).sound(SoundType.SAND)) + ); + public static final Block CYAN_CONCRETE_POWDER = register( + "cyan_concrete_powder", + new ConcretePowderBlock(CYAN_CONCRETE, BlockBehaviour.Properties.of(Material.SAND, DyeColor.CYAN).strength(0.5F).sound(SoundType.SAND)) + ); + public static final Block PURPLE_CONCRETE_POWDER = register( + "purple_concrete_powder", + new ConcretePowderBlock(PURPLE_CONCRETE, BlockBehaviour.Properties.of(Material.SAND, DyeColor.PURPLE).strength(0.5F).sound(SoundType.SAND)) + ); + public static final Block BLUE_CONCRETE_POWDER = register( + "blue_concrete_powder", + new ConcretePowderBlock(BLUE_CONCRETE, BlockBehaviour.Properties.of(Material.SAND, DyeColor.BLUE).strength(0.5F).sound(SoundType.SAND)) + ); + public static final Block BROWN_CONCRETE_POWDER = register( + "brown_concrete_powder", + new ConcretePowderBlock(BROWN_CONCRETE, BlockBehaviour.Properties.of(Material.SAND, DyeColor.BROWN).strength(0.5F).sound(SoundType.SAND)) + ); + public static final Block GREEN_CONCRETE_POWDER = register( + "green_concrete_powder", + new ConcretePowderBlock(GREEN_CONCRETE, BlockBehaviour.Properties.of(Material.SAND, DyeColor.GREEN).strength(0.5F).sound(SoundType.SAND)) + ); + public static final Block RED_CONCRETE_POWDER = register( + "red_concrete_powder", new ConcretePowderBlock(RED_CONCRETE, BlockBehaviour.Properties.of(Material.SAND, DyeColor.RED).strength(0.5F).sound(SoundType.SAND)) + ); + public static final Block BLACK_CONCRETE_POWDER = register( + "black_concrete_powder", + new ConcretePowderBlock(BLACK_CONCRETE, BlockBehaviour.Properties.of(Material.SAND, DyeColor.BLACK).strength(0.5F).sound(SoundType.SAND)) + ); + public static final Block KELP = register( + "kelp", new KelpBlock(BlockBehaviour.Properties.of(Material.WATER_PLANT).noCollission().randomTicks().instabreak().sound(SoundType.WET_GRASS)) + ); + public static final Block KELP_PLANT = register( + "kelp_plant", new KelpPlantBlock(BlockBehaviour.Properties.of(Material.WATER_PLANT).noCollission().instabreak().sound(SoundType.WET_GRASS)) + ); + public static final Block DRIED_KELP_BLOCK = register( + "dried_kelp_block", new Block(BlockBehaviour.Properties.of(Material.GRASS, MaterialColor.COLOR_GREEN).strength(0.5F, 2.5F).sound(SoundType.GRASS)) + ); + public static final Block TURTLE_EGG = register( + "turtle_egg", + new TurtleEggBlock(BlockBehaviour.Properties.of(Material.EGG, MaterialColor.SAND).strength(0.5F).sound(SoundType.METAL).randomTicks().noOcclusion()) + ); + public static final Block DEAD_TUBE_CORAL_BLOCK = register( + "dead_tube_coral_block", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_GRAY).requiresCorrectToolForDrops().strength(1.5F, 6.0F)) + ); + public static final Block DEAD_BRAIN_CORAL_BLOCK = register( + "dead_brain_coral_block", + new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_GRAY).requiresCorrectToolForDrops().strength(1.5F, 6.0F)) + ); + public static final Block DEAD_BUBBLE_CORAL_BLOCK = register( + "dead_bubble_coral_block", + new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_GRAY).requiresCorrectToolForDrops().strength(1.5F, 6.0F)) + ); + public static final Block DEAD_FIRE_CORAL_BLOCK = register( + "dead_fire_coral_block", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_GRAY).requiresCorrectToolForDrops().strength(1.5F, 6.0F)) + ); + public static final Block DEAD_HORN_CORAL_BLOCK = register( + "dead_horn_coral_block", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_GRAY).requiresCorrectToolForDrops().strength(1.5F, 6.0F)) + ); + public static final Block TUBE_CORAL_BLOCK = register( + "tube_coral_block", + new CoralBlock( + DEAD_TUBE_CORAL_BLOCK, + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_BLUE).requiresCorrectToolForDrops().strength(1.5F, 6.0F).sound(SoundType.CORAL_BLOCK) + ) + ); + public static final Block BRAIN_CORAL_BLOCK = register( + "brain_coral_block", + new CoralBlock( + DEAD_BRAIN_CORAL_BLOCK, + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_PINK).requiresCorrectToolForDrops().strength(1.5F, 6.0F).sound(SoundType.CORAL_BLOCK) + ) + ); + public static final Block BUBBLE_CORAL_BLOCK = register( + "bubble_coral_block", + new CoralBlock( + DEAD_BUBBLE_CORAL_BLOCK, + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_PURPLE).requiresCorrectToolForDrops().strength(1.5F, 6.0F).sound(SoundType.CORAL_BLOCK) + ) + ); + public static final Block FIRE_CORAL_BLOCK = register( + "fire_coral_block", + new CoralBlock( + DEAD_FIRE_CORAL_BLOCK, + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_RED).requiresCorrectToolForDrops().strength(1.5F, 6.0F).sound(SoundType.CORAL_BLOCK) + ) + ); + public static final Block HORN_CORAL_BLOCK = register( + "horn_coral_block", + new CoralBlock( + DEAD_HORN_CORAL_BLOCK, + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_YELLOW).requiresCorrectToolForDrops().strength(1.5F, 6.0F).sound(SoundType.CORAL_BLOCK) + ) + ); + public static final Block DEAD_TUBE_CORAL = register( + "dead_tube_coral", + new BaseCoralPlantBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_GRAY).requiresCorrectToolForDrops().noCollission().instabreak()) + ); + public static final Block DEAD_BRAIN_CORAL = register( + "dead_brain_coral", + new BaseCoralPlantBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_GRAY).requiresCorrectToolForDrops().noCollission().instabreak()) + ); + public static final Block DEAD_BUBBLE_CORAL = register( + "dead_bubble_coral", + new BaseCoralPlantBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_GRAY).requiresCorrectToolForDrops().noCollission().instabreak()) + ); + public static final Block DEAD_FIRE_CORAL = register( + "dead_fire_coral", + new BaseCoralPlantBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_GRAY).requiresCorrectToolForDrops().noCollission().instabreak()) + ); + public static final Block DEAD_HORN_CORAL = register( + "dead_horn_coral", + new BaseCoralPlantBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_GRAY).requiresCorrectToolForDrops().noCollission().instabreak()) + ); + public static final Block TUBE_CORAL = register( + "tube_coral", + new CoralPlantBlock( + DEAD_TUBE_CORAL, BlockBehaviour.Properties.of(Material.WATER_PLANT, MaterialColor.COLOR_BLUE).noCollission().instabreak().sound(SoundType.WET_GRASS) + ) + ); + public static final Block BRAIN_CORAL = register( + "brain_coral", + new CoralPlantBlock( + DEAD_BRAIN_CORAL, BlockBehaviour.Properties.of(Material.WATER_PLANT, MaterialColor.COLOR_PINK).noCollission().instabreak().sound(SoundType.WET_GRASS) + ) + ); + public static final Block BUBBLE_CORAL = register( + "bubble_coral", + new CoralPlantBlock( + DEAD_BUBBLE_CORAL, BlockBehaviour.Properties.of(Material.WATER_PLANT, MaterialColor.COLOR_PURPLE).noCollission().instabreak().sound(SoundType.WET_GRASS) + ) + ); + public static final Block FIRE_CORAL = register( + "fire_coral", + new CoralPlantBlock( + DEAD_FIRE_CORAL, BlockBehaviour.Properties.of(Material.WATER_PLANT, MaterialColor.COLOR_RED).noCollission().instabreak().sound(SoundType.WET_GRASS) + ) + ); + public static final Block HORN_CORAL = register( + "horn_coral", + new CoralPlantBlock( + DEAD_HORN_CORAL, BlockBehaviour.Properties.of(Material.WATER_PLANT, MaterialColor.COLOR_YELLOW).noCollission().instabreak().sound(SoundType.WET_GRASS) + ) + ); + public static final Block DEAD_TUBE_CORAL_FAN = register( + "dead_tube_coral_fan", + new BaseCoralFanBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_GRAY).requiresCorrectToolForDrops().noCollission().instabreak()) + ); + public static final Block DEAD_BRAIN_CORAL_FAN = register( + "dead_brain_coral_fan", + new BaseCoralFanBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_GRAY).requiresCorrectToolForDrops().noCollission().instabreak()) + ); + public static final Block DEAD_BUBBLE_CORAL_FAN = register( + "dead_bubble_coral_fan", + new BaseCoralFanBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_GRAY).requiresCorrectToolForDrops().noCollission().instabreak()) + ); + public static final Block DEAD_FIRE_CORAL_FAN = register( + "dead_fire_coral_fan", + new BaseCoralFanBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_GRAY).requiresCorrectToolForDrops().noCollission().instabreak()) + ); + public static final Block DEAD_HORN_CORAL_FAN = register( + "dead_horn_coral_fan", + new BaseCoralFanBlock(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_GRAY).requiresCorrectToolForDrops().noCollission().instabreak()) + ); + public static final Block TUBE_CORAL_FAN = register( + "tube_coral_fan", + new CoralFanBlock( + DEAD_TUBE_CORAL_FAN, BlockBehaviour.Properties.of(Material.WATER_PLANT, MaterialColor.COLOR_BLUE).noCollission().instabreak().sound(SoundType.WET_GRASS) + ) + ); + public static final Block BRAIN_CORAL_FAN = register( + "brain_coral_fan", + new CoralFanBlock( + DEAD_BRAIN_CORAL_FAN, BlockBehaviour.Properties.of(Material.WATER_PLANT, MaterialColor.COLOR_PINK).noCollission().instabreak().sound(SoundType.WET_GRASS) + ) + ); + public static final Block BUBBLE_CORAL_FAN = register( + "bubble_coral_fan", + new CoralFanBlock( + DEAD_BUBBLE_CORAL_FAN, BlockBehaviour.Properties.of(Material.WATER_PLANT, MaterialColor.COLOR_PURPLE).noCollission().instabreak().sound(SoundType.WET_GRASS) + ) + ); + public static final Block FIRE_CORAL_FAN = register( + "fire_coral_fan", + new CoralFanBlock( + DEAD_FIRE_CORAL_FAN, BlockBehaviour.Properties.of(Material.WATER_PLANT, MaterialColor.COLOR_RED).noCollission().instabreak().sound(SoundType.WET_GRASS) + ) + ); + public static final Block HORN_CORAL_FAN = register( + "horn_coral_fan", + new CoralFanBlock( + DEAD_HORN_CORAL_FAN, BlockBehaviour.Properties.of(Material.WATER_PLANT, MaterialColor.COLOR_YELLOW).noCollission().instabreak().sound(SoundType.WET_GRASS) + ) + ); + public static final Block DEAD_TUBE_CORAL_WALL_FAN = register( + "dead_tube_coral_wall_fan", + new BaseCoralWallFanBlock( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_GRAY) + .requiresCorrectToolForDrops() + .noCollission() + .instabreak() + .dropsLike(DEAD_TUBE_CORAL_FAN) + ) + ); + public static final Block DEAD_BRAIN_CORAL_WALL_FAN = register( + "dead_brain_coral_wall_fan", + new BaseCoralWallFanBlock( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_GRAY) + .requiresCorrectToolForDrops() + .noCollission() + .instabreak() + .dropsLike(DEAD_BRAIN_CORAL_FAN) + ) + ); + public static final Block DEAD_BUBBLE_CORAL_WALL_FAN = register( + "dead_bubble_coral_wall_fan", + new BaseCoralWallFanBlock( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_GRAY) + .requiresCorrectToolForDrops() + .noCollission() + .instabreak() + .dropsLike(DEAD_BUBBLE_CORAL_FAN) + ) + ); + public static final Block DEAD_FIRE_CORAL_WALL_FAN = register( + "dead_fire_coral_wall_fan", + new BaseCoralWallFanBlock( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_GRAY) + .requiresCorrectToolForDrops() + .noCollission() + .instabreak() + .dropsLike(DEAD_FIRE_CORAL_FAN) + ) + ); + public static final Block DEAD_HORN_CORAL_WALL_FAN = register( + "dead_horn_coral_wall_fan", + new BaseCoralWallFanBlock( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_GRAY) + .requiresCorrectToolForDrops() + .noCollission() + .instabreak() + .dropsLike(DEAD_HORN_CORAL_FAN) + ) + ); + public static final Block TUBE_CORAL_WALL_FAN = register( + "tube_coral_wall_fan", + new CoralWallFanBlock( + DEAD_TUBE_CORAL_WALL_FAN, + BlockBehaviour.Properties.of(Material.WATER_PLANT, MaterialColor.COLOR_BLUE) + .noCollission() + .instabreak() + .sound(SoundType.WET_GRASS) + .dropsLike(TUBE_CORAL_FAN) + ) + ); + public static final Block BRAIN_CORAL_WALL_FAN = register( + "brain_coral_wall_fan", + new CoralWallFanBlock( + DEAD_BRAIN_CORAL_WALL_FAN, + BlockBehaviour.Properties.of(Material.WATER_PLANT, MaterialColor.COLOR_PINK) + .noCollission() + .instabreak() + .sound(SoundType.WET_GRASS) + .dropsLike(BRAIN_CORAL_FAN) + ) + ); + public static final Block BUBBLE_CORAL_WALL_FAN = register( + "bubble_coral_wall_fan", + new CoralWallFanBlock( + DEAD_BUBBLE_CORAL_WALL_FAN, + BlockBehaviour.Properties.of(Material.WATER_PLANT, MaterialColor.COLOR_PURPLE) + .noCollission() + .instabreak() + .sound(SoundType.WET_GRASS) + .dropsLike(BUBBLE_CORAL_FAN) + ) + ); + public static final Block FIRE_CORAL_WALL_FAN = register( + "fire_coral_wall_fan", + new CoralWallFanBlock( + DEAD_FIRE_CORAL_WALL_FAN, + BlockBehaviour.Properties.of(Material.WATER_PLANT, MaterialColor.COLOR_RED).noCollission().instabreak().sound(SoundType.WET_GRASS).dropsLike(FIRE_CORAL_FAN) + ) + ); + public static final Block HORN_CORAL_WALL_FAN = register( + "horn_coral_wall_fan", + new CoralWallFanBlock( + DEAD_HORN_CORAL_WALL_FAN, + BlockBehaviour.Properties.of(Material.WATER_PLANT, MaterialColor.COLOR_YELLOW) + .noCollission() + .instabreak() + .sound(SoundType.WET_GRASS) + .dropsLike(HORN_CORAL_FAN) + ) + ); + public static final Block SEA_PICKLE = register( + "sea_pickle", + new SeaPickleBlock( + BlockBehaviour.Properties.of(Material.WATER_PLANT, MaterialColor.COLOR_GREEN) + .lightLevel(blockStatex -> SeaPickleBlock.isDead(blockStatex) ? 0 : 3 + 3 * (Integer)blockStatex.getValue(SeaPickleBlock.PICKLES)) + .sound(SoundType.SLIME_BLOCK) + .noOcclusion() + ) + ); + public static final Block BLUE_ICE = register( + "blue_ice", new HalfTransparentBlock(BlockBehaviour.Properties.of(Material.ICE_SOLID).strength(2.8F).friction(0.989F).sound(SoundType.GLASS)) + ); + public static final Block CONDUIT = register( + "conduit", new ConduitBlock(BlockBehaviour.Properties.of(Material.GLASS, MaterialColor.DIAMOND).strength(3.0F).lightLevel(blockStatex -> 15).noOcclusion()) + ); + public static final Block BAMBOO_SAPLING = register( + "bamboo_sapling", + new BambooSaplingBlock( + BlockBehaviour.Properties.of(Material.BAMBOO_SAPLING).randomTicks().instabreak().noCollission().strength(1.0F).sound(SoundType.BAMBOO_SAPLING) + ) + ); + public static final Block BAMBOO = register( + "bamboo", + new BambooBlock( + BlockBehaviour.Properties.of(Material.BAMBOO, MaterialColor.PLANT) + .randomTicks() + .instabreak() + .strength(1.0F) + .sound(SoundType.BAMBOO) + .noOcclusion() + .dynamicShape() + ) + ); + public static final Block POTTED_BAMBOO = register( + "potted_bamboo", new FlowerPotBlock(BAMBOO, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block VOID_AIR = register("void_air", new AirBlock(BlockBehaviour.Properties.of(Material.AIR).noCollission().noDrops().air())); + public static final Block CAVE_AIR = register("cave_air", new AirBlock(BlockBehaviour.Properties.of(Material.AIR).noCollission().noDrops().air())); + public static final Block BUBBLE_COLUMN = register( + "bubble_column", new BubbleColumnBlock(BlockBehaviour.Properties.of(Material.BUBBLE_COLUMN).noCollission().noDrops()) + ); + public static final Block POLISHED_GRANITE_STAIRS = register( + "polished_granite_stairs", new StairBlock(POLISHED_GRANITE.defaultBlockState(), BlockBehaviour.Properties.copy(POLISHED_GRANITE)) + ); + public static final Block SMOOTH_RED_SANDSTONE_STAIRS = register( + "smooth_red_sandstone_stairs", new StairBlock(SMOOTH_RED_SANDSTONE.defaultBlockState(), BlockBehaviour.Properties.copy(SMOOTH_RED_SANDSTONE)) + ); + public static final Block MOSSY_STONE_BRICK_STAIRS = register( + "mossy_stone_brick_stairs", new StairBlock(MOSSY_STONE_BRICKS.defaultBlockState(), BlockBehaviour.Properties.copy(MOSSY_STONE_BRICKS)) + ); + public static final Block POLISHED_DIORITE_STAIRS = register( + "polished_diorite_stairs", new StairBlock(POLISHED_DIORITE.defaultBlockState(), BlockBehaviour.Properties.copy(POLISHED_DIORITE)) + ); + public static final Block MOSSY_COBBLESTONE_STAIRS = register( + "mossy_cobblestone_stairs", new StairBlock(MOSSY_COBBLESTONE.defaultBlockState(), BlockBehaviour.Properties.copy(MOSSY_COBBLESTONE)) + ); + public static final Block END_STONE_BRICK_STAIRS = register( + "end_stone_brick_stairs", new StairBlock(END_STONE_BRICKS.defaultBlockState(), BlockBehaviour.Properties.copy(END_STONE_BRICKS)) + ); + public static final Block STONE_STAIRS = register("stone_stairs", new StairBlock(STONE.defaultBlockState(), BlockBehaviour.Properties.copy(STONE))); + public static final Block SMOOTH_SANDSTONE_STAIRS = register( + "smooth_sandstone_stairs", new StairBlock(SMOOTH_SANDSTONE.defaultBlockState(), BlockBehaviour.Properties.copy(SMOOTH_SANDSTONE)) + ); + public static final Block SMOOTH_QUARTZ_STAIRS = register( + "smooth_quartz_stairs", new StairBlock(SMOOTH_QUARTZ.defaultBlockState(), BlockBehaviour.Properties.copy(SMOOTH_QUARTZ)) + ); + public static final Block GRANITE_STAIRS = register("granite_stairs", new StairBlock(GRANITE.defaultBlockState(), BlockBehaviour.Properties.copy(GRANITE))); + public static final Block ANDESITE_STAIRS = register("andesite_stairs", new StairBlock(ANDESITE.defaultBlockState(), BlockBehaviour.Properties.copy(ANDESITE))); + public static final Block RED_NETHER_BRICK_STAIRS = register( + "red_nether_brick_stairs", new StairBlock(RED_NETHER_BRICKS.defaultBlockState(), BlockBehaviour.Properties.copy(RED_NETHER_BRICKS)) + ); + public static final Block POLISHED_ANDESITE_STAIRS = register( + "polished_andesite_stairs", new StairBlock(POLISHED_ANDESITE.defaultBlockState(), BlockBehaviour.Properties.copy(POLISHED_ANDESITE)) + ); + public static final Block DIORITE_STAIRS = register("diorite_stairs", new StairBlock(DIORITE.defaultBlockState(), BlockBehaviour.Properties.copy(DIORITE))); + public static final Block POLISHED_GRANITE_SLAB = register("polished_granite_slab", new SlabBlock(BlockBehaviour.Properties.copy(POLISHED_GRANITE))); + public static final Block SMOOTH_RED_SANDSTONE_SLAB = register( + "smooth_red_sandstone_slab", new SlabBlock(BlockBehaviour.Properties.copy(SMOOTH_RED_SANDSTONE)) + ); + public static final Block MOSSY_STONE_BRICK_SLAB = register("mossy_stone_brick_slab", new SlabBlock(BlockBehaviour.Properties.copy(MOSSY_STONE_BRICKS))); + public static final Block POLISHED_DIORITE_SLAB = register("polished_diorite_slab", new SlabBlock(BlockBehaviour.Properties.copy(POLISHED_DIORITE))); + public static final Block MOSSY_COBBLESTONE_SLAB = register("mossy_cobblestone_slab", new SlabBlock(BlockBehaviour.Properties.copy(MOSSY_COBBLESTONE))); + public static final Block END_STONE_BRICK_SLAB = register("end_stone_brick_slab", new SlabBlock(BlockBehaviour.Properties.copy(END_STONE_BRICKS))); + public static final Block SMOOTH_SANDSTONE_SLAB = register("smooth_sandstone_slab", new SlabBlock(BlockBehaviour.Properties.copy(SMOOTH_SANDSTONE))); + public static final Block SMOOTH_QUARTZ_SLAB = register("smooth_quartz_slab", new SlabBlock(BlockBehaviour.Properties.copy(SMOOTH_QUARTZ))); + public static final Block GRANITE_SLAB = register("granite_slab", new SlabBlock(BlockBehaviour.Properties.copy(GRANITE))); + public static final Block ANDESITE_SLAB = register("andesite_slab", new SlabBlock(BlockBehaviour.Properties.copy(ANDESITE))); + public static final Block RED_NETHER_BRICK_SLAB = register("red_nether_brick_slab", new SlabBlock(BlockBehaviour.Properties.copy(RED_NETHER_BRICKS))); + public static final Block POLISHED_ANDESITE_SLAB = register("polished_andesite_slab", new SlabBlock(BlockBehaviour.Properties.copy(POLISHED_ANDESITE))); + public static final Block DIORITE_SLAB = register("diorite_slab", new SlabBlock(BlockBehaviour.Properties.copy(DIORITE))); + public static final Block BRICK_WALL = register("brick_wall", new WallBlock(BlockBehaviour.Properties.copy(BRICKS))); + public static final Block PRISMARINE_WALL = register("prismarine_wall", new WallBlock(BlockBehaviour.Properties.copy(PRISMARINE))); + public static final Block RED_SANDSTONE_WALL = register("red_sandstone_wall", new WallBlock(BlockBehaviour.Properties.copy(RED_SANDSTONE))); + public static final Block MOSSY_STONE_BRICK_WALL = register("mossy_stone_brick_wall", new WallBlock(BlockBehaviour.Properties.copy(MOSSY_STONE_BRICKS))); + public static final Block GRANITE_WALL = register("granite_wall", new WallBlock(BlockBehaviour.Properties.copy(GRANITE))); + public static final Block STONE_BRICK_WALL = register("stone_brick_wall", new WallBlock(BlockBehaviour.Properties.copy(STONE_BRICKS))); + public static final Block NETHER_BRICK_WALL = register("nether_brick_wall", new WallBlock(BlockBehaviour.Properties.copy(NETHER_BRICKS))); + public static final Block ANDESITE_WALL = register("andesite_wall", new WallBlock(BlockBehaviour.Properties.copy(ANDESITE))); + public static final Block RED_NETHER_BRICK_WALL = register("red_nether_brick_wall", new WallBlock(BlockBehaviour.Properties.copy(RED_NETHER_BRICKS))); + public static final Block SANDSTONE_WALL = register("sandstone_wall", new WallBlock(BlockBehaviour.Properties.copy(SANDSTONE))); + public static final Block END_STONE_BRICK_WALL = register("end_stone_brick_wall", new WallBlock(BlockBehaviour.Properties.copy(END_STONE_BRICKS))); + public static final Block DIORITE_WALL = register("diorite_wall", new WallBlock(BlockBehaviour.Properties.copy(DIORITE))); + public static final Block SCAFFOLDING = register( + "scaffolding", + new ScaffoldingBlock(BlockBehaviour.Properties.of(Material.DECORATION, MaterialColor.SAND).noCollission().sound(SoundType.SCAFFOLDING).dynamicShape()) + ); + public static final Block LOOM = register("loom", new LoomBlock(BlockBehaviour.Properties.of(Material.WOOD).strength(2.5F).sound(SoundType.WOOD))); + public static final Block BARREL = register("barrel", new BarrelBlock(BlockBehaviour.Properties.of(Material.WOOD).strength(2.5F).sound(SoundType.WOOD))); + public static final Block SMOKER = register( + "smoker", new SmokerBlock(BlockBehaviour.Properties.of(Material.STONE).requiresCorrectToolForDrops().strength(3.5F).lightLevel(litBlockEmission(13))) + ); + public static final Block BLAST_FURNACE = register( + "blast_furnace", + new BlastFurnaceBlock(BlockBehaviour.Properties.of(Material.STONE).requiresCorrectToolForDrops().strength(3.5F).lightLevel(litBlockEmission(13))) + ); + public static final Block CARTOGRAPHY_TABLE = register( + "cartography_table", new CartographyTableBlock(BlockBehaviour.Properties.of(Material.WOOD).strength(2.5F).sound(SoundType.WOOD)) + ); + public static final Block FLETCHING_TABLE = register( + "fletching_table", new FletchingTableBlock(BlockBehaviour.Properties.of(Material.WOOD).strength(2.5F).sound(SoundType.WOOD)) + ); + public static final Block GRINDSTONE = register( + "grindstone", + new GrindstoneBlock( + BlockBehaviour.Properties.of(Material.HEAVY_METAL, MaterialColor.METAL).requiresCorrectToolForDrops().strength(2.0F, 6.0F).sound(SoundType.STONE) + ) + ); + public static final Block LECTERN = register("lectern", new LecternBlock(BlockBehaviour.Properties.of(Material.WOOD).strength(2.5F).sound(SoundType.WOOD))); + public static final Block SMITHING_TABLE = register( + "smithing_table", new SmithingTableBlock(BlockBehaviour.Properties.of(Material.WOOD).strength(2.5F).sound(SoundType.WOOD)) + ); + public static final Block STONECUTTER = register( + "stonecutter", new StonecutterBlock(BlockBehaviour.Properties.of(Material.STONE).requiresCorrectToolForDrops().strength(3.5F)) + ); + public static final Block BELL = register( + "bell", new BellBlock(BlockBehaviour.Properties.of(Material.METAL, MaterialColor.GOLD).requiresCorrectToolForDrops().strength(5.0F).sound(SoundType.ANVIL)) + ); + public static final Block LANTERN = register( + "lantern", + new LanternBlock( + BlockBehaviour.Properties.of(Material.METAL) + .requiresCorrectToolForDrops() + .strength(3.5F) + .sound(SoundType.LANTERN) + .lightLevel(blockStatex -> 15) + .noOcclusion() + ) + ); + public static final Block SOUL_LANTERN = register( + "soul_lantern", + new LanternBlock( + BlockBehaviour.Properties.of(Material.METAL) + .requiresCorrectToolForDrops() + .strength(3.5F) + .sound(SoundType.LANTERN) + .lightLevel(blockStatex -> 10) + .noOcclusion() + ) + ); + public static final Block CAMPFIRE = register( + "campfire", + new CampfireBlock( + true, + 1, + BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.PODZOL).strength(2.0F).sound(SoundType.WOOD).lightLevel(litBlockEmission(15)).noOcclusion() + ) + ); + public static final Block SOUL_CAMPFIRE = register( + "soul_campfire", + new CampfireBlock( + false, + 2, + BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.PODZOL).strength(2.0F).sound(SoundType.WOOD).lightLevel(litBlockEmission(10)).noOcclusion() + ) + ); + public static final Block SWEET_BERRY_BUSH = register( + "sweet_berry_bush", new SweetBerryBushBlock(BlockBehaviour.Properties.of(Material.PLANT).randomTicks().noCollission().sound(SoundType.SWEET_BERRY_BUSH)) + ); + public static final Block WARPED_STEM = register("warped_stem", netherStem(MaterialColor.WARPED_STEM)); + public static final Block STRIPPED_WARPED_STEM = register("stripped_warped_stem", netherStem(MaterialColor.WARPED_STEM)); + public static final Block WARPED_HYPHAE = register( + "warped_hyphae", new RotatedPillarBlock(BlockBehaviour.Properties.of(Material.NETHER_WOOD, MaterialColor.WARPED_HYPHAE).strength(2.0F).sound(SoundType.STEM)) + ); + public static final Block STRIPPED_WARPED_HYPHAE = register( + "stripped_warped_hyphae", + new RotatedPillarBlock(BlockBehaviour.Properties.of(Material.NETHER_WOOD, MaterialColor.WARPED_HYPHAE).strength(2.0F).sound(SoundType.STEM)) + ); + public static final Block WARPED_NYLIUM = register( + "warped_nylium", + new NyliumBlock( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.WARPED_NYLIUM).requiresCorrectToolForDrops().strength(0.4F).sound(SoundType.NYLIUM).randomTicks() + ) + ); + public static final Block WARPED_FUNGUS = register( + "warped_fungus", + new FungusBlock( + BlockBehaviour.Properties.of(Material.PLANT, MaterialColor.COLOR_CYAN).instabreak().noCollission().sound(SoundType.FUNGUS), + () -> Features.WARPED_FUNGI_PLANTED + ) + ); + public static final Block WARPED_WART_BLOCK = register( + "warped_wart_block", new Block(BlockBehaviour.Properties.of(Material.GRASS, MaterialColor.WARPED_WART_BLOCK).strength(1.0F).sound(SoundType.WART_BLOCK)) + ); + public static final Block WARPED_ROOTS = register( + "warped_roots", + new RootsBlock( + BlockBehaviour.Properties.of(Material.REPLACEABLE_FIREPROOF_PLANT, MaterialColor.COLOR_CYAN).noCollission().instabreak().sound(SoundType.ROOTS) + ) + ); + public static final Block NETHER_SPROUTS = register( + "nether_sprouts", + new NetherSproutsBlock( + BlockBehaviour.Properties.of(Material.REPLACEABLE_FIREPROOF_PLANT, MaterialColor.COLOR_CYAN).noCollission().instabreak().sound(SoundType.NETHER_SPROUTS) + ) + ); + public static final Block CRIMSON_STEM = register("crimson_stem", netherStem(MaterialColor.CRIMSON_STEM)); + public static final Block STRIPPED_CRIMSON_STEM = register("stripped_crimson_stem", netherStem(MaterialColor.CRIMSON_STEM)); + public static final Block CRIMSON_HYPHAE = register( + "crimson_hyphae", + new RotatedPillarBlock(BlockBehaviour.Properties.of(Material.NETHER_WOOD, MaterialColor.CRIMSON_HYPHAE).strength(2.0F).sound(SoundType.STEM)) + ); + public static final Block STRIPPED_CRIMSON_HYPHAE = register( + "stripped_crimson_hyphae", + new RotatedPillarBlock(BlockBehaviour.Properties.of(Material.NETHER_WOOD, MaterialColor.CRIMSON_HYPHAE).strength(2.0F).sound(SoundType.STEM)) + ); + public static final Block CRIMSON_NYLIUM = register( + "crimson_nylium", + new NyliumBlock( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.CRIMSON_NYLIUM) + .requiresCorrectToolForDrops() + .strength(0.4F) + .sound(SoundType.NYLIUM) + .randomTicks() + ) + ); + public static final Block CRIMSON_FUNGUS = register( + "crimson_fungus", + new FungusBlock( + BlockBehaviour.Properties.of(Material.PLANT, MaterialColor.NETHER).instabreak().noCollission().sound(SoundType.FUNGUS), () -> Features.CRIMSON_FUNGI_PLANTED + ) + ); + public static final Block SHROOMLIGHT = register( + "shroomlight", + new Block(BlockBehaviour.Properties.of(Material.GRASS, MaterialColor.COLOR_RED).strength(1.0F).sound(SoundType.SHROOMLIGHT).lightLevel(blockStatex -> 15)) + ); + public static final Block WEEPING_VINES = register( + "weeping_vines", + new WeepingVinesBlock( + BlockBehaviour.Properties.of(Material.PLANT, MaterialColor.NETHER).randomTicks().noCollission().instabreak().sound(SoundType.WEEPING_VINES) + ) + ); + public static final Block WEEPING_VINES_PLANT = register( + "weeping_vines_plant", + new WeepingVinesPlantBlock(BlockBehaviour.Properties.of(Material.PLANT, MaterialColor.NETHER).noCollission().instabreak().sound(SoundType.WEEPING_VINES)) + ); + public static final Block TWISTING_VINES = register( + "twisting_vines", + new TwistingVinesBlock( + BlockBehaviour.Properties.of(Material.PLANT, MaterialColor.COLOR_CYAN).randomTicks().noCollission().instabreak().sound(SoundType.WEEPING_VINES) + ) + ); + public static final Block TWISTING_VINES_PLANT = register( + "twisting_vines_plant", + new TwistingVinesPlantBlock(BlockBehaviour.Properties.of(Material.PLANT, MaterialColor.COLOR_CYAN).noCollission().instabreak().sound(SoundType.WEEPING_VINES)) + ); + public static final Block CRIMSON_ROOTS = register( + "crimson_roots", + new RootsBlock(BlockBehaviour.Properties.of(Material.REPLACEABLE_FIREPROOF_PLANT, MaterialColor.NETHER).noCollission().instabreak().sound(SoundType.ROOTS)) + ); + public static final Block CRIMSON_PLANKS = register( + "crimson_planks", new Block(BlockBehaviour.Properties.of(Material.NETHER_WOOD, MaterialColor.CRIMSON_STEM).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block WARPED_PLANKS = register( + "warped_planks", new Block(BlockBehaviour.Properties.of(Material.NETHER_WOOD, MaterialColor.WARPED_STEM).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block CRIMSON_SLAB = register( + "crimson_slab", + new SlabBlock(BlockBehaviour.Properties.of(Material.NETHER_WOOD, CRIMSON_PLANKS.defaultMaterialColor()).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block WARPED_SLAB = register( + "warped_slab", + new SlabBlock(BlockBehaviour.Properties.of(Material.NETHER_WOOD, WARPED_PLANKS.defaultMaterialColor()).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block CRIMSON_PRESSURE_PLATE = register( + "crimson_pressure_plate", + new PressurePlateBlock( + PressurePlateBlock.Sensitivity.EVERYTHING, + BlockBehaviour.Properties.of(Material.NETHER_WOOD, CRIMSON_PLANKS.defaultMaterialColor()).noCollission().strength(0.5F).sound(SoundType.WOOD) + ) + ); + public static final Block WARPED_PRESSURE_PLATE = register( + "warped_pressure_plate", + new PressurePlateBlock( + PressurePlateBlock.Sensitivity.EVERYTHING, + BlockBehaviour.Properties.of(Material.NETHER_WOOD, WARPED_PLANKS.defaultMaterialColor()).noCollission().strength(0.5F).sound(SoundType.WOOD) + ) + ); + public static final Block CRIMSON_FENCE = register( + "crimson_fence", + new FenceBlock(BlockBehaviour.Properties.of(Material.NETHER_WOOD, CRIMSON_PLANKS.defaultMaterialColor()).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block WARPED_FENCE = register( + "warped_fence", + new FenceBlock(BlockBehaviour.Properties.of(Material.NETHER_WOOD, WARPED_PLANKS.defaultMaterialColor()).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block CRIMSON_TRAPDOOR = register( + "crimson_trapdoor", + new TrapDoorBlock( + BlockBehaviour.Properties.of(Material.NETHER_WOOD, CRIMSON_PLANKS.defaultMaterialColor()) + .strength(3.0F) + .sound(SoundType.WOOD) + .noOcclusion() + .isValidSpawn(Blocks::never) + ) + ); + public static final Block WARPED_TRAPDOOR = register( + "warped_trapdoor", + new TrapDoorBlock( + BlockBehaviour.Properties.of(Material.NETHER_WOOD, WARPED_PLANKS.defaultMaterialColor()) + .strength(3.0F) + .sound(SoundType.WOOD) + .noOcclusion() + .isValidSpawn(Blocks::never) + ) + ); + public static final Block CRIMSON_FENCE_GATE = register( + "crimson_fence_gate", + new FenceGateBlock(BlockBehaviour.Properties.of(Material.NETHER_WOOD, CRIMSON_PLANKS.defaultMaterialColor()).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block WARPED_FENCE_GATE = register( + "warped_fence_gate", + new FenceGateBlock(BlockBehaviour.Properties.of(Material.NETHER_WOOD, WARPED_PLANKS.defaultMaterialColor()).strength(2.0F, 3.0F).sound(SoundType.WOOD)) + ); + public static final Block CRIMSON_STAIRS = register( + "crimson_stairs", new StairBlock(CRIMSON_PLANKS.defaultBlockState(), BlockBehaviour.Properties.copy(CRIMSON_PLANKS)) + ); + public static final Block WARPED_STAIRS = register( + "warped_stairs", new StairBlock(WARPED_PLANKS.defaultBlockState(), BlockBehaviour.Properties.copy(WARPED_PLANKS)) + ); + public static final Block CRIMSON_BUTTON = register( + "crimson_button", new WoodButtonBlock(BlockBehaviour.Properties.of(Material.DECORATION).noCollission().strength(0.5F).sound(SoundType.WOOD)) + ); + public static final Block WARPED_BUTTON = register( + "warped_button", new WoodButtonBlock(BlockBehaviour.Properties.of(Material.DECORATION).noCollission().strength(0.5F).sound(SoundType.WOOD)) + ); + public static final Block CRIMSON_DOOR = register( + "crimson_door", + new DoorBlock(BlockBehaviour.Properties.of(Material.NETHER_WOOD, CRIMSON_PLANKS.defaultMaterialColor()).strength(3.0F).sound(SoundType.WOOD).noOcclusion()) + ); + public static final Block WARPED_DOOR = register( + "warped_door", + new DoorBlock(BlockBehaviour.Properties.of(Material.NETHER_WOOD, WARPED_PLANKS.defaultMaterialColor()).strength(3.0F).sound(SoundType.WOOD).noOcclusion()) + ); + public static final Block CRIMSON_SIGN = register( + "crimson_sign", + new StandingSignBlock( + BlockBehaviour.Properties.of(Material.NETHER_WOOD, CRIMSON_PLANKS.defaultMaterialColor()).noCollission().strength(1.0F).sound(SoundType.WOOD), + WoodType.CRIMSON + ) + ); + public static final Block WARPED_SIGN = register( + "warped_sign", + new StandingSignBlock( + BlockBehaviour.Properties.of(Material.NETHER_WOOD, WARPED_PLANKS.defaultMaterialColor()).noCollission().strength(1.0F).sound(SoundType.WOOD), + WoodType.WARPED + ) + ); + public static final Block CRIMSON_WALL_SIGN = register( + "crimson_wall_sign", + new WallSignBlock( + BlockBehaviour.Properties.of(Material.NETHER_WOOD, CRIMSON_PLANKS.defaultMaterialColor()) + .noCollission() + .strength(1.0F) + .sound(SoundType.WOOD) + .dropsLike(CRIMSON_SIGN), + WoodType.CRIMSON + ) + ); + public static final Block WARPED_WALL_SIGN = register( + "warped_wall_sign", + new WallSignBlock( + BlockBehaviour.Properties.of(Material.NETHER_WOOD, WARPED_PLANKS.defaultMaterialColor()) + .noCollission() + .strength(1.0F) + .sound(SoundType.WOOD) + .dropsLike(WARPED_SIGN), + WoodType.WARPED + ) + ); + public static final Block STRUCTURE_BLOCK = register( + "structure_block", + new StructureBlock( + BlockBehaviour.Properties.of(Material.METAL, MaterialColor.COLOR_LIGHT_GRAY).requiresCorrectToolForDrops().strength(-1.0F, 3600000.0F).noDrops() + ) + ); + public static final Block JIGSAW = register( + "jigsaw", + new JigsawBlock( + BlockBehaviour.Properties.of(Material.METAL, MaterialColor.COLOR_LIGHT_GRAY).requiresCorrectToolForDrops().strength(-1.0F, 3600000.0F).noDrops() + ) + ); + public static final Block COMPOSTER = register( + "composter", new ComposterBlock(BlockBehaviour.Properties.of(Material.WOOD).strength(0.6F).sound(SoundType.WOOD)) + ); + public static final Block TARGET = register( + "target", new TargetBlock(BlockBehaviour.Properties.of(Material.GRASS, MaterialColor.QUARTZ).strength(0.5F).sound(SoundType.GRASS)) + ); + public static final Block BEE_NEST = register( + "bee_nest", new BeehiveBlock(BlockBehaviour.Properties.of(Material.WOOD, MaterialColor.COLOR_YELLOW).strength(0.3F).sound(SoundType.WOOD)) + ); + public static final Block BEEHIVE = register("beehive", new BeehiveBlock(BlockBehaviour.Properties.of(Material.WOOD).strength(0.6F).sound(SoundType.WOOD))); + public static final Block HONEY_BLOCK = register( + "honey_block", + new HoneyBlock( + BlockBehaviour.Properties.of(Material.CLAY, MaterialColor.COLOR_ORANGE).speedFactor(0.4F).jumpFactor(0.5F).noOcclusion().sound(SoundType.HONEY_BLOCK) + ) + ); + public static final Block HONEYCOMB_BLOCK = register( + "honeycomb_block", new Block(BlockBehaviour.Properties.of(Material.CLAY, MaterialColor.COLOR_ORANGE).strength(0.6F).sound(SoundType.CORAL_BLOCK)) + ); + public static final Block NETHERITE_BLOCK = register( + "netherite_block", + new Block( + BlockBehaviour.Properties.of(Material.METAL, MaterialColor.COLOR_BLACK) + .requiresCorrectToolForDrops() + .strength(50.0F, 1200.0F) + .sound(SoundType.NETHERITE_BLOCK) + ) + ); + public static final Block ANCIENT_DEBRIS = register( + "ancient_debris", + new Block( + BlockBehaviour.Properties.of(Material.METAL, MaterialColor.COLOR_BLACK) + .requiresCorrectToolForDrops() + .strength(30.0F, 1200.0F) + .sound(SoundType.ANCIENT_DEBRIS) + ) + ); + public static final Block CRYING_OBSIDIAN = register( + "crying_obsidian", + new CryingObsidianBlock( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_BLACK).requiresCorrectToolForDrops().strength(50.0F, 1200.0F).lightLevel(blockStatex -> 10) + ) + ); + public static final Block RESPAWN_ANCHOR = register( + "respawn_anchor", + new RespawnAnchorBlock( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_BLACK) + .requiresCorrectToolForDrops() + .strength(50.0F, 1200.0F) + .lightLevel(blockStatex -> RespawnAnchorBlock.getScaledChargeLevel(blockStatex, 15)) + ) + ); + public static final Block POTTED_CRIMSON_FUNGUS = register( + "potted_crimson_fungus", new FlowerPotBlock(CRIMSON_FUNGUS, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_WARPED_FUNGUS = register( + "potted_warped_fungus", new FlowerPotBlock(WARPED_FUNGUS, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_CRIMSON_ROOTS = register( + "potted_crimson_roots", new FlowerPotBlock(CRIMSON_ROOTS, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_WARPED_ROOTS = register( + "potted_warped_roots", new FlowerPotBlock(WARPED_ROOTS, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block LODESTONE = register( + "lodestone", new Block(BlockBehaviour.Properties.of(Material.HEAVY_METAL).requiresCorrectToolForDrops().strength(3.5F).sound(SoundType.LODESTONE)) + ); + public static final Block BLACKSTONE = register( + "blackstone", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_BLACK).requiresCorrectToolForDrops().strength(1.5F, 6.0F)) + ); + public static final Block BLACKSTONE_STAIRS = register( + "blackstone_stairs", new StairBlock(BLACKSTONE.defaultBlockState(), BlockBehaviour.Properties.copy(BLACKSTONE)) + ); + public static final Block BLACKSTONE_WALL = register("blackstone_wall", new WallBlock(BlockBehaviour.Properties.copy(BLACKSTONE))); + public static final Block BLACKSTONE_SLAB = register("blackstone_slab", new SlabBlock(BlockBehaviour.Properties.copy(BLACKSTONE).strength(2.0F, 6.0F))); + public static final Block POLISHED_BLACKSTONE = register("polished_blackstone", new Block(BlockBehaviour.Properties.copy(BLACKSTONE).strength(2.0F, 6.0F))); + public static final Block POLISHED_BLACKSTONE_BRICKS = register( + "polished_blackstone_bricks", new Block(BlockBehaviour.Properties.copy(POLISHED_BLACKSTONE).strength(1.5F, 6.0F)) + ); + public static final Block CRACKED_POLISHED_BLACKSTONE_BRICKS = register( + "cracked_polished_blackstone_bricks", new Block(BlockBehaviour.Properties.copy(POLISHED_BLACKSTONE_BRICKS)) + ); + public static final Block CHISELED_POLISHED_BLACKSTONE = register( + "chiseled_polished_blackstone", new Block(BlockBehaviour.Properties.copy(POLISHED_BLACKSTONE).strength(1.5F, 6.0F)) + ); + public static final Block POLISHED_BLACKSTONE_BRICK_SLAB = register( + "polished_blackstone_brick_slab", new SlabBlock(BlockBehaviour.Properties.copy(POLISHED_BLACKSTONE_BRICKS).strength(2.0F, 6.0F)) + ); + public static final Block POLISHED_BLACKSTONE_BRICK_STAIRS = register( + "polished_blackstone_brick_stairs", + new StairBlock(POLISHED_BLACKSTONE_BRICKS.defaultBlockState(), BlockBehaviour.Properties.copy(POLISHED_BLACKSTONE_BRICKS)) + ); + public static final Block POLISHED_BLACKSTONE_BRICK_WALL = register( + "polished_blackstone_brick_wall", new WallBlock(BlockBehaviour.Properties.copy(POLISHED_BLACKSTONE_BRICKS)) + ); + public static final Block GILDED_BLACKSTONE = register( + "gilded_blackstone", new Block(BlockBehaviour.Properties.copy(BLACKSTONE).sound(SoundType.GILDED_BLACKSTONE)) + ); + public static final Block POLISHED_BLACKSTONE_STAIRS = register( + "polished_blackstone_stairs", new StairBlock(POLISHED_BLACKSTONE.defaultBlockState(), BlockBehaviour.Properties.copy(POLISHED_BLACKSTONE)) + ); + public static final Block POLISHED_BLACKSTONE_SLAB = register("polished_blackstone_slab", new SlabBlock(BlockBehaviour.Properties.copy(POLISHED_BLACKSTONE))); + public static final Block POLISHED_BLACKSTONE_PRESSURE_PLATE = register( + "polished_blackstone_pressure_plate", + new PressurePlateBlock( + PressurePlateBlock.Sensitivity.MOBS, + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_BLACK).requiresCorrectToolForDrops().noCollission().strength(0.5F) + ) + ); + public static final Block POLISHED_BLACKSTONE_BUTTON = register( + "polished_blackstone_button", new StoneButtonBlock(BlockBehaviour.Properties.of(Material.DECORATION).noCollission().strength(0.5F)) + ); + public static final Block POLISHED_BLACKSTONE_WALL = register("polished_blackstone_wall", new WallBlock(BlockBehaviour.Properties.copy(POLISHED_BLACKSTONE))); + public static final Block CHISELED_NETHER_BRICKS = register( + "chiseled_nether_bricks", + new Block( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.NETHER).requiresCorrectToolForDrops().strength(2.0F, 6.0F).sound(SoundType.NETHER_BRICKS) + ) + ); + public static final Block CRACKED_NETHER_BRICKS = register( + "cracked_nether_bricks", + new Block( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.NETHER).requiresCorrectToolForDrops().strength(2.0F, 6.0F).sound(SoundType.NETHER_BRICKS) + ) + ); + public static final Block QUARTZ_BRICKS = register("quartz_bricks", new Block(BlockBehaviour.Properties.copy(QUARTZ_BLOCK))); + public static final Block CANDLE = register( + "candle", + new CandleBlock( + BlockBehaviour.Properties.of(Material.DECORATION, MaterialColor.SAND) + .noOcclusion() + .strength(0.1F) + .sound(SoundType.CANDLE) + .lightLevel(CandleBlock.LIGHT_EMISSION) + ) + ); + public static final Block WHITE_CANDLE = register( + "white_candle", + new CandleBlock( + BlockBehaviour.Properties.of(Material.DECORATION, MaterialColor.WOOL) + .noOcclusion() + .strength(0.1F) + .sound(SoundType.CANDLE) + .lightLevel(CandleBlock.LIGHT_EMISSION) + ) + ); + public static final Block ORANGE_CANDLE = register( + "orange_candle", + new CandleBlock( + BlockBehaviour.Properties.of(Material.DECORATION, MaterialColor.COLOR_ORANGE) + .noOcclusion() + .strength(0.1F) + .sound(SoundType.CANDLE) + .lightLevel(CandleBlock.LIGHT_EMISSION) + ) + ); + public static final Block MAGENTA_CANDLE = register( + "magenta_candle", + new CandleBlock( + BlockBehaviour.Properties.of(Material.DECORATION, MaterialColor.COLOR_MAGENTA) + .noOcclusion() + .strength(0.1F) + .sound(SoundType.CANDLE) + .lightLevel(CandleBlock.LIGHT_EMISSION) + ) + ); + public static final Block LIGHT_BLUE_CANDLE = register( + "light_blue_candle", + new CandleBlock( + BlockBehaviour.Properties.of(Material.DECORATION, MaterialColor.COLOR_LIGHT_BLUE) + .noOcclusion() + .strength(0.1F) + .sound(SoundType.CANDLE) + .lightLevel(CandleBlock.LIGHT_EMISSION) + ) + ); + public static final Block YELLOW_CANDLE = register( + "yellow_candle", + new CandleBlock( + BlockBehaviour.Properties.of(Material.DECORATION, MaterialColor.COLOR_YELLOW) + .noOcclusion() + .strength(0.1F) + .sound(SoundType.CANDLE) + .lightLevel(CandleBlock.LIGHT_EMISSION) + ) + ); + public static final Block LIME_CANDLE = register( + "lime_candle", + new CandleBlock( + BlockBehaviour.Properties.of(Material.DECORATION, MaterialColor.COLOR_LIGHT_GREEN) + .noOcclusion() + .strength(0.1F) + .sound(SoundType.CANDLE) + .lightLevel(CandleBlock.LIGHT_EMISSION) + ) + ); + public static final Block PINK_CANDLE = register( + "pink_candle", + new CandleBlock( + BlockBehaviour.Properties.of(Material.DECORATION, MaterialColor.COLOR_PINK) + .noOcclusion() + .strength(0.1F) + .sound(SoundType.CANDLE) + .lightLevel(CandleBlock.LIGHT_EMISSION) + ) + ); + public static final Block GRAY_CANDLE = register( + "gray_candle", + new CandleBlock( + BlockBehaviour.Properties.of(Material.DECORATION, MaterialColor.COLOR_GRAY) + .noOcclusion() + .strength(0.1F) + .sound(SoundType.CANDLE) + .lightLevel(CandleBlock.LIGHT_EMISSION) + ) + ); + public static final Block LIGHT_GRAY_CANDLE = register( + "light_gray_candle", + new CandleBlock( + BlockBehaviour.Properties.of(Material.DECORATION, MaterialColor.COLOR_LIGHT_GRAY) + .noOcclusion() + .strength(0.1F) + .sound(SoundType.CANDLE) + .lightLevel(CandleBlock.LIGHT_EMISSION) + ) + ); + public static final Block CYAN_CANDLE = register( + "cyan_candle", + new CandleBlock( + BlockBehaviour.Properties.of(Material.DECORATION, MaterialColor.COLOR_CYAN) + .noOcclusion() + .strength(0.1F) + .sound(SoundType.CANDLE) + .lightLevel(CandleBlock.LIGHT_EMISSION) + ) + ); + public static final Block PURPLE_CANDLE = register( + "purple_candle", + new CandleBlock( + BlockBehaviour.Properties.of(Material.DECORATION, MaterialColor.COLOR_PURPLE) + .noOcclusion() + .strength(0.1F) + .sound(SoundType.CANDLE) + .lightLevel(CandleBlock.LIGHT_EMISSION) + ) + ); + public static final Block BLUE_CANDLE = register( + "blue_candle", + new CandleBlock( + BlockBehaviour.Properties.of(Material.DECORATION, MaterialColor.COLOR_BLUE) + .noOcclusion() + .strength(0.1F) + .sound(SoundType.CANDLE) + .lightLevel(CandleBlock.LIGHT_EMISSION) + ) + ); + public static final Block BROWN_CANDLE = register( + "brown_candle", + new CandleBlock( + BlockBehaviour.Properties.of(Material.DECORATION, MaterialColor.COLOR_BROWN) + .noOcclusion() + .strength(0.1F) + .sound(SoundType.CANDLE) + .lightLevel(CandleBlock.LIGHT_EMISSION) + ) + ); + public static final Block GREEN_CANDLE = register( + "green_candle", + new CandleBlock( + BlockBehaviour.Properties.of(Material.DECORATION, MaterialColor.COLOR_GREEN) + .noOcclusion() + .strength(0.1F) + .sound(SoundType.CANDLE) + .lightLevel(CandleBlock.LIGHT_EMISSION) + ) + ); + public static final Block RED_CANDLE = register( + "red_candle", + new CandleBlock( + BlockBehaviour.Properties.of(Material.DECORATION, MaterialColor.COLOR_RED) + .noOcclusion() + .strength(0.1F) + .sound(SoundType.CANDLE) + .lightLevel(CandleBlock.LIGHT_EMISSION) + ) + ); + public static final Block BLACK_CANDLE = register( + "black_candle", + new CandleBlock( + BlockBehaviour.Properties.of(Material.DECORATION, MaterialColor.COLOR_BLACK) + .noOcclusion() + .strength(0.1F) + .sound(SoundType.CANDLE) + .lightLevel(CandleBlock.LIGHT_EMISSION) + ) + ); + public static final Block CANDLE_CAKE = register( + "candle_cake", new CandleCakeBlock(CANDLE, BlockBehaviour.Properties.copy(CAKE).lightLevel(litBlockEmission(3))) + ); + public static final Block WHITE_CANDLE_CAKE = register("white_candle_cake", new CandleCakeBlock(WHITE_CANDLE, BlockBehaviour.Properties.copy(CANDLE_CAKE))); + public static final Block ORANGE_CANDLE_CAKE = register("orange_candle_cake", new CandleCakeBlock(ORANGE_CANDLE, BlockBehaviour.Properties.copy(CANDLE_CAKE))); + public static final Block MAGENTA_CANDLE_CAKE = register( + "magenta_candle_cake", new CandleCakeBlock(MAGENTA_CANDLE, BlockBehaviour.Properties.copy(CANDLE_CAKE)) + ); + public static final Block LIGHT_BLUE_CANDLE_CAKE = register( + "light_blue_candle_cake", new CandleCakeBlock(LIGHT_BLUE_CANDLE, BlockBehaviour.Properties.copy(CANDLE_CAKE)) + ); + public static final Block YELLOW_CANDLE_CAKE = register("yellow_candle_cake", new CandleCakeBlock(YELLOW_CANDLE, BlockBehaviour.Properties.copy(CANDLE_CAKE))); + public static final Block LIME_CANDLE_CAKE = register("lime_candle_cake", new CandleCakeBlock(LIME_CANDLE, BlockBehaviour.Properties.copy(CANDLE_CAKE))); + public static final Block PINK_CANDLE_CAKE = register("pink_candle_cake", new CandleCakeBlock(PINK_CANDLE, BlockBehaviour.Properties.copy(CANDLE_CAKE))); + public static final Block GRAY_CANDLE_CAKE = register("gray_candle_cake", new CandleCakeBlock(GRAY_CANDLE, BlockBehaviour.Properties.copy(CANDLE_CAKE))); + public static final Block LIGHT_GRAY_CANDLE_CAKE = register( + "light_gray_candle_cake", new CandleCakeBlock(LIGHT_GRAY_CANDLE, BlockBehaviour.Properties.copy(CANDLE_CAKE)) + ); + public static final Block CYAN_CANDLE_CAKE = register("cyan_candle_cake", new CandleCakeBlock(CYAN_CANDLE, BlockBehaviour.Properties.copy(CANDLE_CAKE))); + public static final Block PURPLE_CANDLE_CAKE = register("purple_candle_cake", new CandleCakeBlock(PURPLE_CANDLE, BlockBehaviour.Properties.copy(CANDLE_CAKE))); + public static final Block BLUE_CANDLE_CAKE = register("blue_candle_cake", new CandleCakeBlock(BLUE_CANDLE, BlockBehaviour.Properties.copy(CANDLE_CAKE))); + public static final Block BROWN_CANDLE_CAKE = register("brown_candle_cake", new CandleCakeBlock(BROWN_CANDLE, BlockBehaviour.Properties.copy(CANDLE_CAKE))); + public static final Block GREEN_CANDLE_CAKE = register("green_candle_cake", new CandleCakeBlock(GREEN_CANDLE, BlockBehaviour.Properties.copy(CANDLE_CAKE))); + public static final Block RED_CANDLE_CAKE = register("red_candle_cake", new CandleCakeBlock(RED_CANDLE, BlockBehaviour.Properties.copy(CANDLE_CAKE))); + public static final Block BLACK_CANDLE_CAKE = register("black_candle_cake", new CandleCakeBlock(BLACK_CANDLE, BlockBehaviour.Properties.copy(CANDLE_CAKE))); + public static final Block AMETHYST_BLOCK = register( + "amethyst_block", + new AmethystBlock( + BlockBehaviour.Properties.of(Material.AMETHYST, MaterialColor.COLOR_PURPLE).strength(1.5F).sound(SoundType.AMETHYST).requiresCorrectToolForDrops() + ) + ); + public static final Block BUDDING_AMETHYST = register( + "budding_amethyst", + new BuddingAmethystBlock(BlockBehaviour.Properties.of(Material.AMETHYST).randomTicks().strength(1.5F).sound(SoundType.AMETHYST).requiresCorrectToolForDrops()) + ); + public static final Block AMETHYST_CLUSTER = register( + "amethyst_cluster", + new AmethystClusterBlock( + 7, + 3, + BlockBehaviour.Properties.of(Material.AMETHYST).noOcclusion().randomTicks().sound(SoundType.AMETHYST_CLUSTER).strength(1.5F).lightLevel(blockStatex -> 5) + ) + ); + public static final Block LARGE_AMETHYST_BUD = register( + "large_amethyst_bud", + new AmethystClusterBlock(5, 3, BlockBehaviour.Properties.copy(AMETHYST_CLUSTER).sound(SoundType.MEDIUM_AMETHYST_BUD).lightLevel(blockStatex -> 4)) + ); + public static final Block MEDIUM_AMETHYST_BUD = register( + "medium_amethyst_bud", + new AmethystClusterBlock(4, 3, BlockBehaviour.Properties.copy(AMETHYST_CLUSTER).sound(SoundType.LARGE_AMETHYST_BUD).lightLevel(blockStatex -> 2)) + ); + public static final Block SMALL_AMETHYST_BUD = register( + "small_amethyst_bud", + new AmethystClusterBlock(3, 4, BlockBehaviour.Properties.copy(AMETHYST_CLUSTER).sound(SoundType.SMALL_AMETHYST_BUD).lightLevel(blockStatex -> 1)) + ); + public static final Block TUFF = register( + "tuff", + new Block( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.TERRACOTTA_GRAY).sound(SoundType.TUFF).requiresCorrectToolForDrops().strength(1.5F, 6.0F) + ) + ); + public static final Block CALCITE = register( + "calcite", + new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.TERRACOTTA_WHITE).sound(SoundType.CALCITE).requiresCorrectToolForDrops().strength(0.75F)) + ); + public static final Block TINTED_GLASS = register( + "tinted_glass", + new TintedGlassBlock( + BlockBehaviour.Properties.copy(GLASS) + .color(MaterialColor.COLOR_GRAY) + .noOcclusion() + .isValidSpawn(Blocks::never) + .isRedstoneConductor(Blocks::never) + .isSuffocating(Blocks::never) + .isViewBlocking(Blocks::never) + ) + ); + public static final Block POWDER_SNOW = register( + "powder_snow", new PowderSnowBlock(BlockBehaviour.Properties.of(Material.POWDER_SNOW).strength(0.25F).sound(SoundType.POWDER_SNOW).dynamicShape()) + ); + public static final Block SCULK_SENSOR = register( + "sculk_sensor", + new SculkSensorBlock( + BlockBehaviour.Properties.of(Material.SCULK, MaterialColor.COLOR_CYAN) + .strength(1.5F) + .sound(SoundType.SCULK_SENSOR) + .lightLevel(blockStatex -> 1) + .emissiveRendering((blockStatex, blockGetter, blockPos) -> SculkSensorBlock.getPhase(blockStatex) == SculkSensorPhase.ACTIVE), + 8 + ) + ); + public static final Block OXIDIZED_COPPER = register( + "oxidized_copper", + new WeatheringCopperFullBlock( + WeatheringCopper.WeatherState.OXIDIZED, + BlockBehaviour.Properties.of(Material.METAL, MaterialColor.WARPED_NYLIUM).requiresCorrectToolForDrops().strength(3.0F, 6.0F).sound(SoundType.COPPER) + ) + ); + public static final Block WEATHERED_COPPER = register( + "weathered_copper", + new WeatheringCopperFullBlock( + WeatheringCopper.WeatherState.WEATHERED, + BlockBehaviour.Properties.of(Material.METAL, MaterialColor.WARPED_STEM).requiresCorrectToolForDrops().strength(3.0F, 6.0F).sound(SoundType.COPPER) + ) + ); + public static final Block EXPOSED_COPPER = register( + "exposed_copper", + new WeatheringCopperFullBlock( + WeatheringCopper.WeatherState.EXPOSED, + BlockBehaviour.Properties.of(Material.METAL, MaterialColor.TERRACOTTA_LIGHT_GRAY).requiresCorrectToolForDrops().strength(3.0F, 6.0F).sound(SoundType.COPPER) + ) + ); + public static final Block COPPER_BLOCK = register( + "copper_block", + new WeatheringCopperFullBlock( + WeatheringCopper.WeatherState.UNAFFECTED, + BlockBehaviour.Properties.of(Material.METAL, MaterialColor.COLOR_ORANGE).requiresCorrectToolForDrops().strength(3.0F, 6.0F).sound(SoundType.COPPER) + ) + ); + public static final Block COPPER_ORE = register("copper_ore", new OreBlock(BlockBehaviour.Properties.copy(IRON_ORE))); + public static final Block DEEPSLATE_COPPER_ORE = register( + "deepslate_copper_ore", + new OreBlock(BlockBehaviour.Properties.copy(COPPER_ORE).color(MaterialColor.DEEPSLATE).strength(4.5F, 3.0F).sound(SoundType.DEEPSLATE)) + ); + public static final Block OXIDIZED_CUT_COPPER = register( + "oxidized_cut_copper", new WeatheringCopperFullBlock(WeatheringCopper.WeatherState.OXIDIZED, BlockBehaviour.Properties.copy(OXIDIZED_COPPER)) + ); + public static final Block WEATHERED_CUT_COPPER = register( + "weathered_cut_copper", new WeatheringCopperFullBlock(WeatheringCopper.WeatherState.WEATHERED, BlockBehaviour.Properties.copy(WEATHERED_COPPER)) + ); + public static final Block EXPOSED_CUT_COPPER = register( + "exposed_cut_copper", new WeatheringCopperFullBlock(WeatheringCopper.WeatherState.EXPOSED, BlockBehaviour.Properties.copy(EXPOSED_COPPER)) + ); + public static final Block CUT_COPPER = register( + "cut_copper", new WeatheringCopperFullBlock(WeatheringCopper.WeatherState.UNAFFECTED, BlockBehaviour.Properties.copy(COPPER_BLOCK)) + ); + public static final Block OXIDIZED_CUT_COPPER_STAIRS = register( + "oxidized_cut_copper_stairs", + new WeatheringCopperStairBlock( + WeatheringCopper.WeatherState.OXIDIZED, OXIDIZED_CUT_COPPER.defaultBlockState(), BlockBehaviour.Properties.copy(OXIDIZED_CUT_COPPER) + ) + ); + public static final Block WEATHERED_CUT_COPPER_STAIRS = register( + "weathered_cut_copper_stairs", + new WeatheringCopperStairBlock( + WeatheringCopper.WeatherState.WEATHERED, WEATHERED_CUT_COPPER.defaultBlockState(), BlockBehaviour.Properties.copy(WEATHERED_COPPER) + ) + ); + public static final Block EXPOSED_CUT_COPPER_STAIRS = register( + "exposed_cut_copper_stairs", + new WeatheringCopperStairBlock(WeatheringCopper.WeatherState.EXPOSED, EXPOSED_CUT_COPPER.defaultBlockState(), BlockBehaviour.Properties.copy(EXPOSED_COPPER)) + ); + public static final Block CUT_COPPER_STAIRS = register( + "cut_copper_stairs", + new WeatheringCopperStairBlock(WeatheringCopper.WeatherState.UNAFFECTED, CUT_COPPER.defaultBlockState(), BlockBehaviour.Properties.copy(COPPER_BLOCK)) + ); + public static final Block OXIDIZED_CUT_COPPER_SLAB = register( + "oxidized_cut_copper_slab", + new WeatheringCopperSlabBlock(WeatheringCopper.WeatherState.OXIDIZED, BlockBehaviour.Properties.copy(OXIDIZED_CUT_COPPER).requiresCorrectToolForDrops()) + ); + public static final Block WEATHERED_CUT_COPPER_SLAB = register( + "weathered_cut_copper_slab", + new WeatheringCopperSlabBlock(WeatheringCopper.WeatherState.WEATHERED, BlockBehaviour.Properties.copy(WEATHERED_CUT_COPPER).requiresCorrectToolForDrops()) + ); + public static final Block EXPOSED_CUT_COPPER_SLAB = register( + "exposed_cut_copper_slab", + new WeatheringCopperSlabBlock(WeatheringCopper.WeatherState.EXPOSED, BlockBehaviour.Properties.copy(EXPOSED_CUT_COPPER).requiresCorrectToolForDrops()) + ); + public static final Block CUT_COPPER_SLAB = register( + "cut_copper_slab", + new WeatheringCopperSlabBlock(WeatheringCopper.WeatherState.UNAFFECTED, BlockBehaviour.Properties.copy(CUT_COPPER).requiresCorrectToolForDrops()) + ); + public static final Block WAXED_COPPER_BLOCK = register("waxed_copper_block", new Block(BlockBehaviour.Properties.copy(COPPER_BLOCK))); + public static final Block WAXED_WEATHERED_COPPER = register("waxed_weathered_copper", new Block(BlockBehaviour.Properties.copy(WEATHERED_COPPER))); + public static final Block WAXED_EXPOSED_COPPER = register("waxed_exposed_copper", new Block(BlockBehaviour.Properties.copy(EXPOSED_COPPER))); + public static final Block WAXED_OXIDIZED_COPPER = register("waxed_oxidized_copper", new Block(BlockBehaviour.Properties.copy(OXIDIZED_COPPER))); + public static final Block WAXED_OXIDIZED_CUT_COPPER = register("waxed_oxidized_cut_copper", new Block(BlockBehaviour.Properties.copy(OXIDIZED_COPPER))); + public static final Block WAXED_WEATHERED_CUT_COPPER = register("waxed_weathered_cut_copper", new Block(BlockBehaviour.Properties.copy(WEATHERED_COPPER))); + public static final Block WAXED_EXPOSED_CUT_COPPER = register("waxed_exposed_cut_copper", new Block(BlockBehaviour.Properties.copy(EXPOSED_COPPER))); + public static final Block WAXED_CUT_COPPER = register("waxed_cut_copper", new Block(BlockBehaviour.Properties.copy(COPPER_BLOCK))); + public static final Block WAXED_OXIDIZED_CUT_COPPER_STAIRS = register( + "waxed_oxidized_cut_copper_stairs", new StairBlock(WAXED_OXIDIZED_CUT_COPPER.defaultBlockState(), BlockBehaviour.Properties.copy(OXIDIZED_COPPER)) + ); + public static final Block WAXED_WEATHERED_CUT_COPPER_STAIRS = register( + "waxed_weathered_cut_copper_stairs", new StairBlock(WAXED_WEATHERED_CUT_COPPER.defaultBlockState(), BlockBehaviour.Properties.copy(WEATHERED_COPPER)) + ); + public static final Block WAXED_EXPOSED_CUT_COPPER_STAIRS = register( + "waxed_exposed_cut_copper_stairs", new StairBlock(WAXED_EXPOSED_CUT_COPPER.defaultBlockState(), BlockBehaviour.Properties.copy(EXPOSED_COPPER)) + ); + public static final Block WAXED_CUT_COPPER_STAIRS = register( + "waxed_cut_copper_stairs", new StairBlock(WAXED_CUT_COPPER.defaultBlockState(), BlockBehaviour.Properties.copy(COPPER_BLOCK)) + ); + public static final Block WAXED_OXIDIZED_CUT_COPPER_SLAB = register( + "waxed_oxidized_cut_copper_slab", new SlabBlock(BlockBehaviour.Properties.copy(WAXED_OXIDIZED_CUT_COPPER).requiresCorrectToolForDrops()) + ); + public static final Block WAXED_WEATHERED_CUT_COPPER_SLAB = register( + "waxed_weathered_cut_copper_slab", new SlabBlock(BlockBehaviour.Properties.copy(WAXED_WEATHERED_CUT_COPPER).requiresCorrectToolForDrops()) + ); + public static final Block WAXED_EXPOSED_CUT_COPPER_SLAB = register( + "waxed_exposed_cut_copper_slab", new SlabBlock(BlockBehaviour.Properties.copy(WAXED_EXPOSED_CUT_COPPER).requiresCorrectToolForDrops()) + ); + public static final Block WAXED_CUT_COPPER_SLAB = register( + "waxed_cut_copper_slab", new SlabBlock(BlockBehaviour.Properties.copy(WAXED_CUT_COPPER).requiresCorrectToolForDrops()) + ); + public static final Block LIGHTNING_ROD = register( + "lightning_rod", + new LightningRodBlock( + BlockBehaviour.Properties.of(Material.METAL, MaterialColor.COLOR_ORANGE) + .requiresCorrectToolForDrops() + .strength(3.0F, 6.0F) + .sound(SoundType.COPPER) + .noOcclusion() + ) + ); + public static final Block POINTED_DRIPSTONE = register( + "pointed_dripstone", + new PointedDripstoneBlock( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.TERRACOTTA_BROWN) + .noOcclusion() + .sound(SoundType.POINTED_DRIPSTONE) + .randomTicks() + .strength(1.5F, 3.0F) + .dynamicShape() + ) + ); + public static final Block DRIPSTONE_BLOCK = register( + "dripstone_block", + new Block( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.TERRACOTTA_BROWN) + .sound(SoundType.DRIPSTONE_BLOCK) + .requiresCorrectToolForDrops() + .strength(1.5F, 1.0F) + ) + ); + public static final Block CAVE_VINES = register( + "cave_vines", + new CaveVinesBlock( + BlockBehaviour.Properties.of(Material.PLANT).randomTicks().noCollission().lightLevel(CaveVines.emission(14)).instabreak().sound(SoundType.CAVE_VINES) + ) + ); + public static final Block CAVE_VINES_PLANT = register( + "cave_vines_plant", + new CaveVinesPlantBlock( + BlockBehaviour.Properties.of(Material.PLANT).noCollission().lightLevel(CaveVines.emission(14)).instabreak().sound(SoundType.CAVE_VINES) + ) + ); + public static final Block SPORE_BLOSSOM = register( + "spore_blossom", new SporeBlossomBlock(BlockBehaviour.Properties.of(Material.PLANT).instabreak().noCollission().sound(SoundType.SPORE_BLOSSOM)) + ); + public static final Block AZALEA = register( + "azalea", new AzaleaBlock(BlockBehaviour.Properties.of(Material.PLANT).instabreak().sound(SoundType.AZALEA).noOcclusion()) + ); + public static final Block FLOWERING_AZALEA = register( + "flowering_azalea", new AzaleaBlock(BlockBehaviour.Properties.of(Material.PLANT).instabreak().sound(SoundType.FLOWERING_AZALEA).noOcclusion()) + ); + public static final Block MOSS_CARPET = register( + "moss_carpet", new CarpetBlock(BlockBehaviour.Properties.of(Material.PLANT, MaterialColor.COLOR_GREEN).strength(0.1F).sound(SoundType.MOSS_CARPET)) + ); + public static final Block MOSS_BLOCK = register( + "moss_block", new MossBlock(BlockBehaviour.Properties.of(Material.MOSS, MaterialColor.COLOR_GREEN).strength(0.1F).sound(SoundType.MOSS)) + ); + public static final Block BIG_DRIPLEAF = register( + "big_dripleaf", new BigDripleafBlock(BlockBehaviour.Properties.of(Material.PLANT).strength(0.1F).sound(SoundType.BIG_DRIPLEAF)) + ); + public static final Block BIG_DRIPLEAF_STEM = register( + "big_dripleaf_stem", new BigDripleafStemBlock(BlockBehaviour.Properties.of(Material.PLANT).noCollission().strength(0.1F).sound(SoundType.BIG_DRIPLEAF)) + ); + public static final Block SMALL_DRIPLEAF = register( + "small_dripleaf", new SmallDripleafBlock(BlockBehaviour.Properties.of(Material.PLANT).noCollission().instabreak().sound(SoundType.SMALL_DRIPLEAF)) + ); + public static final Block HANGING_ROOTS = register( + "hanging_roots", + new HangingRootsBlock(BlockBehaviour.Properties.of(Material.REPLACEABLE_PLANT, MaterialColor.DIRT).noCollission().instabreak().sound(SoundType.HANGING_ROOTS)) + ); + public static final Block ROOTED_DIRT = register( + "rooted_dirt", new RootedDirtBlock(BlockBehaviour.Properties.of(Material.DIRT, MaterialColor.DIRT).strength(0.5F).sound(SoundType.ROOTED_DIRT)) + ); + public static final Block DEEPSLATE = register( + "deepslate", + new RotatedPillarBlock( + BlockBehaviour.Properties.of(Material.STONE, MaterialColor.DEEPSLATE).requiresCorrectToolForDrops().strength(3.0F, 6.0F).sound(SoundType.DEEPSLATE) + ) + ); + public static final Block COBBLED_DEEPSLATE = register("cobbled_deepslate", new Block(BlockBehaviour.Properties.copy(DEEPSLATE).strength(3.5F, 6.0F))); + public static final Block COBBLED_DEEPSLATE_STAIRS = register( + "cobbled_deepslate_stairs", new StairBlock(COBBLED_DEEPSLATE.defaultBlockState(), BlockBehaviour.Properties.copy(COBBLED_DEEPSLATE)) + ); + public static final Block COBBLED_DEEPSLATE_SLAB = register("cobbled_deepslate_slab", new SlabBlock(BlockBehaviour.Properties.copy(COBBLED_DEEPSLATE))); + public static final Block COBBLED_DEEPSLATE_WALL = register("cobbled_deepslate_wall", new WallBlock(BlockBehaviour.Properties.copy(COBBLED_DEEPSLATE))); + public static final Block POLISHED_DEEPSLATE = register( + "polished_deepslate", new Block(BlockBehaviour.Properties.copy(COBBLED_DEEPSLATE).sound(SoundType.POLISHED_DEEPSLATE)) + ); + public static final Block POLISHED_DEEPSLATE_STAIRS = register( + "polished_deepslate_stairs", new StairBlock(POLISHED_DEEPSLATE.defaultBlockState(), BlockBehaviour.Properties.copy(POLISHED_DEEPSLATE)) + ); + public static final Block POLISHED_DEEPSLATE_SLAB = register("polished_deepslate_slab", new SlabBlock(BlockBehaviour.Properties.copy(POLISHED_DEEPSLATE))); + public static final Block POLISHED_DEEPSLATE_WALL = register("polished_deepslate_wall", new WallBlock(BlockBehaviour.Properties.copy(POLISHED_DEEPSLATE))); + public static final Block DEEPSLATE_TILES = register( + "deepslate_tiles", new Block(BlockBehaviour.Properties.copy(COBBLED_DEEPSLATE).sound(SoundType.DEEPSLATE_TILES)) + ); + public static final Block DEEPSLATE_TILE_STAIRS = register( + "deepslate_tile_stairs", new StairBlock(DEEPSLATE_TILES.defaultBlockState(), BlockBehaviour.Properties.copy(DEEPSLATE_TILES)) + ); + public static final Block DEEPSLATE_TILE_SLAB = register("deepslate_tile_slab", new SlabBlock(BlockBehaviour.Properties.copy(DEEPSLATE_TILES))); + public static final Block DEEPSLATE_TILE_WALL = register("deepslate_tile_wall", new WallBlock(BlockBehaviour.Properties.copy(DEEPSLATE_TILES))); + public static final Block DEEPSLATE_BRICKS = register( + "deepslate_bricks", new Block(BlockBehaviour.Properties.copy(COBBLED_DEEPSLATE).sound(SoundType.DEEPSLATE_BRICKS)) + ); + public static final Block DEEPSLATE_BRICK_STAIRS = register( + "deepslate_brick_stairs", new StairBlock(DEEPSLATE_BRICKS.defaultBlockState(), BlockBehaviour.Properties.copy(DEEPSLATE_BRICKS)) + ); + public static final Block DEEPSLATE_BRICK_SLAB = register("deepslate_brick_slab", new SlabBlock(BlockBehaviour.Properties.copy(DEEPSLATE_BRICKS))); + public static final Block DEEPSLATE_BRICK_WALL = register("deepslate_brick_wall", new WallBlock(BlockBehaviour.Properties.copy(DEEPSLATE_BRICKS))); + public static final Block CHISELED_DEEPSLATE = register( + "chiseled_deepslate", new Block(BlockBehaviour.Properties.copy(COBBLED_DEEPSLATE).sound(SoundType.DEEPSLATE_BRICKS)) + ); + public static final Block CRACKED_DEEPSLATE_BRICKS = register("cracked_deepslate_bricks", new Block(BlockBehaviour.Properties.copy(DEEPSLATE_BRICKS))); + public static final Block CRACKED_DEEPSLATE_TILES = register("cracked_deepslate_tiles", new Block(BlockBehaviour.Properties.copy(DEEPSLATE_TILES))); + public static final Block INFESTED_DEEPSLATE = register( + "infested_deepslate", + new InfestedRotatedPillarBlock(DEEPSLATE, BlockBehaviour.Properties.of(Material.CLAY, MaterialColor.DEEPSLATE).sound(SoundType.DEEPSLATE)) + ); + public static final Block SMOOTH_BASALT = register("smooth_basalt", new Block(BlockBehaviour.Properties.copy(BASALT))); + public static final Block RAW_IRON_BLOCK = register( + "raw_iron_block", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.RAW_IRON).requiresCorrectToolForDrops().strength(5.0F, 6.0F)) + ); + public static final Block RAW_COPPER_BLOCK = register( + "raw_copper_block", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.COLOR_ORANGE).requiresCorrectToolForDrops().strength(5.0F, 6.0F)) + ); + public static final Block RAW_GOLD_BLOCK = register( + "raw_gold_block", new Block(BlockBehaviour.Properties.of(Material.STONE, MaterialColor.GOLD).requiresCorrectToolForDrops().strength(5.0F, 6.0F)) + ); + public static final Block POTTED_AZALEA = register( + "potted_azalea_bush", new FlowerPotBlock(AZALEA, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + public static final Block POTTED_FLOWERING_AZALEA = register( + "potted_flowering_azalea_bush", new FlowerPotBlock(FLOWERING_AZALEA, BlockBehaviour.Properties.of(Material.DECORATION).instabreak().noOcclusion()) + ); + + private static ToIntFunction litBlockEmission(int i) { + return blockState -> blockState.getValue(BlockStateProperties.LIT) ? i : 0; + } + + private static Boolean never(BlockState blockState, BlockGetter blockGetter, BlockPos blockPos, EntityType entityType) { + return false; + } + + private static Boolean always(BlockState blockState, BlockGetter blockGetter, BlockPos blockPos, EntityType entityType) { + return true; + } + + private static Boolean ocelotOrParrot(BlockState blockState, BlockGetter blockGetter, BlockPos blockPos, EntityType entityType) { + return entityType == EntityType.OCELOT || entityType == EntityType.PARROT; + } + + private static BedBlock bed(DyeColor dyeColor) { + return new BedBlock( + dyeColor, + BlockBehaviour.Properties.of( + Material.WOOL, blockState -> blockState.getValue(BedBlock.PART) == BedPart.FOOT ? dyeColor.getMaterialColor() : MaterialColor.WOOL + ) + .sound(SoundType.WOOD) + .strength(0.2F) + .noOcclusion() + ); + } + + private static RotatedPillarBlock log(MaterialColor materialColor, MaterialColor materialColor2) { + return new RotatedPillarBlock( + BlockBehaviour.Properties.of(Material.WOOD, blockState -> blockState.getValue(RotatedPillarBlock.AXIS) == Direction.Axis.Y ? materialColor : materialColor2) + .strength(2.0F) + .sound(SoundType.WOOD) + ); + } + + private static Block netherStem(MaterialColor materialColor) { + return new RotatedPillarBlock(BlockBehaviour.Properties.of(Material.NETHER_WOOD, blockState -> materialColor).strength(2.0F).sound(SoundType.STEM)); + } + + private static boolean always(BlockState blockState, BlockGetter blockGetter, BlockPos blockPos) { + return true; + } + + private static boolean never(BlockState blockState, BlockGetter blockGetter, BlockPos blockPos) { + return false; + } + + private static StainedGlassBlock stainedGlass(DyeColor dyeColor) { + return new StainedGlassBlock( + dyeColor, + BlockBehaviour.Properties.of(Material.GLASS, dyeColor) + .strength(0.3F) + .sound(SoundType.GLASS) + .noOcclusion() + .isValidSpawn(Blocks::never) + .isRedstoneConductor(Blocks::never) + .isSuffocating(Blocks::never) + .isViewBlocking(Blocks::never) + ); + } + + private static LeavesBlock leaves(SoundType soundType) { + return new LeavesBlock( + BlockBehaviour.Properties.of(Material.LEAVES) + .strength(0.2F) + .randomTicks() + .sound(soundType) + .noOcclusion() + .isValidSpawn(Blocks::ocelotOrParrot) + .isSuffocating(Blocks::never) + .isViewBlocking(Blocks::never) + ); + } + + private static ShulkerBoxBlock shulkerBox(DyeColor dyeColor, BlockBehaviour.Properties properties) { + BlockBehaviour.StatePredicate statePredicate = (blockState, blockGetter, blockPos) -> !( + blockGetter.getBlockEntity(blockPos) instanceof ShulkerBoxBlockEntity shulkerBoxBlockEntity + ) + ? true + : shulkerBoxBlockEntity.isClosed(); + return new ShulkerBoxBlock(dyeColor, properties.strength(2.0F).dynamicShape().noOcclusion().isSuffocating(statePredicate).isViewBlocking(statePredicate)); + } + + private static PistonBaseBlock pistonBase(boolean bl) { + BlockBehaviour.StatePredicate statePredicate = (blockState, blockGetter, blockPos) -> !(Boolean)blockState.getValue(PistonBaseBlock.EXTENDED); + return new PistonBaseBlock( + bl, + BlockBehaviour.Properties.of(Material.PISTON).strength(1.5F).isRedstoneConductor(Blocks::never).isSuffocating(statePredicate).isViewBlocking(statePredicate) + ); + } + + private static Block register(String string, Block block) { + return Registry.register(Registry.BLOCK, string, block); + } + + public static void rebuildCache() { + Block.BLOCK_STATE_REGISTRY.forEach(BlockBehaviour.BlockStateBase::initCache); + } + + static { + for (Block block : Registry.BLOCK) { + for (BlockState blockState : block.getStateDefinition().getPossibleStates()) { + Block.BLOCK_STATE_REGISTRY.add(blockState); + } + + block.getLootTable(); + } + } +} diff --git a/src/lib/vanilla-1.17.1/COMMIT b/src/lib/vanilla-1.17.1/COMMIT new file mode 100644 index 0000000..b1d36ef --- /dev/null +++ b/src/lib/vanilla-1.17.1/COMMIT @@ -0,0 +1 @@ +72447031d70fca0cfc9087de2704da83bc12fa79 diff --git a/src/lib/vanilla-1.17.1/SOURCE.txt b/src/lib/vanilla-1.17.1/SOURCE.txt new file mode 100644 index 0000000..9defb11 --- /dev/null +++ b/src/lib/vanilla-1.17.1/SOURCE.txt @@ -0,0 +1,10 @@ +Vanilla 1.17.1 block registry snapshot for light tables. + +Source: MinecraftDeobfuscated-Mojang tag 1.17.1 +Commit: 72447031d70fca0cfc9087de2704da83bc12fa79 +SharedConstants.VERSION_STRING=1.17.1 +SharedConstants.WORLD_VERSION=2730 + +Do not replace this with the current snapshot / 1.21.7 tree. +Emission numbers come from Blocks.java lightLevel(...) on this tag. +minecraft-data 1.17.1 is used only to map block name + properties → stateId. diff --git a/src/lib/worldrendererCommon.lightOwner.test.ts b/src/lib/worldrendererCommon.lightOwner.test.ts new file mode 100644 index 0000000..a35e68a --- /dev/null +++ b/src/lib/worldrendererCommon.lightOwner.test.ts @@ -0,0 +1,903 @@ +import { EventEmitter } from 'events' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { Vec3 } from 'vec3' +import { proxy } from 'valtio' +import * as worldRendererModule from './worldrendererCommon' +import { WorldRendererCommon } from './worldrendererCommon' +import { defaultWorldRendererConfig } from '../graphicsBackend/config' +import { defaultPerformanceInstabilityFactors } from '../performanceMonitor' +import { getInitialPlayerState } from '../playerState/playerState' +import type { DisplayWorldOptions, GraphicsInitOptions } from '../graphicsBackend/types' +import { LIGHT_OWNER_WORKER_SCRIPT } from '../three/clientLightOwner' +import { INITIAL_TOPOLOGY_REVISION } from './clientLightVersions' +import { clearTopologyPostCache, getOrComputeTopologyPost } from '../wasm-mesher/worker/mesherWasmTopologyPostCache' +import Chunks from 'prismarine-chunk' + +vi.mock('./ui/newStats', () => ({ + addNewStat: vi.fn(() => ({ updateText: vi.fn(), setVisibility: vi.fn() })), + updateStatText: vi.fn(), + removeAllStats: vi.fn(), + updatePanesVisibility: vi.fn(), + MC_RENDERER_DEBUG_OVERLAY_CLASS: 'mc-renderer-debug-overlay' +})) + +vi.mock('./utils/skins', () => ({ + setSkinsConfig: vi.fn(), + steveTexture: {}, + stevePngUrl: '' +})) + +function ensurePromiseWithResolvers() { + if (!Promise.withResolvers) { + Promise.withResolvers = function () { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } + } + } +} + +class TestWorldRenderer extends WorldRendererCommon { + outputFormat = 'threeJs' as const + + changeBackgroundColor() {} + changeCardinalLight() {} + handleWorkerMessage() {} + updateCamera() {} + render() {} + updateShowChunksBorder() {} + updatePlayerEntity() {} + worldStop() {} +} + +const constructedScripts: string[] = [] + +function createRenderer(enableOwner = false, workerCount = 2, version = '1.17.1') { + const rendererState = proxy({ + world: { + chunksLoaded: {} as Record, + heightmaps: {} as Record, + allChunksLoaded: false, + mesherWork: false, + instabilityFactors: defaultPerformanceInstabilityFactors(), + intersectMedia: null + }, + renderer: '', + preventEscapeMenu: false + }) + + const displayOptions: DisplayWorldOptions = { + version, + worldView: Object.assign(new EventEmitter(), { reloadLoadedChunks: vi.fn(async () => {}) }) as unknown as DisplayWorldOptions['worldView'], + inWorldRenderingConfig: proxy({ ...defaultWorldRendererConfig, mesherWorkers: workerCount, enableClientLightOwner: enableOwner }), + playerStateReactive: getInitialPlayerState(), + rendererState, + nonReactiveState: { + fps: 0, + worstRenderTime: 0, + avgRenderTime: 0, + world: { + chunksLoadedCount: 0, + chunksTotalNumber: 0, + chunksFullInfo: '' + }, + renderer: { + timeline: { live: [], frozen: [], lastSecond: [] } + } + }, + resourcesManager: { + currentResources: { + mcData: { version: {} }, + blocksAtlasJson: {}, + blockstatesModels: {} + } + } as DisplayWorldOptions['resourcesManager'] + } + + const initOptions: GraphicsInitOptions = { + config: { sceneBackground: '#000' }, + rendererSpecificSettings: {}, + callbacks: { + displayCriticalError: vi.fn(), + setRendererSpecificSettings: vi.fn(), + fireCustomEvent: vi.fn() + } + } + + const renderer = new TestWorldRenderer(displayOptions.resourcesManager, displayOptions, initOptions) + renderer.active = true + renderer.viewDistance = 8 + renderer.viewerChunkPosition = new Vec3(0, 64, 0) + renderer.worldSizeParams = { minY: 0, worldHeight: 256 } + return renderer +} + +describe('WorldRendererCommon client light owner spawn', () => { + beforeEach(() => { + ensurePromiseWithResolvers() + constructedScripts.length = 0 + vi.stubGlobal('location', { href: 'http://localhost/' }) + vi.stubGlobal( + 'Worker', + class MockWorker { + script: string + postMessage = vi.fn() + terminate = vi.fn() + addEventListener = vi.fn() + onmessage: ((event: MessageEvent) => void) | null = null + constructor(script: string) { + this.script = script + constructedScripts.push(script) + } + } + ) + vi.spyOn(worldRendererModule, 'meshersSendMcData').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + test('flag-off initWorkers does not spawn the light-owner worker', () => { + const renderer = createRenderer(false, 2) + renderer.initWorkers(2) + expect(renderer.workers).toHaveLength(2) + expect(constructedScripts.every(script => script !== LIGHT_OWNER_WORKER_SCRIPT)).toBe(true) + expect(renderer.hasClientLightOwner()).toBe(false) + expect(defaultWorldRendererConfig.enableClientLightOwner).toBe(false) + }) + + test('flag-on on a non-1.17.1 session does not load the 1.17.1 tables', () => { + const renderer = createRenderer(true, 1, '1.16.5') + renderer.initWorkers(1) + expect(renderer.workers).toHaveLength(1) + expect(constructedScripts.every(script => script !== LIGHT_OWNER_WORKER_SCRIPT)).toBe(true) + expect(renderer.getClientLightOwnerWorker()).toBeNull() + expect(renderer.hasClientLightOwner()).toBe(false) + expect(renderer.getClientLightOwnerFailureReason()).toMatch(/1\.17\.1/) + }) + + test('flag-on initWorkers spawns a dedicated owner, not a mesh worker', () => { + const renderer = createRenderer(true, 2) + renderer.initWorkers(2) + expect(renderer.workers).toHaveLength(2) + expect(constructedScripts.filter(script => script === LIGHT_OWNER_WORKER_SCRIPT)).toHaveLength(1) + expect(constructedScripts.filter(script => script !== LIGHT_OWNER_WORKER_SCRIPT)).toHaveLength(2) + expect(renderer.getClientLightOwnerWorker()).toBeTruthy() + expect(renderer.hasClientLightOwner()).toBe(false) + }) + + test('flag-on owner is not working until the worker reports ready', () => { + const renderer = createRenderer(true, 1) + renderer.initWorkers(1) + expect(renderer.hasClientLightOwner()).toBe(false) + const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null } + owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent) + expect(renderer.hasClientLightOwner()).toBe(true) + }) + + test('flag-on owner goes failed on worker error and is not working', () => { + const renderer = createRenderer(true, 1) + renderer.initWorkers(1) + const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null } + owner.onmessage?.({ data: { type: 'error', error: 'NetworkError loading lightOwnerWorker.js' } } as MessageEvent) + expect(renderer.hasClientLightOwner()).toBe(false) + }) + + test('flag-off setBlock / addColumn / removeColumn do not post to an owner worker', () => { + const renderer = createRenderer(false, 2) + renderer.initWorkers(2) + const ownerPosts = constructedScripts.map((_, i) => i).filter(i => constructedScripts[i] === LIGHT_OWNER_WORKER_SCRIPT) + expect(ownerPosts).toHaveLength(0) + + const meshPosts = renderer.workers.map((w: { postMessage: ReturnType }) => w.postMessage) + renderer.loadedChunks['0,0'] = true + renderer.setBlockStateIdInner(new Vec3(1, 64, 1), 1) + renderer.removeColumn(0, 0) + + expect(meshPosts.some(fn => fn.mock.calls.length > 0)).toBe(true) + expect(renderer.hasClientLightOwner()).toBe(false) + }) + + test('startup light stays bulk; a later publication of an edited section stays urgent', () => { + const renderer = createRenderer(true, 1) + renderer.initWorkers(1) + renderer.forceCallFromMesherReplayer = true + renderer.neighborChunkUpdates = false + renderer.loadedChunks['0,0'] = true + renderer.loadedChunks['64,64'] = true + const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null } + owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent) + const mesh = renderer.workers[0] as { postMessage: ReturnType } + const publish = (version: number, sx: number, sy: number, sz: number) => { + owner.onmessage?.({ + data: { + type: 'publication', + publication: { + worldGeneration: 1, + publicationVersion: version, + sections: [{ sx, sy, sz, blockLight: new Uint8Array(2048) }] + } + } + } as MessageEvent) + } + const dirties = () => mesh.postMessage.mock.calls.map(call => call[0]).filter((message: { type?: string }) => message?.type === 'dirty') + + mesh.postMessage.mockClear() + publish(1, 0, 4, 0) + const startup = dirties() + expect(startup.length).toBeGreaterThan(0) + expect(startup.every((message: { urgent?: boolean }) => message.urgent !== true)).toBe(true) + + mesh.postMessage.mockClear() + renderer.setBlockStateIdInner(new Vec3(1, 64, 1), 1) + const editDirties = dirties() + expect(editDirties.length).toBeGreaterThan(0) + expect(editDirties.every((message: { urgent?: boolean }) => message.urgent === true)).toBe(true) + + mesh.postMessage.mockClear() + publish(2, 0, 4, 0) + const covering = dirties() + expect(covering.length).toBeGreaterThan(0) + expect(covering.every((message: { urgent?: boolean }) => message.urgent === true)).toBe(true) + + mesh.postMessage.mockClear() + publish(3, 4, 4, 4) + const unrelated = dirties() + expect(unrelated.length).toBeGreaterThan(0) + expect(unrelated.every((message: { urgent?: boolean }) => message.urgent !== true)).toBe(true) + }) + + test('geometry at the pre-edit light version keeps the edit urgent until its own publication is committed', () => { + const renderer = createRenderer(true, 1) + renderer.initWorkers(1) + renderer.forceCallFromMesherReplayer = true + renderer.neighborChunkUpdates = false + renderer.loadedChunks['0,0'] = true + const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null } + owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent) + const mesh = renderer.workers[0] as { postMessage: ReturnType } + const publish = (version: number) => { + owner.onmessage?.({ + data: { + type: 'publication', + publication: { + worldGeneration: 1, + publicationVersion: version, + sections: [{ sx: 0, sy: 4, sz: 0, blockLight: new Uint8Array(2048) }] + } + } + } as MessageEvent) + } + const dirties = () => mesh.postMessage.mock.calls.map(call => call[0]).filter((message: { type?: string }) => message?.type === 'dirty') + publish(5) + renderer.setBlockStateIdInner(new Vec3(1, 64, 1), 1) + const topology = (renderer as any).topologyRevisionBySection.get('0,64,0') as number + const geometry = { + type: 'geometry', + key: '0,64,0', + worldGeneration: 1, + lightPublicationVersion: 5, + sessionEpoch: 1, + columnIncarnation: 1, + requestId: 1, + topologyRevision: topology, + meshMode: 'owner' as const, + workerIndex: 0, + geometry: {} + } + const early = (renderer as any).evaluateOwnerGeometry(geometry) + expect(early.accepted).toBe(true) + expect(early.keepCovering).toBe(false) + renderer.handleMessage(geometry) + owner.onmessage?.({ data: { type: 'stepped', remaining: false, stepGeneration: 0 } } as MessageEvent) + mesh.postMessage.mockClear() + publish(6) + const covering = dirties() + expect(covering.length).toBeGreaterThan(0) + expect(covering.every((message: { urgent?: boolean }) => message.urgent === true)).toBe(true) + + renderer.handleMessage({ ...geometry, lightPublicationVersion: 6 }) + mesh.postMessage.mockClear() + publish(7) + const afterCommit = dirties() + expect(afterCommit.length).toBeGreaterThan(0) + expect(afterCommit.every((message: { urgent?: boolean }) => message.urgent !== true)).toBe(true) + }) + + test('a second urgent edit in the throttle window is dispatched immediately', () => { + vi.useFakeTimers() + try { + const renderer = createRenderer(true, 1) + renderer.initWorkers(1) + renderer.forceCallFromMesherReplayer = true + renderer.neighborChunkUpdates = false + renderer.loadedChunks['0,0'] = true + const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null } + owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent) + const mesh = renderer.workers[0] as { postMessage: ReturnType } + renderer.setBlockStateIdInner(new Vec3(1, 64, 1), 1) + mesh.postMessage.mockClear() + renderer.setBlockStateIdInner(new Vec3(2, 64, 1), 2) + const second = mesh.postMessage.mock.calls.map(call => call[0]).filter((message: { type?: string }) => message?.type === 'dirty') + expect(second.length).toBeGreaterThan(0) + expect(second.every((message: { urgent?: boolean }) => message.urgent === true)).toBe(true) + } finally { + vi.clearAllTimers() + vi.useRealTimers() + } + }) + + test('flag-on setBlock / addColumn / removeColumn / update_light forward to the owner', () => { + const renderer = createRenderer(true, 2) + renderer.initWorkers(2) + const owner = renderer.getClientLightOwnerWorker() as unknown as { postMessage: ReturnType } + expect(owner).toBeTruthy() + owner.postMessage.mockClear() + + renderer.loadedChunks['0,0'] = true + renderer.setBlockStateIdInner(new Vec3(8, 64, 8), 2) + const Chunk = Chunks('1.17.1') as any + renderer.addColumn(0, 0, new Chunk().toJson(), false) + renderer.removeColumn(0, 0) + renderer.feedChunkPacket({ + kind: 'setUpdateLightV17', + protocol: 756, + numSections: 16, + rawPacket: new Uint8Array([1, 2, 3]) + }) + + const payloads = owner.postMessage.mock.calls.map(call => call[0]) + expect(payloads.some(p => p?.type === 'pushEvent' && p.event?.type === 'blockChange')).toBe(true) + expect(payloads.some(p => p?.type === 'pushEvent' && p.event?.type === 'ingestBlockSection')).toBe(true) + expect(payloads.some(p => p?.type === 'pushEvent' && p.event?.type === 'unloadColumn')).toBe(true) + expect(payloads.some(p => p?.type === 'setUpdateLightV17')).toBe(true) + }) + + test('resetWorld terminates the owner worker when it was spawned', () => { + const renderer = createRenderer(true, 1) + renderer.initWorkers(1) + const owner = renderer.getClientLightOwnerWorker() as unknown as { terminate: ReturnType } + expect(owner).toBeTruthy() + renderer.resetWorld() + expect(owner.terminate).toHaveBeenCalled() + expect(renderer.hasClientLightOwner()).toBe(false) + expect(renderer.workers).toHaveLength(0) + }) + + test('owner publication remesh does not wait the 100ms trailing window', () => { + vi.useFakeTimers() + try { + const renderer = createRenderer(true, 1) + renderer.initWorkers(1) + renderer.forceCallFromMesherReplayer = true + const mesh = renderer.workers[0] as { postMessage: ReturnType } + renderer.setSectionDirty(new Vec3(0, 64, 0), true, true) + mesh.postMessage.mockClear() + ;(renderer as any).onClientLightOwnerPublication({ + applied: true, + lastVersion: 7, + acceptedGeneration: 1, + dirtyMeshSections: [{ sx: 0, sy: 64, sz: 0 }], + workerMessage: null + }) + const immediate = mesh.postMessage.mock.calls.map(call => call[0]).filter((message: { type?: string }) => message?.type === 'dirty') + expect(immediate.some((message: { lightPublicationVersion?: number }) => message.lightPublicationVersion === 7)).toBe(true) + mesh.postMessage.mockClear() + vi.advanceTimersByTime(WorldRendererCommon['GEOMETRY_THROTTLE_DELAY']) + const trailing = mesh.postMessage.mock.calls.map(call => call[0]).filter((message: { type?: string }) => message?.type === 'dirty') + expect(trailing).toHaveLength(0) + } finally { + vi.useRealTimers() + } + }) + + test('owner publication is posted only to mesh workers that own dirty sections', () => { + const renderer = createRenderer(true, 8) + renderer.initWorkers(8) + for (const worker of renderer.workers) { + ;(worker as { postMessage: ReturnType }).postMessage.mockClear() + } + const workerMessage = { + type: 'applyOwnerLightPublication', + worldGeneration: 1, + publicationVersion: 4, + sections: [{ sx: 0, sy: 64, sz: 0, blockLight: new Uint8Array(2048) }] + } + ;(renderer as any).onClientLightOwnerPublication({ + applied: true, + lastVersion: 4, + acceptedGeneration: 1, + dirtyMeshSections: [{ sx: 0, sy: 64, sz: 0 }], + workerMessage + }) + const posted = renderer.workers.map(worker => + (worker as { postMessage: ReturnType }).postMessage.mock.calls.some(call => call[0]?.type === 'applyOwnerLightPublication') + ) + expect(posted.filter(Boolean).length).toBeGreaterThan(0) + expect(posted.filter(Boolean).length).toBeLessThan(renderer.workers.length) + }) + + test('trailing dirty after an owner publication keeps the target revision', () => { + vi.useFakeTimers() + try { + const renderer = createRenderer(true, 1) + renderer.initWorkers(1) + renderer.forceCallFromMesherReplayer = true + const mesh = renderer.workers[0] as { postMessage: ReturnType } + mesh.postMessage.mockClear() + renderer.setSectionDirty(new Vec3(0, 64, 0), true, true) + ;(renderer as any).onClientLightOwnerPublication({ + applied: true, + lastVersion: 7, + acceptedGeneration: 1, + dirtyMeshSections: [{ sx: 0, sy: 64, sz: 0 }], + workerMessage: null + }) + vi.advanceTimersByTime(WorldRendererCommon['GEOMETRY_THROTTLE_DELAY']) + const dirties = mesh.postMessage.mock.calls.map(call => call[0]).filter((message: { type?: string }) => message?.type === 'dirty') + const trailing = dirties.at(-1) + expect(trailing?.lightPublicationVersion).toBe(7) + expect(trailing?.worldGeneration).toBe(1) + } finally { + vi.useRealTimers() + } + }) + + test('rejected stale owner geometry does not add a remesh when covering is already outstanding', () => { + const renderer = createRenderer(true, 1) + renderer.initWorkers(1) + renderer.forceCallFromMesherReplayer = true + renderer.loadedChunks['0,0'] = true + const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null } + owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent) + owner.onmessage?.({ + data: { + type: 'publication', + publication: { + worldGeneration: 1, + publicationVersion: 2, + sections: [{ sx: 0, sy: 4, sz: 0, blockLight: new Uint8Array(2048) }] + } + } + } as MessageEvent) + + const mesh = renderer.workers[0] as { postMessage: ReturnType } + mesh.postMessage.mockClear() + renderer.sectionsWaiting.set('0,64,0', 1) + renderer.handleMessage({ + type: 'geometry', + key: '0,64,0', + worldGeneration: 1, + lightPublicationVersion: 1, + sessionEpoch: 1, + columnIncarnation: 1, + requestId: 1, + topologyRevision: 1, + workerIndex: 0, + geometry: {} + }) + const dirties = mesh.postMessage.mock.calls.map(call => call[0]).filter((message: { type?: string }) => message?.type === 'dirty') + expect(dirties).toHaveLength(0) + + renderer.handleMessage({ type: 'sectionFinished', key: '0,64,0', workerIndex: 0, processTime: 0 }) + const replacement = mesh.postMessage.mock.calls.map(call => call[0]).filter((message: { type?: string }) => message?.type === 'dirty') + expect(replacement).toHaveLength(1) + expect(replacement[0]?.lightPublicationVersion).toBe(2) + expect(renderer.sectionsWaiting.get('0,64,0')).toBe(1) + expect((renderer as any).rejectedFinishedBySection.get('0,64,0') ?? 0).toBe(0) + }) + + test('fast A→B→C drops B and keeps a single covering remesh for C', () => { + const renderer = createRenderer(true, 1) + renderer.initWorkers(1) + renderer.forceCallFromMesherReplayer = true + renderer.loadedChunks['0,0'] = true + const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null } + owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent) + const publish = (version: number) => { + owner.onmessage?.({ + data: { + type: 'publication', + publication: { + worldGeneration: 1, + publicationVersion: version, + sections: [{ sx: 0, sy: 4, sz: 0, blockLight: new Uint8Array(2048) }] + } + } + } as MessageEvent) + } + publish(1) + publish(2) + publish(3) + const mesh = renderer.workers[0] as { postMessage: ReturnType } + mesh.postMessage.mockClear() + renderer.sectionsWaiting.set('0,64,0', 1) + renderer.handleMessage({ + type: 'geometry', + key: '0,64,0', + worldGeneration: 1, + lightPublicationVersion: 2, + sessionEpoch: 1, + columnIncarnation: 1, + requestId: 2, + topologyRevision: 1, + workerIndex: 0, + geometry: {} + }) + expect(mesh.postMessage.mock.calls.filter(call => call[0]?.type === 'dirty')).toHaveLength(0) + const accepted = (renderer as any).evaluateOwnerGeometry({ + type: 'geometry', + key: '0,64,0', + worldGeneration: 1, + lightPublicationVersion: 3, + sessionEpoch: 1, + columnIncarnation: 1, + requestId: 3, + topologyRevision: 1, + geometry: {} + }) + expect(accepted.accepted).toBe(true) + }) + + test('owner failure posts a revert to mesh workers and rejects later owner publications', () => { + const renderer = createRenderer(true, 1) + renderer.initWorkers(1) + renderer.forceCallFromMesherReplayer = true + const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null } + owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent) + const mesh = renderer.workers[0] as { postMessage: ReturnType } + mesh.postMessage.mockClear() + owner.onmessage?.({ data: { type: 'error', error: 'owner wasm crashed' } } as MessageEvent) + expect(renderer.hasClientLightOwner()).toBe(false) + expect((renderer as any).getClientLightOwnerFailureReason()).toBe('owner wasm crashed') + expect(mesh.postMessage.mock.calls.some(call => call[0]?.type === 'revertOwnerLightToIncoming')).toBe(true) + const late = (renderer as any).evaluateOwnerGeometry({ + type: 'geometry', + key: '0,64,0', + worldGeneration: 1, + lightPublicationVersion: 9, + sessionEpoch: 1, + meshMode: 'owner', + geometry: {} + }) + expect(late.accepted).toBe(false) + }) + + test('reload of the same column increments incarnation so a late reply is dropped', () => { + const renderer = createRenderer(true, 1) + renderer.initWorkers(1) + renderer.forceCallFromMesherReplayer = true + renderer.loadedChunks['0,0'] = true + const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null } + owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent) + const first = (renderer as any).columnIncarnationFor('0,0') + renderer.removeColumn(0, 0) + renderer.loadedChunks['0,0'] = true + const second = (renderer as any).columnIncarnationFor('0,0') + expect(second).toBeGreaterThan(first ?? 0) + const late = (renderer as any).evaluateOwnerGeometry({ + type: 'geometry', + key: '0,64,0', + worldGeneration: 1, + lightPublicationVersion: 4, + sessionEpoch: 1, + columnIncarnation: first ?? 1, + requestId: 4, + topologyRevision: 1, + meshMode: 'owner', + geometry: {} + }) + expect(late.accepted).toBe(false) + }) + + test('flag-on ready owner does not fan-out raw update_light to mesh workers', () => { + const renderer = createRenderer(true, 2) + renderer.initWorkers(2) + const owner = renderer.getClientLightOwnerWorker() as unknown as { + onmessage: ((event: MessageEvent) => void) | null + postMessage: ReturnType + } + owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent) + owner.postMessage.mockClear() + for (const worker of renderer.workers) { + ;(worker as { postMessage: ReturnType }).postMessage.mockClear() + } + renderer.feedChunkPacket({ + kind: 'setUpdateLightV17', + protocol: 756, + numSections: 16, + rawPacket: new Uint8Array([1, 2, 3]) + }) + expect(owner.postMessage.mock.calls.some(call => call[0]?.type === 'setUpdateLightV17')).toBe(true) + expect( + renderer.workers.every(worker => + (worker as { postMessage: ReturnType }).postMessage.mock.calls.every(call => call[0]?.type !== 'setUpdateLightV17') + ) + ).toBe(true) + }) + + test('stale topology with fresh light is rejected and covering stays outstanding', () => { + const renderer = createRenderer(true, 1) + renderer.initWorkers(1) + renderer.forceCallFromMesherReplayer = true + renderer.neighborChunkUpdates = false + renderer.loadedChunks['0,0'] = true + const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null } + owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent) + renderer.setBlockStateIdInner(new Vec3(1, 64, 1), 1) + renderer.setBlockStateIdInner(new Vec3(1, 64, 1), 2) + const topology = (renderer as any).topologyRevisionBySection.get('0,64,0') as number + expect(topology).toBeGreaterThan(1) + owner.onmessage?.({ + data: { + type: 'publication', + publication: { + worldGeneration: 1, + publicationVersion: 4, + sections: [{ sx: 0, sy: 4, sz: 0, blockLight: new Uint8Array(2048) }] + } + } + } as MessageEvent) + expect((renderer as any).clientLightOwnerSession.requiredLightForSection('0,64,0')?.topologyRevision).toBe(topology) + const mesh = renderer.workers[0] as { postMessage: ReturnType } + mesh.postMessage.mockClear() + renderer.sectionsWaiting.set('0,64,0', 1) + renderer.handleMessage({ + type: 'geometry', + key: '0,64,0', + worldGeneration: 1, + lightPublicationVersion: 4, + sessionEpoch: 1, + columnIncarnation: 1, + requestId: 1, + topologyRevision: topology - 1, + workerIndex: 0, + geometry: {} + }) + expect( + (renderer as any).evaluateOwnerGeometry({ + type: 'geometry', + key: '0,64,0', + worldGeneration: 1, + lightPublicationVersion: 4, + sessionEpoch: 1, + columnIncarnation: 1, + topologyRevision: topology - 1, + geometry: {} + }).accepted + ).toBe(false) + expect(mesh.postMessage.mock.calls.filter(call => call[0]?.type === 'dirty')).toHaveLength(0) + expect((renderer as any).pendingTopologyBySection.get('0,64,0')).toBe(topology) + renderer.handleMessage({ type: 'sectionFinished', key: '0,64,0', workerIndex: 0, processTime: 0 }) + const replacement = mesh.postMessage.mock.calls.map(call => call[0]).filter((message: { type?: string }) => message?.type === 'dirty') + expect(replacement).toHaveLength(1) + expect(replacement[0]?.topologyRevision).toBe(topology) + expect(replacement[0]?.lightPublicationVersion).toBe(4) + expect(renderer.sectionsWaiting.get('0,64,0')).toBe(1) + expect((renderer as any).pendingTopologyBySection.get('0,64,0')).toBe(topology) + }) + + test('fresh topology with stale light installs and keeps covering without a second dispatch', () => { + const renderer = createRenderer(true, 1) + renderer.initWorkers(1) + renderer.forceCallFromMesherReplayer = true + renderer.neighborChunkUpdates = false + renderer.loadedChunks['0,0'] = true + const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null } + owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent) + renderer.setBlockStateIdInner(new Vec3(1, 64, 1), 1) + const topology = (renderer as any).topologyRevisionBySection.get('0,64,0') as number + owner.onmessage?.({ + data: { + type: 'publication', + publication: { + worldGeneration: 1, + publicationVersion: 3, + sections: [{ sx: 0, sy: 4, sz: 0, blockLight: new Uint8Array(2048) }] + } + } + } as MessageEvent) + const mesh = renderer.workers[0] as { postMessage: ReturnType } + mesh.postMessage.mockClear() + renderer.sectionsWaiting.set('0,64,0', 2) + renderer.handleMessage({ + type: 'geometry', + key: '0,64,0', + worldGeneration: 1, + lightPublicationVersion: 1, + sessionEpoch: 1, + columnIncarnation: 1, + requestId: 1, + topologyRevision: topology, + workerIndex: 0, + geometry: {} + }) + const decision = (renderer as any).evaluateOwnerGeometry({ + type: 'geometry', + key: '0,64,0', + worldGeneration: 1, + lightPublicationVersion: 1, + sessionEpoch: 1, + columnIncarnation: 1, + topologyRevision: topology, + geometry: {} + }) + expect(decision.accepted).toBe(true) + expect(decision.keepCovering).toBe(true) + expect(mesh.postMessage.mock.calls.filter(call => call[0]?.type === 'dirty')).toHaveLength(0) + expect((renderer as any).pendingCoveringBySection.has('0,64,0')).toBe(true) + expect((renderer as any).pendingTopologyBySection.has('0,64,0')).toBe(false) + renderer.handleMessage({ type: 'sectionFinished', key: '0,64,0', workerIndex: 0, processTime: 0 }) + expect(renderer.sectionsWaiting.get('0,64,0')).toBe(1) + expect((renderer as any).pendingCoveringBySection.has('0,64,0')).toBe(true) + }) + + test('fast A→B→C publications keep the max topology revision', () => { + const renderer = createRenderer(true, 1) + renderer.initWorkers(1) + renderer.forceCallFromMesherReplayer = true + renderer.neighborChunkUpdates = false + renderer.loadedChunks['0,0'] = true + const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null } + owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent) + const publish = (version: number) => { + owner.onmessage?.({ + data: { + type: 'publication', + publication: { + worldGeneration: 1, + publicationVersion: version, + sections: [{ sx: 0, sy: 4, sz: 0, blockLight: new Uint8Array(2048) }] + } + } + } as MessageEvent) + } + renderer.setBlockStateIdInner(new Vec3(1, 64, 1), 1) + publish(1) + renderer.setBlockStateIdInner(new Vec3(1, 64, 1), 2) + publish(2) + renderer.setBlockStateIdInner(new Vec3(1, 64, 1), 3) + publish(3) + const topology = (renderer as any).topologyRevisionBySection.get('0,64,0') + expect((renderer as any).clientLightOwnerSession.requiredLightForSection('0,64,0')).toMatchObject({ + requiredVersion: 3, + worldGeneration: 1, + topologyRevision: topology + }) + }) + + test('publishing light only for column A accepts the neighbor wall without a publication of B', () => { + const renderer = createRenderer(true, 2) + renderer.initWorkers(2) + renderer.forceCallFromMesherReplayer = true + renderer.loadedChunks['0,0'] = true + renderer.loadedChunks['16,0'] = true + renderer.loadedChunks['-16,0'] = true + const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null } + owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent) + owner.onmessage?.({ + data: { + type: 'publication', + publication: { + worldGeneration: 1, + publicationVersion: 8, + sections: [{ sx: 0, sy: 4, sz: 0, blockLight: new Uint8Array(2048) }] + } + } + } as MessageEvent) + + const posted = renderer.workers.map(worker => + (worker as { postMessage: ReturnType }).postMessage.mock.calls.some(call => call[0]?.type === 'applyOwnerLightPublication') + ) + expect(posted).toEqual([true, true]) + expect((renderer as any).clientLightOwnerSession.requiredLightForSection('16,64,0')?.requiredVersion).toBe(8) + expect((renderer as any).clientLightOwnerSession.requiredLightForSection('-16,64,0')?.requiredVersion).toBe(8) + + const evaluate = (key: string, lightPublicationVersion: number, meshMode: 'owner' | 'legacyBootstrap') => { + const [x, , z] = key.split(',').map(Number) + return (renderer as any).evaluateOwnerGeometry({ + type: 'geometry', + key, + worldGeneration: 1, + lightPublicationVersion, + sessionEpoch: 1, + columnIncarnation: renderer.columnIncarnationFor(`${x},${z}`), + requestId: 1, + topologyRevision: INITIAL_TOPOLOGY_REVISION, + meshMode, + geometry: {} + }) + } + + const bootstrapWall = evaluate('-16,64,0', 8, 'legacyBootstrap') + expect(bootstrapWall.accepted).toBe(true) + expect(bootstrapWall.keepCovering).toBe(false) + expect(evaluate('16,64,0', 3, 'owner').accepted).toBe(false) + expect(evaluate('16,64,0', 8, 'owner').accepted).toBe(true) + }) + + test('bootstrap geometry from the previous column life is rejected', () => { + const renderer = createRenderer(true, 1) + renderer.initWorkers(1) + renderer.forceCallFromMesherReplayer = true + renderer.loadedChunks['0,0'] = true + const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null } + owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent) + const first = renderer.columnIncarnationFor('0,0') + renderer.removeColumn(0, 0) + renderer.loadedChunks['0,0'] = true + const late = (renderer as any).evaluateOwnerGeometry({ + type: 'geometry', + key: '0,64,0', + meshMode: 'legacyBootstrap', + sessionEpoch: 1, + columnIncarnation: first, + geometry: {} + }) + expect(late.accepted).toBe(false) + }) + + test('the first edit misses the initial topology cache and covering remesh carries the new revision', () => { + vi.useFakeTimers() + try { + const renderer = createRenderer(true, 1) + renderer.initWorkers(1) + renderer.forceCallFromMesherReplayer = true + renderer.neighborChunkUpdates = false + renderer.loadedChunks['0,0'] = true + const owner = renderer.getClientLightOwnerWorker() as { onmessage: ((event: MessageEvent) => void) | null } + owner.onmessage?.({ data: { type: 'ready' } } as MessageEvent) + const mesh = renderer.workers[0] as { postMessage: ReturnType } + mesh.postMessage.mockClear() + renderer.setSectionDirty(new Vec3(1, 64, 1), true, true) + const initial = mesh.postMessage.mock.calls + .map(call => call[0]) + .filter((message: { type?: string }) => message?.type === 'dirty') + .at(-1) + expect(initial?.topologyRevision).toBe(INITIAL_TOPOLOGY_REVISION) + + let walks = 0 + const compute = () => { + walks++ + return { visibilitySet: walks, signs: {}, heads: {}, banners: {} } + } + getOrComputeTopologyPost('0,64,0', initial?.topologyRevision, compute) + renderer.setBlockStateIdInner(new Vec3(1, 64, 1), 1) + owner.onmessage?.({ + data: { + type: 'publication', + publication: { + worldGeneration: 1, + publicationVersion: 2, + sections: [{ sx: 0, sy: 4, sz: 0, blockLight: new Uint8Array(2048) }] + } + } + } as MessageEvent) + const covering = mesh.postMessage.mock.calls + .map(call => call[0]) + .filter((message: { type?: string; lightPublicationVersion?: number; x?: number; y?: number; z?: number }) => { + if (message?.type !== 'dirty' || message.lightPublicationVersion !== 2) return false + const sx = Math.floor((message.x ?? 0) / 16) * 16 + const sy = Math.floor((message.y ?? 0) / 16) * 16 + const sz = Math.floor((message.z ?? 0) / 16) * 16 + return `${sx},${sy},${sz}` === '0,64,0' + }) + expect(covering).toHaveLength(1) + expect(covering[0]?.topologyRevision).not.toBe(initial?.topologyRevision) + const editedRevision = covering[0]?.topologyRevision + const recomputed = getOrComputeTopologyPost('0,64,0', editedRevision, compute) + expect(recomputed.hit).toBe(false) + expect(walks).toBe(2) + } finally { + clearTopologyPostCache() + vi.clearAllTimers() + vi.useRealTimers() + } + }) +}) diff --git a/src/lib/worldrendererCommon.ts b/src/lib/worldrendererCommon.ts index 4dc1fac..05a44eb 100644 --- a/src/lib/worldrendererCommon.ts +++ b/src/lib/worldrendererCommon.ts @@ -32,6 +32,41 @@ import { PlayerStateReactive } from '../playerState/playerState' import { IndexedData } from 'minecraft-data' import { WorldRendererConfig } from '../graphicsBackend/config' import { CameraCollisionBlockCache } from '../three/cameraCollisionBlockCache' +import { RendererLightCache } from '../three/rendererLightCache' +import { + bumpClientLightTraceSessionEpoch, + currentClientLightTraceIds, + ingestRemoteClientLightTraceMessage, + isClientLightTraceArmed, + isClientLightTraceEnabled, + isClientLightTraceMessage, + maybeEnableClientLightTraceFromLocation, + nextClientLightEditSeq, + nextClientLightRequestId, + recordClientLightTrace, + setClientLightTraceConfigListener +} from './clientLightTrace' +import { + ClientLightOwnerSession, + LIGHT_OWNER_WORKER_SCRIPT, + coveringReplacementForReject, + meshWorkerIndexesForDirtySections, + raiseRequiredLightRevisions, + shouldAcceptMeshGeometry, + clientLightOwnerVersionBlockReason, + shouldSpawnClientLightOwner, + skyLightEnabledFromRendererState, + type OwnerPublicationApplyResult +} from '../three/clientLightOwner' +import { + INITIAL_TOPOLOGY_REVISION, + isOwnerGeometryLightStale, + nextTopologyRevision, + shouldDispatchCoveringRemesh, + validateMeshResultVersions, + type MeshSectionLightRequirement +} from './clientLightVersions' +import type { FeedChunkPacketPayload } from '../worldView/types' function mod(x, n) { return ((x % n) + n) % n @@ -45,6 +80,8 @@ export abstract class WorldRendererCommon worldSizeParams = { minY: 0, worldHeight: 256 } /** Block columns for third-person voxel DDA (renderer thread). */ cameraCollisionBlockCache: CameraCollisionBlockCache + /** Packed block/sky light for entity sampling (renderer thread). */ + rendererLightCache: RendererLightCache reactiveDebugParams = proxy({ stopRendering: false, chunksRenderAboveOverride: undefined as number | undefined, @@ -84,6 +121,22 @@ export abstract class WorldRendererCommon }> customTexturesDataUrl = undefined as string | undefined workers: any[] = [] + private clientLightOwnerSession: ClientLightOwnerSession | null = null + private clientLightOwnerFailureReason: string | null = null + private clientLightOwnerFence = { sessionEpoch: 1, rejectStaleOwnerMeshes: false } + private columnIncarnationByColumn = new Map() + private topologyRevisionBySection = new Map() + protected pendingTopologyBySection = new Map() + private pendingCoveringBySection = new Map() + private rejectedFinishedBySection = new Map() + private nextDirtyLightMeta: { lightPublicationVersion?: number; worldGeneration?: number } = {} + private nextDirtyUrgent = false + /** + * Player edits whose covering light publication is still outstanding. + * `lightVersionAtEdit` is the owner version already on screen. Geometry at that + * version must not clear the edit: the publication for this edit is a newer one. + */ + private interactiveSections = new Map() viewerChunkPosition?: Vec3 // Last viewer chunk-grid coords for which `onViewerChunkPositionChanged` // fired — throttles the hook to chunk-grid changes. @@ -123,6 +176,8 @@ export abstract class WorldRendererCommon // Cumulative cache hit/miss counters (column-mode conversion cache). workersPreCacheHitsTotal = 0 workersPreCacheMissesTotal = 0 + workersPreWasmParseHitsTotal = 0 + workersPreWasmParseMissesTotal = 0 private static readonly PHASE_PERF_LOG_INTERVAL = 64 geometryReceiveCount = {} as Record allLoadedIn: undefined | number @@ -152,7 +207,8 @@ export abstract class WorldRendererCommon // Geometry throttle: first dirty per section is instant, subsequent within window are grouped private sectionDirtyCount = new Map() private sectionDirtyTimers = new Map>() - private sectionDirtyPendingArgs = new Map() + private sectionDirtyPendingArgs = new Map() + private sectionDirtyLightMeta = new Map() private static readonly GEOMETRY_THROTTLE_THRESHOLD = 1 private static readonly GEOMETRY_THROTTLE_DELAY = 100 // ms @@ -222,11 +278,17 @@ export abstract class WorldRendererCommon public initOptions: GraphicsInitOptions ) { this.cameraCollisionBlockCache = new CameraCollisionBlockCache(this.displayOptions.version) + this.rendererLightCache = new RendererLightCache(this.displayOptions.version) this.snapshotInitialValues() this.worldRendererConfig = displayOptions.inWorldRenderingConfig this.playerStateReactive = displayOptions.playerStateReactive! this.playerStateUtils = getPlayerStateUtils(this.playerStateReactive) this.reactiveState = displayOptions.rendererState! + maybeEnableClientLightTraceFromLocation() + if (isClientLightTraceEnabled()) bumpClientLightTraceSessionEpoch() + setClientLightTraceConfigListener(() => { + this.syncClientLightTraceConfig() + }) // this.mesherLogReader = new MesherLogReader(this) this.renderUpdateEmitter.on('update', () => { const loadedChunks = Object.keys(this.finishedChunks).length @@ -353,6 +415,174 @@ export abstract class WorldRendererCommon for (let i = 0; i < numWorkers; i++) { this.workers.push(this.createMesherWorker()) } + this.maybeSpawnClientLightOwner() + this.syncClientLightTraceConfig() + } + + hasClientLightOwner(): boolean { + return this.clientLightOwnerSession?.isReady === true + } + + private syncClientLightTraceConfig() { + const message = { + type: 'clientLightTraceConfig' as const, + enabled: isClientLightTraceEnabled(), + armed: isClientLightTraceArmed() + } + for (const worker of this.workers) { + worker.postMessage(message) + } + this.clientLightOwnerSession?.worker.postMessage(message) + } + + getClientLightOwnerWorker(): Worker | null { + return this.clientLightOwnerSession?.worker ?? null + } + + private maybeSpawnClientLightOwner() { + if (this.clientLightOwnerSession) return + if (!shouldSpawnClientLightOwner(this.worldRendererConfig, this.version)) { + if (this.worldRendererConfig.enableClientLightOwner === true) { + this.clientLightOwnerFailureReason = clientLightOwnerVersionBlockReason(this.version) + } + return + } + this.clientLightOwnerFence = { sessionEpoch: this.clientLightOwnerFence.sessionEpoch, rejectStaleOwnerMeshes: false } + this.clientLightOwnerFailureReason = null + this.clientLightOwnerSession = new ClientLightOwnerSession(this.rendererLightCache, { + createWorker: onMessage => initMesherWorker(onMessage, LIGHT_OWNER_WORKER_SCRIPT), + worldMinY: this.worldSizeParams.minY, + worldHeight: this.worldSizeParams.worldHeight, + skyLightEnabled: skyLightEnabledFromRendererState(this.playerStateReactive), + onApplied: result => this.onClientLightOwnerPublication(result), + onFailed: reason => this.onClientLightOwnerFailed(reason), + onOwnerIdle: settledEditGeneration => this.releaseInteractiveEditsWithoutCoveringPublication(settledEditGeneration) + }) + this.postOwnerAcceptFence() + this.syncClientLightTraceConfig() + } + + private terminateClientLightOwner() { + if (this.clientLightOwnerSession) { + this.revertOwnerLightToIncoming('owner-disabled') + } + this.clientLightOwnerSession?.terminate() + this.clientLightOwnerSession = null + this.nextDirtyLightMeta = {} + } + + getClientLightOwnerFailureReason(): string | null { + return this.clientLightOwnerFailureReason ?? this.clientLightOwnerSession?.lastFailureReason ?? null + } + + columnIncarnationFor(columnKey: string): number { + return this.columnIncarnationByColumn.get(columnKey) ?? 1 + } + + private onClientLightOwnerFailed(reason: string) { + this.clientLightOwnerFailureReason = reason + this.revertOwnerLightToIncoming(reason) + } + + private revertOwnerLightToIncoming(reason: string) { + this.clientLightOwnerFence = { + sessionEpoch: this.clientLightOwnerFence.sessionEpoch + 1, + rejectStaleOwnerMeshes: true + } + this.clientLightOwnerFailureReason = reason + this.interactiveSections.clear() + this.clientLightOwnerSession?.requiredLightBySection.clear() + const message = { + type: 'revertOwnerLightToIncoming' as const, + sessionEpoch: this.clientLightOwnerFence.sessionEpoch, + worldGeneration: this.clientLightOwnerSession?.gate.acceptedGeneration ?? 1 + } + for (const worker of this.workers) { + worker.postMessage(message) + worker.postMessage({ + type: 'setOwnerAcceptFence', + sessionEpoch: this.clientLightOwnerFence.sessionEpoch, + rejectOwnerPublications: true + }) + } + } + + private postOwnerAcceptFence() { + for (const worker of this.workers) { + worker.postMessage({ + type: 'setOwnerAcceptFence', + sessionEpoch: this.clientLightOwnerFence.sessionEpoch, + rejectOwnerPublications: this.clientLightOwnerFence.rejectStaleOwnerMeshes + }) + } + } + + private onClientLightOwnerPublication(result: OwnerPublicationApplyResult) { + if (result.workerMessage) { + const workerMessage = { + ...result.workerMessage, + sessionEpoch: this.clientLightOwnerFence.sessionEpoch + } + const targets = meshWorkerIndexesForDirtySections(result.dirtyMeshSections, this.workers.length, (sx, sy, sz) => + this.getWorkerNumber(new Vec3(sx, sy, sz), true) + ) + const indexes = targets.length ? targets : this.workers.map((_, index) => index) + for (const index of indexes) { + this.workers[index]?.postMessage(workerMessage) + } + } + this.nextDirtyLightMeta = { + lightPublicationVersion: result.lastVersion, + worldGeneration: result.acceptedGeneration + } + this.noteCoveringPublication( + result.lastVersion, + result.dirtyMeshSections.map(section => `${section.sx},${section.sy},${section.sz}`) + ) + try { + if (this.clientLightOwnerSession) { + raiseRequiredLightRevisions( + this.clientLightOwnerSession.requiredLightBySection, + result.dirtyMeshSections, + result.lastVersion, + result.acceptedGeneration, + this.topologyRevisionBySection + ) + } + for (const section of result.dirtyMeshSections) { + const key = `${section.sx},${section.sy},${section.sz}` + const required = this.clientLightOwnerSession?.requiredLightForSection(key) + this.pendingCoveringBySection.set( + key, + required ?? { + requiredVersion: result.lastVersion, + worldGeneration: result.acceptedGeneration + } + ) + const urgentCovering = this.sectionTouchesInteractiveWork(key) + this.nextDirtyUrgent = urgentCovering + try { + this.setSectionDirty(new Vec3(section.sx, section.sy, section.sz), true, true) + } finally { + this.nextDirtyUrgent = false + } + } + } finally { + this.nextDirtyLightMeta = {} + } + } + + feedChunkPacket(payload: FeedChunkPacketPayload) { + const { kind, ...rest } = payload + const message = { type: kind, ...rest } + const isLightPacket = kind === 'setUpdateLightV17' || kind === 'setUpdateLightV16' + if (this.clientLightOwnerSession && isLightPacket) { + this.clientLightOwnerSession.pushRawUpdateLight(kind, rest as Record) + if (this.clientLightOwnerSession.isReady) return + } + for (const worker of this.workers) { + worker.postMessage(message) + } } private syncMesherPoolSnapshot() { @@ -400,6 +630,11 @@ export abstract class WorldRendererCommon this.sectionDirtyTimers.clear() this.sectionDirtyCount.clear() this.sectionDirtyPendingArgs.clear() + this.sectionDirtyLightMeta.clear() + this.pendingCoveringBySection.clear() + this.pendingTopologyBySection.clear() + this.rejectedFinishedBySection.clear() + this.interactiveSections.clear() this.reactiveState.world.mesherWork = false } @@ -488,6 +723,15 @@ export abstract class WorldRendererCommon this.changeCardinalLight(value) }) ) + this.valtioUnsubs.push( + this.onReactivePlayerStateUpdated( + 'lightingDisabled', + value => { + this.clientLightOwnerSession?.setSkyLightEnabled(skyLightEnabledFromRendererState({ lightingDisabled: value })) + }, + false + ) + ) } watchReactiveConfig() { @@ -496,6 +740,16 @@ export abstract class WorldRendererCommon setSkinsConfig({ apiEnabled: value }) }) ) + this.valtioUnsubs.push( + this.onReactiveConfigUpdated( + 'enableClientLightOwner', + enabled => { + if (enabled) this.maybeSpawnClientLightOwner() + else this.terminateClientLightOwner() + }, + false + ) + ) } async processMessageQueue(source: string) { @@ -546,16 +800,174 @@ export abstract class WorldRendererCommon this.isProcessingQueue = false } + protected evaluateOwnerGeometry(data: { + type?: string + key?: string + worldGeneration?: number + lightPublicationVersion?: number + topologyRevision?: number + sessionEpoch?: number + columnIncarnation?: number + hadErrors?: boolean + meshMode?: 'owner' | 'legacyBootstrap' + geometry?: { hadErrors?: boolean } + }): { accepted: boolean; keepCovering: boolean } { + if (data.type !== 'geometry') return { accepted: true, keepCovering: false } + const ownerManaged = this.clientLightOwnerSession?.isReady === true || this.clientLightOwnerFence.rejectStaleOwnerMeshes + if (!ownerManaged && this.clientLightOwnerSession == null) return { accepted: true, keepCovering: false } + if (data.key) { + const versions = validateMeshResultVersions({ ...data, key: data.key }) + if (!versions.ok && this.clientLightOwnerSession?.isReady) return { accepted: false, keepCovering: false } + } + const required = data.key ? this.clientLightOwnerSession?.requiredLightForSection(data.key) : undefined + const [x, , z] = (data.key ?? '').split(',').map(Number) + const accepted = shouldAcceptMeshGeometry( + { + worldGeneration: data.worldGeneration, + lightPublicationVersion: data.lightPublicationVersion, + topologyRevision: data.topologyRevision, + sessionEpoch: data.sessionEpoch, + columnIncarnation: data.columnIncarnation, + hadErrors: data.hadErrors ?? data.geometry?.hadErrors, + meshMode: data.meshMode + }, + this.clientLightOwnerSession?.gate ?? { acceptedGeneration: 1, lastVersion: 0 }, + required, + { + ownerManaged, + sessionEpoch: this.clientLightOwnerFence.sessionEpoch, + columnIncarnation: Number.isFinite(x) && Number.isFinite(z) ? this.columnIncarnationFor(`${x},${z}`) : undefined + } + ) + return { + accepted, + keepCovering: accepted && isOwnerGeometryLightStale(data, required) + } + } + + protected isClientLightOwnerSessionLive(): boolean { + return this.clientLightOwnerSession != null + } + + protected acknowledgeAcceptedOwnerTopology(data: { key?: string; topologyRevision?: number }) { + if (!data.key || data.topologyRevision == null) return + const pending = this.pendingTopologyBySection.get(data.key) + if (pending != null && data.topologyRevision >= pending) { + this.pendingTopologyBySection.delete(data.key) + } + } + + protected maybeDispatchCoveringRemesh(key: string, required?: MeshSectionLightRequirement | null) { + required ??= this.clientLightOwnerSession?.requiredLightForSection(key) + if (required == null) return + const outstandingCount = this.sectionsWaiting.get(key) ?? 0 + const pending = this.pendingCoveringBySection.get(key) + if ( + !shouldDispatchCoveringRemesh({ + required, + outstandingCount, + pendingCoveringVersion: pending?.requiredVersion, + pendingCoveringGeneration: pending?.worldGeneration + }) + ) { + return + } + const replacement = coveringReplacementForReject({ accepted: false, required }) + const [x, y, z] = key.split(',').map(Number) + if (replacement && Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z) && this.loadedChunks[`${x},${z}`]) { + this.pendingCoveringBySection.set(key, { + requiredVersion: replacement.lightPublicationVersion, + worldGeneration: replacement.worldGeneration, + topologyRevision: required.topologyRevision + }) + this.sectionDirtyLightMeta.set(key, replacement) + this.nextDirtyLightMeta = replacement + this.nextDirtyUrgent = this.sectionTouchesInteractiveWork(key) + try { + this._dispatchDirtyImmediate(new Vec3(x, y, z), true, true) + } finally { + this.nextDirtyLightMeta = {} + this.nextDirtyUrgent = false + } + } + } + + protected noteRejectedOwnerGeometry(key: string, required: MeshSectionLightRequirement | null | undefined, opts?: { expectLaterFinished?: boolean }) { + if (opts?.expectLaterFinished !== false) { + this.rejectedFinishedBySection.set(key, (this.rejectedFinishedBySection.get(key) ?? 0) + 1) + return + } + this.maybeDispatchCoveringRemesh(key, required) + } + + private rejectStaleOwnerGeometry( + data: MesherMainEvent & { + worldGeneration?: number + lightPublicationVersion?: number + topologyRevision?: number + sessionEpoch?: number + columnIncarnation?: number + meshMode?: 'owner' | 'legacyBootstrap' + } + ): boolean { + if (data.type !== 'geometry') return false + const decision = this.evaluateOwnerGeometry(data) + if (!decision.accepted) { + this.noteRejectedOwnerGeometry(data.key, this.clientLightOwnerSession?.requiredLightForSection(data.key), { + expectLaterFinished: true + }) + return true + } + this.acknowledgeAcceptedOwnerTopology(data) + if (decision.keepCovering) this.maybeDispatchCoveringRemesh(data.key) + else this.releaseInteractiveSectionIfLightCommitted(data) + return false + } + handleMessage(rawData: any) { - const data = rawData as MesherMainEvent + const data = rawData as MesherMainEvent & { + worldGeneration?: number + lightPublicationVersion?: number + clientLightRequestId?: number + clientLightEditSeq?: number + clientLightSessionEpoch?: number + } if (!this.active) return + if (isClientLightTraceMessage(rawData)) { + ingestRemoteClientLightTraceMessage(rawData) + return + } this.mesherLogReader?.workerMessageReceived(data.type, data) - if (data.type !== 'geometry' || !this.debugStopGeometryUpdate) { + const staleOwnerGeometry = this.rejectStaleOwnerGeometry(data) + if (staleOwnerGeometry && data.type === 'geometry' && isClientLightTraceEnabled()) { + recordClientLightTrace({ + phase: 'reject', + sectionKey: data.key, + requestId: data.clientLightRequestId, + editSeq: data.clientLightEditSeq, + sessionEpoch: data.clientLightSessionEpoch, + lightVersion: data.lightPublicationVersion, + worldGeneration: data.worldGeneration + }) + } + if ((data.type !== 'geometry' || !this.debugStopGeometryUpdate) && !staleOwnerGeometry) { const start = performance.now() this.handleWorkerMessage(data as WorkerReceive) this.workerCustomHandleTime += performance.now() - start } if (data.type === 'geometry') { + if (!staleOwnerGeometry && isClientLightTraceEnabled()) { + recordClientLightTrace({ + phase: 'receive', + sectionKey: data.key, + requestId: data.clientLightRequestId, + editSeq: data.clientLightEditSeq, + sessionEpoch: data.clientLightSessionEpoch, + lightVersion: data.lightPublicationVersion, + worldGeneration: data.worldGeneration, + workerIndex: data.workerIndex + }) + } this.logWorkerWork(() => `-> ${data.workerIndex} geometry ${data.key} ${JSON.stringify({ dataSize: JSON.stringify(data).length })}`) this.geometryReceiveCount[data.workerIndex] ??= 0 this.geometryReceiveCount[data.workerIndex]++ @@ -565,6 +977,13 @@ export abstract class WorldRendererCommon if (data.type === 'sectionFinished') { // on after load & unload section this.logWorkerWork(`<- ${data.workerIndex} sectionFinished ${data.key} ${JSON.stringify({ processTime: data.processTime })}`) + const rejectedFinishes = this.rejectedFinishedBySection.get(data.key) ?? 0 + const rejectedFinish = rejectedFinishes > 0 + if (rejectedFinish) { + const nextRejected = rejectedFinishes - 1 + if (nextRejected === 0) this.rejectedFinishedBySection.delete(data.key) + else this.rejectedFinishedBySection.set(data.key, nextRejected) + } if (!this.sectionsWaiting.has(data.key)) { console.debug(`sectionFinished for non-outstanding section ${data.key} (viewDistance=${this.viewDistance})`) return @@ -572,7 +991,14 @@ export abstract class WorldRendererCommon this.sectionsWaiting.set(data.key, this.sectionsWaiting.get(data.key)! - 1) if (this.sectionsWaiting.get(data.key) === 0) { this.sectionsWaiting.delete(data.key) - this.finishedSections[data.key] = true + if (!rejectedFinish) { + this.pendingCoveringBySection.delete(data.key) + this.finishedSections[data.key] = true + } + } + if (rejectedFinish) { + this.maybeDispatchCoveringRemesh(data.key) + if (!this.finishedSections[data.key]) return } const chunkCoords = data.key.split(',').map(Number) @@ -646,6 +1072,8 @@ export abstract class WorldRendererCommon this.workersPreOtherAverageTime = (this.workersPreOtherAverageTime * (n - 1) + po) / n this.workersPreCacheHitsTotal += pch this.workersPreCacheMissesTotal += pcm + if (typeof data.preWasmParseHits === 'number') this.workersPreWasmParseHitsTotal += data.preWasmParseHits + if (typeof data.preWasmParseMisses === 'number') this.workersPreWasmParseMissesTotal += data.preWasmParseMisses if (this.worldRendererConfig.debugWasmPerf && n % WorldRendererCommon.PHASE_PERF_LOG_INTERVAL === 0) { const total = this.workersPreAverageTime + this.workersWasmAverageTime + this.workersPostAverageTime const prePct = total > 0 ? (this.workersPreAverageTime / total) * 100 : 0 @@ -660,9 +1088,11 @@ export abstract class WorldRendererCommon const nbrPerAvg = nbrCnt > 0 ? this.workersPreNeighborConvertAverageTime / nbrCnt : 0 const cacheTotal = this.workersPreCacheHitsTotal + this.workersPreCacheMissesTotal const cacheHitPct = cacheTotal > 0 ? (this.workersPreCacheHitsTotal / cacheTotal) * 100 : 0 + const wasmParseTotal = this.workersPreWasmParseHitsTotal + this.workersPreWasmParseMissesTotal + const wasmParseHitPct = wasmParseTotal > 0 ? (this.workersPreWasmParseHitsTotal / wasmParseTotal) * 100 : 0 // eslint-disable-next-line no-console console.log( - `[wasm-mesher perf] n=${n} pre=${this.workersPreAverageTime.toFixed(2)}ms (${prePct.toFixed(1)}%) wasm=${this.workersWasmAverageTime.toFixed(2)}ms (${wasmPct.toFixed(1)}%) post=${this.workersPostAverageTime.toFixed(2)}ms (${postPct.toFixed(1)}%) | pre.targetConvert=${this.workersPreTargetConvertAverageTime.toFixed(2)}ms (${tgtPct.toFixed(1)}%) pre.neighborConvert=${this.workersPreNeighborConvertAverageTime.toFixed(2)}ms (${nbrPct.toFixed(1)}%) [n̄=${nbrCnt.toFixed(2)}, per-nbr=${nbrPerAvg.toFixed(2)}ms] pre.typedArrayBuild=${this.workersPreTypedArrayBuildAverageTime.toFixed(2)}ms (${tabPct.toFixed(1)}%) pre.other=${this.workersPreOtherAverageTime.toFixed(2)}ms (${othPct.toFixed(1)}%) | pre.cache hits=${this.workersPreCacheHitsTotal} misses=${this.workersPreCacheMissesTotal} (${cacheHitPct.toFixed(1)}% hit)` + `[wasm-mesher perf] n=${n} pre=${this.workersPreAverageTime.toFixed(2)}ms (${prePct.toFixed(1)}%) wasm=${this.workersWasmAverageTime.toFixed(2)}ms (${wasmPct.toFixed(1)}%) post=${this.workersPostAverageTime.toFixed(2)}ms (${postPct.toFixed(1)}%) | pre.targetConvert=${this.workersPreTargetConvertAverageTime.toFixed(2)}ms (${tgtPct.toFixed(1)}%) pre.neighborConvert=${this.workersPreNeighborConvertAverageTime.toFixed(2)}ms (${nbrPct.toFixed(1)}%) [n̄=${nbrCnt.toFixed(2)}, per-nbr=${nbrPerAvg.toFixed(2)}ms] pre.typedArrayBuild=${this.workersPreTypedArrayBuildAverageTime.toFixed(2)}ms (${tabPct.toFixed(1)}%) pre.other=${this.workersPreOtherAverageTime.toFixed(2)}ms (${othPct.toFixed(1)}%) | pre.cache hits=${this.workersPreCacheHitsTotal} misses=${this.workersPreCacheMissesTotal} (${cacheHitPct.toFixed(1)}% hit) | pre.wasmParse hits=${this.workersPreWasmParseHitsTotal} misses=${this.workersPreWasmParseMissesTotal} (${wasmParseHitPct.toFixed(1)}% hit)` ) } } @@ -772,11 +1202,13 @@ export abstract class WorldRendererCommon abstract updateShowChunksBorder(value: boolean): void resetWorld() { + this.interactiveSections.clear() // destroy workers for (const worker of this.workers) { worker.terminate() } this.workers = [] + this.terminateClientLightOwner() } async resetWorkers() { @@ -885,7 +1317,8 @@ export abstract class WorldRendererCommon x, z, chunk, - customBlockModels: customBlockModels || undefined + customBlockModels: customBlockModels || undefined, + isLightUpdate }) } // WASM mesher pushes heightmaps from `processColumnTick` after each @@ -906,6 +1339,15 @@ export abstract class WorldRendererCommon ) this.mesherLogReader?.chunkReceived(x, z, chunk.length) this.cameraCollisionBlockCache.ingestColumn(x, z, chunk) + this.rendererLightCache.ingestColumn(x, z, chunk) + this.clientLightOwnerSession?.ingestColumn({ + chunkX: x, + chunkZ: z, + chunkJson: chunk, + version: this.version, + worldMinY: this.worldSizeParams.minY, + worldHeight: this.worldSizeParams.worldHeight + }) const sectionHeight = this.getSectionHeight() const CHUNK_SIZE = 16 @@ -947,6 +1389,7 @@ export abstract class WorldRendererCommon this.sectionDirtyTimers.delete(key) this.sectionDirtyCount.delete(key) this.sectionDirtyPendingArgs.delete(key) + this.sectionDirtyLightMeta.delete(key) } } for (let i = 0; i < this.workers.length; i++) { @@ -971,10 +1414,27 @@ export abstract class WorldRendererCommon ) } this.sectionsWaiting.delete(sectionKey) + this.interactiveSections.delete(sectionKey) delete this.finishedSections[sectionKey] } this.highestBlocksByChunks.delete(`${x},${z}`) this.cameraCollisionBlockCache.removeColumn(x, z) + this.rendererLightCache.removeColumn(x, z) + this.clientLightOwnerSession?.onUnload(x, z) + const columnKey = `${x},${z}` + this.columnIncarnationByColumn.set(columnKey, this.columnIncarnationFor(columnKey) + 1) + for (const key of [...this.topologyRevisionBySection.keys()]) { + if (key.startsWith(`${x},`) && key.endsWith(`,${z}`)) this.topologyRevisionBySection.delete(key) + } + for (const key of [...this.pendingCoveringBySection.keys()]) { + if (key.startsWith(`${x},`) && key.endsWith(`,${z}`)) this.pendingCoveringBySection.delete(key) + } + for (const key of [...this.pendingTopologyBySection.keys()]) { + if (key.startsWith(`${x},`) && key.endsWith(`,${z}`)) this.pendingTopologyBySection.delete(key) + } + for (const key of [...this.rejectedFinishedBySection.keys()]) { + if (key.startsWith(`${x},`) && key.endsWith(`,${z}`)) this.rejectedFinishedBySection.delete(key) + } const heightmapKey = `${Math.floor(x / 16)},${Math.floor(z / 16)}` delete this.reactiveState.world.heightmaps[heightmapKey] @@ -1052,6 +1512,7 @@ export abstract class WorldRendererCommon ({ x, z, chunk, worldConfig, isLightUpdate }) => { this.worldSizeParams = worldConfig this.cameraCollisionBlockCache.setWorldBounds(worldConfig.minY, worldConfig.worldHeight) + this.rendererLightCache.setWorldBounds(worldConfig.minY, worldConfig.worldHeight) this.queuedChunks.add(`${x},${z}`) const args = [x, z, chunk, isLightUpdate] if (!currentLoadChunkBatch) { @@ -1156,6 +1617,7 @@ export abstract class WorldRendererCommon 'onWorldSwitch', () => { this.cameraCollisionBlockCache.clear() + this.rendererLightCache.clear() for (const fn of this.onWorldSwitched) { try { fn() @@ -1248,44 +1710,71 @@ export abstract class WorldRendererCommon this.logWorkerWork(`-> blockUpdate ${JSON.stringify({ pos, stateId, customBlockModels })}`) if (stateId !== undefined) { this.cameraCollisionBlockCache.setBlockStateId(pos.x, pos.y, pos.z, stateId) + this.clientLightOwnerSession?.onBlockChange(pos.x, pos.y, pos.z, stateId) } - this.setSectionDirty(pos, true, true) + this.bumpTopologyRevision(pos) if (this.neighborChunkUpdates) { - const CHUNK_SIZE = 16 const sectionHeight = this.getSectionHeight() - if ((pos.x & 15) === 0) this.setSectionDirty(pos.offset(-CHUNK_SIZE, 0, 0), true, true) - if ((pos.x & 15) === 15) this.setSectionDirty(pos.offset(CHUNK_SIZE, 0, 0), true, true) - if ((pos.y & (sectionHeight - 1)) === 0) this.setSectionDirty(pos.offset(0, -sectionHeight, 0), true, true) - if ((pos.y & (sectionHeight - 1)) === sectionHeight - 1) this.setSectionDirty(pos.offset(0, sectionHeight, 0), true, true) - if ((pos.z & 15) === 0) this.setSectionDirty(pos.offset(0, 0, -CHUNK_SIZE), true, true) - if ((pos.z & 15) === 15) this.setSectionDirty(pos.offset(0, 0, CHUNK_SIZE), true, true) - - if (needAoRecalculation) { - // top view neighbors - if ((pos.x & 15) === 0 && (pos.z & 15) === 0) this.setSectionDirty(pos.offset(-CHUNK_SIZE, 0, -CHUNK_SIZE), true, true) - if ((pos.x & 15) === 15 && (pos.z & 15) === 0) this.setSectionDirty(pos.offset(CHUNK_SIZE, 0, -CHUNK_SIZE), true, true) - if ((pos.x & 15) === 0 && (pos.z & 15) === 15) this.setSectionDirty(pos.offset(-CHUNK_SIZE, 0, CHUNK_SIZE), true, true) - if ((pos.x & 15) === 15 && (pos.z & 15) === 15) this.setSectionDirty(pos.offset(CHUNK_SIZE, 0, CHUNK_SIZE), true, true) - - // side view neighbors (but ignore updates above) - // z view neighbors - if ((pos.x & 15) === 0 && (pos.y & (sectionHeight - 1)) === 0) this.setSectionDirty(pos.offset(-CHUNK_SIZE, -sectionHeight, 0), true, true) - if ((pos.x & 15) === 15 && (pos.y & (sectionHeight - 1)) === 0) this.setSectionDirty(pos.offset(CHUNK_SIZE, -sectionHeight, 0), true, true) - - // x view neighbors - if ((pos.z & 15) === 0 && (pos.y & (sectionHeight - 1)) === 0) this.setSectionDirty(pos.offset(0, -sectionHeight, -CHUNK_SIZE), true, true) - if ((pos.z & 15) === 15 && (pos.y & (sectionHeight - 1)) === 0) this.setSectionDirty(pos.offset(0, -sectionHeight, CHUNK_SIZE), true, true) - - // x & z neighbors - if ((pos.y & (sectionHeight - 1)) === 0 && (pos.x & 15) === 0 && (pos.z & 15) === 0) - this.setSectionDirty(pos.offset(-CHUNK_SIZE, -sectionHeight, -CHUNK_SIZE), true, true) - if ((pos.y & (sectionHeight - 1)) === 0 && (pos.x & 15) === 15 && (pos.z & 15) === 0) - this.setSectionDirty(pos.offset(CHUNK_SIZE, -sectionHeight, -CHUNK_SIZE), true, true) - if ((pos.y & (sectionHeight - 1)) === 0 && (pos.x & 15) === 0 && (pos.z & 15) === 15) - this.setSectionDirty(pos.offset(-CHUNK_SIZE, -sectionHeight, CHUNK_SIZE), true, true) - if ((pos.y & (sectionHeight - 1)) === 0 && (pos.x & 15) === 15 && (pos.z & 15) === 15) - this.setSectionDirty(pos.offset(CHUNK_SIZE, -sectionHeight, CHUNK_SIZE), true, true) + if ((pos.x & 15) === 0) this.bumpTopologyRevision(pos.offset(-CHUNK_SIZE, 0, 0)) + if ((pos.x & 15) === 15) this.bumpTopologyRevision(pos.offset(CHUNK_SIZE, 0, 0)) + if ((pos.y & (sectionHeight - 1)) === 0) this.bumpTopologyRevision(pos.offset(0, -sectionHeight, 0)) + if ((pos.y & (sectionHeight - 1)) === sectionHeight - 1) this.bumpTopologyRevision(pos.offset(0, sectionHeight, 0)) + if ((pos.z & 15) === 0) this.bumpTopologyRevision(pos.offset(0, 0, -CHUNK_SIZE)) + if ((pos.z & 15) === 15) this.bumpTopologyRevision(pos.offset(0, 0, CHUNK_SIZE)) + } + if (isClientLightTraceEnabled()) { + const sectionHeight = this.getSectionHeight() + const CHUNK_SIZE = 16 + recordClientLightTrace({ + phase: 'blockChange', + editSeq: nextClientLightEditSeq(), + sectionKey: `${Math.floor(pos.x / CHUNK_SIZE) * CHUNK_SIZE},${Math.floor(pos.y / sectionHeight) * sectionHeight},${Math.floor(pos.z / CHUNK_SIZE) * CHUNK_SIZE}`, + blockVersion: stateId + }) + } + this.noteInteractiveSection(pos) + this.nextDirtyUrgent = true + try { + this.setSectionDirty(pos, true, true) + if (this.neighborChunkUpdates) { + const CHUNK_SIZE = 16 + const sectionHeight = this.getSectionHeight() + if ((pos.x & 15) === 0) this.setSectionDirty(pos.offset(-CHUNK_SIZE, 0, 0), true, true) + if ((pos.x & 15) === 15) this.setSectionDirty(pos.offset(CHUNK_SIZE, 0, 0), true, true) + if ((pos.y & (sectionHeight - 1)) === 0) this.setSectionDirty(pos.offset(0, -sectionHeight, 0), true, true) + if ((pos.y & (sectionHeight - 1)) === sectionHeight - 1) this.setSectionDirty(pos.offset(0, sectionHeight, 0), true, true) + if ((pos.z & 15) === 0) this.setSectionDirty(pos.offset(0, 0, -CHUNK_SIZE), true, true) + if ((pos.z & 15) === 15) this.setSectionDirty(pos.offset(0, 0, CHUNK_SIZE), true, true) + + if (needAoRecalculation) { + // top view neighbors + if ((pos.x & 15) === 0 && (pos.z & 15) === 0) this.setSectionDirty(pos.offset(-CHUNK_SIZE, 0, -CHUNK_SIZE), true, true) + if ((pos.x & 15) === 15 && (pos.z & 15) === 0) this.setSectionDirty(pos.offset(CHUNK_SIZE, 0, -CHUNK_SIZE), true, true) + if ((pos.x & 15) === 0 && (pos.z & 15) === 15) this.setSectionDirty(pos.offset(-CHUNK_SIZE, 0, CHUNK_SIZE), true, true) + if ((pos.x & 15) === 15 && (pos.z & 15) === 15) this.setSectionDirty(pos.offset(CHUNK_SIZE, 0, CHUNK_SIZE), true, true) + + // side view neighbors (but ignore updates above) + // z view neighbors + if ((pos.x & 15) === 0 && (pos.y & (sectionHeight - 1)) === 0) this.setSectionDirty(pos.offset(-CHUNK_SIZE, -sectionHeight, 0), true, true) + if ((pos.x & 15) === 15 && (pos.y & (sectionHeight - 1)) === 0) this.setSectionDirty(pos.offset(CHUNK_SIZE, -sectionHeight, 0), true, true) + + // x view neighbors + if ((pos.z & 15) === 0 && (pos.y & (sectionHeight - 1)) === 0) this.setSectionDirty(pos.offset(0, -sectionHeight, -CHUNK_SIZE), true, true) + if ((pos.z & 15) === 15 && (pos.y & (sectionHeight - 1)) === 0) this.setSectionDirty(pos.offset(0, -sectionHeight, CHUNK_SIZE), true, true) + + // x & z neighbors + if ((pos.y & (sectionHeight - 1)) === 0 && (pos.x & 15) === 0 && (pos.z & 15) === 0) + this.setSectionDirty(pos.offset(-CHUNK_SIZE, -sectionHeight, -CHUNK_SIZE), true, true) + if ((pos.y & (sectionHeight - 1)) === 0 && (pos.x & 15) === 15 && (pos.z & 15) === 0) + this.setSectionDirty(pos.offset(CHUNK_SIZE, -sectionHeight, -CHUNK_SIZE), true, true) + if ((pos.y & (sectionHeight - 1)) === 0 && (pos.x & 15) === 0 && (pos.z & 15) === 15) + this.setSectionDirty(pos.offset(-CHUNK_SIZE, -sectionHeight, CHUNK_SIZE), true, true) + if ((pos.y & (sectionHeight - 1)) === 0 && (pos.x & 15) === 15 && (pos.z & 15) === 15) + this.setSectionDirty(pos.offset(CHUNK_SIZE, -sectionHeight, CHUNK_SIZE), true, true) + } } + } finally { + this.nextDirtyUrgent = false } } @@ -1376,6 +1865,28 @@ export abstract class WorldRendererCommon const sectionHeight = this.getSectionHeight() const key = `${Math.floor(pos.x / CHUNK_SIZE) * CHUNK_SIZE},${Math.floor(pos.y / sectionHeight) * sectionHeight},${Math.floor(pos.z / CHUNK_SIZE) * CHUNK_SIZE}` + this.sectionDirtyLightMeta ??= new Map() + if (this.nextDirtyLightMeta.lightPublicationVersion != null) { + const prev = this.sectionDirtyLightMeta.get(key) + const incomingVersion = this.nextDirtyLightMeta.lightPublicationVersion + if (!prev || incomingVersion >= prev.lightPublicationVersion) { + this.sectionDirtyLightMeta.set(key, { + lightPublicationVersion: incomingVersion, + worldGeneration: this.nextDirtyLightMeta.worldGeneration + }) + } + } + + if (this.nextDirtyLightMeta.lightPublicationVersion != null || this.nextDirtyUrgent) { + this._dispatchDirtyImmediate(pos, value, useChangeWorker) + const pendingTimer = this.sectionDirtyTimers.get(key) + if (pendingTimer) clearTimeout(pendingTimer) + this.sectionDirtyTimers.delete(key) + this.sectionDirtyCount.delete(key) + this.sectionDirtyPendingArgs.delete(key) + return + } + const currentCount = (this.sectionDirtyCount.get(key) ?? 0) + 1 this.sectionDirtyCount.set(key, currentCount) @@ -1393,14 +1904,19 @@ export abstract class WorldRendererCommon this.sectionDirtyTimers.delete(key) this.sectionDirtyPendingArgs.delete(key) if (args) { - this._dispatchDirtyImmediate(args.pos, args.value, args.useChangeWorker) + this.dispatchPendingDirty(args) } }, WorldRendererCommon.GEOMETRY_THROTTLE_DELAY) ) } } else { // Subsequent requests — throttle: store latest args, existing timer will dispatch - this.sectionDirtyPendingArgs.set(key, { pos, value, useChangeWorker }) + this.sectionDirtyPendingArgs.set(key, { + pos, + value, + useChangeWorker, + urgent: this.nextDirtyUrgent || this.sectionDirtyPendingArgs.get(key)?.urgent + }) if (!this.sectionDirtyTimers.has(key)) { this.sectionDirtyTimers.set( @@ -1411,7 +1927,7 @@ export abstract class WorldRendererCommon this.sectionDirtyTimers.delete(key) this.sectionDirtyPendingArgs.delete(key) if (args) { - this._dispatchDirtyImmediate(args.pos, args.value, args.useChangeWorker) + this.dispatchPendingDirty(args) } }, WorldRendererCommon.GEOMETRY_THROTTLE_DELAY) ) @@ -1419,6 +1935,99 @@ export abstract class WorldRendererCommon } } + private dispatchPendingDirty(args: { pos: Vec3; value: boolean; useChangeWorker: boolean; urgent?: boolean }) { + const previousUrgent = this.nextDirtyUrgent + this.nextDirtyUrgent = !!args.urgent + try { + this._dispatchDirtyImmediate(args.pos, args.value, args.useChangeWorker) + } finally { + this.nextDirtyUrgent = previousUrgent + } + } + + private sectionKeyFor(x: number, y: number, z: number): string { + const CHUNK_SIZE = 16 + const sectionHeight = this.getSectionHeight() + return `${Math.floor(x / CHUNK_SIZE) * CHUNK_SIZE},${Math.floor(y / sectionHeight) * sectionHeight},${Math.floor(z / CHUNK_SIZE) * CHUNK_SIZE}` + } + + private noteInteractiveSection(pos: Vec3) { + const key = this.sectionKeyFor(pos.x, pos.y, pos.z) + const requiredVersion = this.clientLightOwnerSession?.requiredLightForSection(key)?.requiredVersion ?? 0 + const gateVersion = this.clientLightOwnerSession?.gate.lastVersion ?? 0 + const prev = this.interactiveSections.get(key) + this.interactiveSections.set(key, { + lightVersionAtEdit: Math.max(prev?.lightVersionAtEdit ?? 0, requiredVersion, gateVersion), + editGeneration: this.clientLightOwnerSession?.notePlayerEdit() ?? 0, + coveringVersion: undefined + }) + } + + private sectionTouchesInteractiveWork(key: string): boolean { + if (this.interactiveSections.has(key)) return true + const [x, y, z] = key.split(',').map(Number) + if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return false + const sectionHeight = this.getSectionHeight() + for (const source of this.interactiveSections.keys()) { + const [sx, sy, sz] = source.split(',').map(Number) + if (Math.abs(x - sx) <= 16 && Math.abs(y - sy) <= sectionHeight && Math.abs(z - sz) <= 16) return true + } + return false + } + + private noteCoveringPublication(version: number, dirtyKeys: Iterable) { + const dirty = new Set(dirtyKeys) + for (const [key, edit] of this.interactiveSections) { + if (!dirty.has(key) || version <= edit.lightVersionAtEdit) continue + this.interactiveSections.set(key, { + lightVersionAtEdit: edit.lightVersionAtEdit, + editGeneration: edit.editGeneration, + coveringVersion: Math.max(edit.coveringVersion ?? 0, version) + }) + } + } + + private releaseInteractiveEditsWithoutCoveringPublication(settledEditGeneration: number) { + for (const [key, edit] of this.interactiveSections) { + if (edit.coveringVersion == null && edit.editGeneration <= settledEditGeneration) this.interactiveSections.delete(key) + } + } + + private releaseInteractiveSectionIfLightCommitted(data: { key?: string; lightPublicationVersion?: number }) { + if (!data.key || data.lightPublicationVersion == null) return + const edit = this.interactiveSections.get(data.key) + if (edit?.coveringVersion == null || data.lightPublicationVersion < edit.coveringVersion) return + this.interactiveSections.delete(data.key) + } + + private bumpTopologyRevision(pos: Vec3) { + const CHUNK_SIZE = 16 + const sectionHeight = this.getSectionHeight() + const key = `${Math.floor(pos.x / CHUNK_SIZE) * CHUNK_SIZE},${Math.floor(pos.y / sectionHeight) * sectionHeight},${Math.floor(pos.z / CHUNK_SIZE) * CHUNK_SIZE}` + const next = nextTopologyRevision(this.topologyRevisionBySection.get(key)) + this.topologyRevisionBySection.set(key, next) + if (this.clientLightOwnerSession != null) { + this.pendingTopologyBySection.set(key, next) + } + } + + private snapshotNeighborTopology(key: string): Array<{ key: string; topologyRevision: number }> { + const [x, y, z] = key.split(',').map(Number) + if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return [] + const sectionHeight = this.getSectionHeight() + const out: Array<{ key: string; topologyRevision: number }> = [] + for (let dx = -16; dx <= 16; dx += 16) { + for (let dy = -sectionHeight; dy <= sectionHeight; dy += sectionHeight) { + for (let dz = -16; dz <= 16; dz += 16) { + const neighborKey = `${x + dx},${y + dy},${z + dz}` + const topologyRevision = this.topologyRevisionBySection.get(neighborKey) + if (topologyRevision != null) out.push({ key: neighborKey, topologyRevision }) + } + } + } + return out + } + /** Dispatch dirty message to worker without throttle (original logic) */ private _dispatchDirtyImmediate(pos: Vec3, value: boolean, useChangeWorker: boolean) { this.reactiveState.world.mesherWork = true @@ -1432,25 +2041,59 @@ export abstract class WorldRendererCommon // is always dispatched to the same worker const hash = this.getWorkerNumber(pos, useChangeWorker && (this.mesherLogger.active || this.worldRendererConfig.dedicatedChangeWorker)) this.sectionsWaiting.set(key, (this.sectionsWaiting.get(key) ?? 0) + 1) - if (this.forceCallFromMesherReplayer) { - this.workers[hash].postMessage({ - type: 'dirty', - x: pos.x, - y: pos.y, - z: pos.z, - value, - config: this.getMesherConfig() + this.sectionDirtyLightMeta ??= new Map() + const lightMeta = this.nextDirtyLightMeta.lightPublicationVersion != null ? this.nextDirtyLightMeta : this.sectionDirtyLightMeta.get(key) + const ownerManaged = this.clientLightOwnerSession != null || this.clientLightOwnerFence.rejectStaleOwnerMeshes + const requestId = ownerManaged || isClientLightTraceEnabled() ? nextClientLightRequestId() : undefined + const traceIds = isClientLightTraceEnabled() ? { ...currentClientLightTraceIds(), requestId } : undefined + const ownerVersions = ownerManaged + ? { + sessionEpoch: this.clientLightOwnerFence.sessionEpoch, + columnIncarnation: this.columnIncarnationFor(`${Math.floor(pos.x / CHUNK_SIZE) * CHUNK_SIZE},${Math.floor(pos.z / CHUNK_SIZE) * CHUNK_SIZE}`), + requestId, + topologyRevision: this.topologyRevisionBySection.get(key) ?? INITIAL_TOPOLOGY_REVISION, + neighborTopologyRevisions: this.snapshotNeighborTopology(key) + } + : {} + const dirtyMessage = { + type: 'dirty' as const, + x: pos.x, + y: pos.y, + z: pos.z, + value, + config: this.getMesherConfig(), + ...(lightMeta?.lightPublicationVersion != null + ? { + lightPublicationVersion: lightMeta.lightPublicationVersion, + worldGeneration: lightMeta.worldGeneration + } + : {}), + ...ownerVersions, + ...(this.nextDirtyUrgent ? { urgent: true as const } : {}), + ...(traceIds + ? { + clientLightRequestId: traceIds.requestId, + clientLightEditSeq: traceIds.editSeq, + clientLightSessionEpoch: traceIds.sessionEpoch + } + : {}) + } + if (traceIds) { + recordClientLightTrace({ + phase: 'mesherEnqueue', + sectionKey: key, + requestId: traceIds.requestId, + editSeq: traceIds.editSeq, + sessionEpoch: traceIds.sessionEpoch, + lightVersion: lightMeta?.lightPublicationVersion, + worldGeneration: lightMeta?.worldGeneration }) + } + if (this.forceCallFromMesherReplayer) { + this.workers[hash].postMessage(dirtyMessage) } else { this.toWorkerMessagesQueue[hash] ??= [] - this.toWorkerMessagesQueue[hash].push({ - type: 'dirty', - x: pos.x, - y: pos.y, - z: pos.z, - value, - config: this.getMesherConfig() - }) + this.toWorkerMessagesQueue[hash].push(dirtyMessage) this.dispatchMessages() } } @@ -1531,12 +2174,15 @@ export abstract class WorldRendererCommon this.sectionDirtyTimers.clear() this.sectionDirtyCount.clear() this.sectionDirtyPendingArgs.clear() + this.sectionDirtyLightMeta.clear() + setClientLightTraceConfigListener(undefined) // Stop all workers for (const worker of this.workers) { worker.terminate() } this.workers = [] + this.terminateClientLightOwner() // Stop and destroy sound system if (this.soundSystem) { diff --git a/src/mesher-shared/lightNibblePack.ts b/src/mesher-shared/lightNibblePack.ts new file mode 100644 index 0000000..35cba43 --- /dev/null +++ b/src/mesher-shared/lightNibblePack.ts @@ -0,0 +1,25 @@ +/** Packed 2048-byte nibble section ↔ unpacked 4096-byte per-block values. */ + +export const PACKED_LIGHT_SECTION_BYTES = 2048 +export const UNPACKED_LIGHT_SECTION_BYTES = 4096 + +export function packUnpackedLightSection(unpacked: Uint8Array): Uint8Array { + const packed = new Uint8Array(PACKED_LIGHT_SECTION_BYTES) + const n = Math.min(unpacked.length, UNPACKED_LIGHT_SECTION_BYTES) + for (let i = 0; i < n; i++) { + const v = unpacked[i]! & 0x0f + const byte = i >> 1 + packed[byte] = i & 1 ? (packed[byte]! & 0x0f) | (v << 4) : (packed[byte]! & 0xf0) | v + } + return packed +} + +export function unpackPackedLightSection(packed: Uint8Array): Uint8Array { + const unpacked = new Uint8Array(UNPACKED_LIGHT_SECTION_BYTES) + const n = Math.min(packed.length, PACKED_LIGHT_SECTION_BYTES) + for (let i = 0; i < n * 2 && i < UNPACKED_LIGHT_SECTION_BYTES; i++) { + const byte = packed[i >> 1] ?? 0 + unpacked[i] = i & 1 ? byte >> 4 : byte & 0x0f + } + return unpacked +} diff --git a/src/mesher-shared/shared.ts b/src/mesher-shared/shared.ts index 9ed068d..26dd5da 100644 --- a/src/mesher-shared/shared.ts +++ b/src/mesher-shared/shared.ts @@ -86,8 +86,53 @@ export type MesherGeometryOutput = { visibilitySet?: number } +export type MeshGeometryMode = 'owner' | 'legacyBootstrap' + +export type MesherGeometryVersionFields = { + sessionEpoch?: number + columnIncarnation?: number + requestId?: number + topologyRevision?: number + lightPublicationVersion?: number + worldGeneration?: number + neighborTopologyRevisions?: Array<{ key: string; topologyRevision: number }> + meshMode?: MeshGeometryMode + clientLightRequestId?: number + clientLightEditSeq?: number + clientLightSessionEpoch?: number +} + +export type MesherWorkerDirtyMessage = { + type: 'dirty' + x: number + y: number + z: number + value?: boolean + config?: MesherConfig +} & MesherGeometryVersionFields + +export type MesherWorkerOwnerPublicationMessage = { + type: 'applyOwnerLightPublication' + worldGeneration: number + publicationVersion: number + sessionEpoch?: number + sections: Array<{ sx: number; sy: number; sz: number; blockLight: Uint8Array; skyLight?: Uint8Array }> +} + +export type MesherWorkerRevertOwnerMessage = { + type: 'revertOwnerLightToIncoming' + sessionEpoch: number + worldGeneration: number +} + +export type MesherWorkerOwnerFenceMessage = { + type: 'setOwnerAcceptFence' + sessionEpoch: number + rejectOwnerPublications: boolean +} + export interface MesherMainEvents { - geometry: { type: 'geometry'; key: string; geometry: MesherGeometryOutput; workerIndex: number } + geometry: { type: 'geometry'; key: string; geometry: MesherGeometryOutput; workerIndex: number } & MesherGeometryVersionFields sectionFinished: { type: 'sectionFinished' key: string @@ -107,6 +152,8 @@ export interface MesherMainEvents { // Per-event counts for the column-mode conversion cache. preCacheHits?: number preCacheMisses?: number + preWasmParseHits?: number + preWasmParseMisses?: number chunkCount?: number worldColumns3x3?: number parsedCache3x3?: number diff --git a/src/three/chunkMeshManager.ts b/src/three/chunkMeshManager.ts index c8755e9..de14c8b 100644 --- a/src/three/chunkMeshManager.ts +++ b/src/three/chunkMeshManager.ts @@ -34,6 +34,7 @@ import { disposeObject } from './threeJsUtils' import { getBannerTexture, createBannerMesh, releaseBannerTexture } from './bannerRenderer' import { getSignTexture, releaseSignTexture, disposeAllSignTextures } from './signTextureCache' import { BlockEntityLightRegistry } from '../lib/blockEntityLightRegistry' +import { isClientLightTraceEnabled, recordClientLightTraceGpuSample } from '../lib/clientLightTrace' import { SectionOcclusionCull, hsvToRgb } from './occlusion/sectionOcclusionCull' export interface ChunkMeshPool { @@ -284,6 +285,15 @@ export class ChunkMeshManager { if (this.globalLegacyShaderMaterial) setLegacySkyLevel(this.globalLegacyShaderMaterial, value) if (this.globalLegacyBlendShaderMaterial) setLegacySkyLevel(this.globalLegacyBlendShaderMaterial, value) this.blockEntityLightRegistry.setSkyLevel(value) + this.worldRenderer.entityLightController?.setSkyLevel(value) + } + + getSkyLevel(): number { + return this.blockEntityLightRegistry.getSkyLevel() + } + + getLightmapParams(): BlockLightmapParams { + return this.blockEntityLightRegistry.getLightmapParams() } setShadingTheme(theme: 'vanilla' | 'high-contrast', cardinalLight: string): void { @@ -299,6 +309,7 @@ export class ChunkMeshManager { if (this.globalLegacyShaderMaterial) setLegacyLightmapParams(this.globalLegacyShaderMaterial, params) if (this.globalLegacyBlendShaderMaterial) setLegacyLightmapParams(this.globalLegacyBlendShaderMaterial, params) this.blockEntityLightRegistry.setLightmapParams(params) + this.worldRenderer.entityLightController?.setLightmapParams(params) } private getLegacyShaderMaterial(): THREE.ShaderMaterial { @@ -590,6 +601,21 @@ export class ChunkMeshManager { gb.setVisibleSpans(spans) } + if (isClientLightTraceEnabled()) { + const cubeFaces = gb?.getVisibleFaceCount() ?? 0 + const legacyQuads = (opaqueBuf?.getVisibleQuadCount() ?? 0) + (blendBuf?.getVisibleQuadCount() ?? 0) + const drawableFaces = cubeFaces + legacyQuads + recordClientLightTraceGpuSample('gpuDrawn', drawableFaces, () => ({ + phase: 'gpuDrawn', + drawableFaces, + pendingReplace: !!(gb?.hasPendingReplace() || opaqueBuf?.hasPendingReplace() || blendBuf?.hasPendingReplace()), + pendingMove: !!(gb?.getPendingMove() || opaqueBuf?.getPendingMove() || blendBuf?.getPendingMove()), + unuploadedRanges: + (gb?.getPendingDirtyRanges().length ?? 0) + (opaqueBuf?.getPendingDirtyRanges().length ?? 0) + (blendBuf?.getPendingDirtyRanges().length ?? 0), + cullReason: smartCull ? 'frustum+occlusion' : 'frustum' + })) + } + this.lastBufferStateKey = this.bufferStateKey() this.updatePooledLegacyCullState(cameraWorldX, cameraWorldY, cameraWorldZ, smartCull, occlusionVisible) } diff --git a/src/three/clientLightOwner.test.ts b/src/three/clientLightOwner.test.ts new file mode 100644 index 0000000..7b0629e --- /dev/null +++ b/src/three/clientLightOwner.test.ts @@ -0,0 +1,436 @@ +import { describe, expect, it, vi } from 'vitest' +import Chunks from 'prismarine-chunk' +import MinecraftData from 'minecraft-data' +import { Vec3 } from 'vec3' +import { RendererLightCache } from './rendererLightCache' +import { defaultWorldRendererConfig } from '../graphicsBackend/config' +import { LightOwnerHost, type LightPublication, type PublicationGate } from './lightOwnerHost' +import { maskBitSet, worldSectionMaskBit, type ParsedUpdateLight } from '../wasm-mesher/worker/mesherWasmLightMerge' +import { + applyOwnerPublicationToRenderer, + blockChangeEvent, + ClientLightOwnerSession, + dirtyMeshSectionsFromChangedLight, + eventsFromColumnLoad, + meshWorkerIndexesForDirtySections, + eventsFromColumnUnload, + eventsFromParsedUpdateLight, + packUnpackedLightSection, + raiseRequiredLightRevisions, + shouldAcceptMeshGeometry, + clientLightOwnerVersionBlockReason, + shouldSpawnClientLightOwner, + skyLightEnabledFromRendererState, + unpackPackedLightSection +} from './clientLightOwner' + +const VERSION = '1.17.1' + +function packedNibble(value: number): Uint8Array { + const n = value & 0x0f + return new Uint8Array(2048).fill(n | (n << 4)) +} + +function makeAirColumnJson() { + const Chunk = Chunks(VERSION) as any + const chunk = new Chunk() + return chunk.toJson() +} + +function unpackedSection(value: number): Uint8Array { + return new Uint8Array(4096).fill(value & 0x0f) +} + +function makeSession() { + let onMessage: (data: any) => void = () => {} + const worker = { + postMessage: () => {}, + terminate: () => {}, + onerror: null as ((event: ErrorEvent) => void) | null + } + const cache = new RendererLightCache(VERSION) + cache.setWorldBounds(0, 256) + const session = new ClientLightOwnerSession(cache, { + createWorker: handler => { + onMessage = handler + return worker as unknown as Worker + }, + worldMinY: 0, + worldHeight: 256, + skyLightEnabled: true, + onApplied: () => {} + }) + return { session, worker, deliver: (data: any) => onMessage(data) } +} + +describe('client light owner flag', () => { + it('stays default-off so production does not spawn the owner', () => { + expect(defaultWorldRendererConfig.enableClientLightOwner).toBe(false) + expect(shouldSpawnClientLightOwner(defaultWorldRendererConfig, '1.17.1')).toBe(false) + }) + + it('spawns only when the flag is true and the session is 1.17.1', () => { + expect(shouldSpawnClientLightOwner({ enableClientLightOwner: true }, '1.17.1')).toBe(true) + expect(shouldSpawnClientLightOwner({ enableClientLightOwner: false }, '1.17.1')).toBe(false) + expect(shouldSpawnClientLightOwner({}, '1.17.1')).toBe(false) + expect(shouldSpawnClientLightOwner({ enableClientLightOwner: true }, '1.16.5')).toBe(false) + expect(shouldSpawnClientLightOwner({ enableClientLightOwner: true }, '1.18.2')).toBe(false) + expect(clientLightOwnerVersionBlockReason('1.17.1')).toBeNull() + expect(clientLightOwnerVersionBlockReason('1.16.5')).toMatch(/1\.17\.1/) + }) +}) + +describe('client light owner lifecycle', () => { + it('starts in starting and is not ready until the worker reports ready', () => { + const { session } = makeSession() + expect(session.state).toBe('starting') + expect(session.isReady).toBe(false) + }) + + it('becomes ready after the worker ready message', () => { + const { session, deliver } = makeSession() + deliver({ type: 'ready' }) + expect(session.state).toBe('ready') + expect(session.isReady).toBe(true) + }) + + it('goes to failed on a worker error message', () => { + const { session, deliver } = makeSession() + deliver({ type: 'error', error: 'NetworkError loading lightOwnerWorker.js' }) + expect(session.state).toBe('failed') + expect(session.isReady).toBe(false) + }) + + it('goes to failed on worker onerror (script 404)', () => { + const { session, worker } = makeSession() + expect(typeof worker.onerror).toBe('function') + worker.onerror?.({ type: 'error', message: 'NetworkError' } as ErrorEvent) + expect(session.state).toBe('failed') + expect(session.isReady).toBe(false) + }) + + it('does not leave failed after a late ready', () => { + const { session, deliver } = makeSession() + deliver({ type: 'error', error: 'init failed' }) + deliver({ type: 'ready' }) + expect(session.state).toBe('failed') + }) +}) + +describe('owner step scheduling', () => { + it('does not bounce remaining slices through main setTimeout', () => { + vi.useFakeTimers() + try { + const posts: Array<{ type?: string }> = [] + let onMessage: (data: any) => void = () => {} + const worker = { + postMessage: (message: { type?: string }) => { + posts.push(message) + }, + terminate: () => {}, + onerror: null as ((event: ErrorEvent) => void) | null + } + const cache = new RendererLightCache(VERSION) + cache.setWorldBounds(0, 256) + const session = new ClientLightOwnerSession(cache, { + createWorker: handler => { + onMessage = handler + return worker as unknown as Worker + }, + worldMinY: 0, + worldHeight: 256, + skyLightEnabled: true, + onApplied: () => {} + }) + onMessage({ type: 'ready' }) + posts.length = 0 + session.pushEvent(blockChangeEvent(8, 64, 8, 1)) + vi.runAllTimers() + expect(posts.filter(message => message.type === 'step')).toHaveLength(1) + posts.length = 0 + onMessage({ type: 'stepped', remaining: true, publication: null }) + vi.runAllTimers() + expect(posts.filter(message => message.type === 'step')).toHaveLength(0) + } finally { + vi.useRealTimers() + } + }) + + it('selects only mesh workers that own dirty sections', () => { + expect( + meshWorkerIndexesForDirtySections( + [ + { sx: 0, sy: 64, sz: 0 }, + { sx: 16, sy: 64, sz: 0 }, + { sx: 0, sy: 80, sz: 0 } + ], + 8, + (sx, _sy, sz) => (((sx / 16 + sz / 16) % 8) + 8) % 8 + ).sort((a, b) => a - b) + ).toEqual([0, 1]) + }) +}) + +describe('nether/end sky detection', () => { + it('disables sky from lightingDisabled (nether/end / !hasSkyLight)', () => { + expect(skyLightEnabledFromRendererState({ lightingDisabled: false })).toBe(true) + expect(skyLightEnabledFromRendererState({ lightingDisabled: true })).toBe(false) + expect(skyLightEnabledFromRendererState({})).toBe(true) + }) +}) + +describe('dirtyMeshSections stencil', () => { + it('covers the 3x3x3 sample neighborhood, including the diagonal section', () => { + const dirty = dirtyMeshSectionsFromChangedLight([{ sx: 0, sy: 64, sz: 0 }], { coords: 'world' }) + const keys = new Set(dirty.map(s => `${s.sx},${s.sy},${s.sz}`)) + expect(keys.has('0,64,0')).toBe(true) + expect(keys.has('-16,64,0')).toBe(true) + expect(keys.has('16,64,0')).toBe(true) + expect(keys.has('0,48,0')).toBe(true) + expect(keys.has('0,80,0')).toBe(true) + expect(keys.has('0,64,-16')).toBe(true) + expect(keys.has('0,64,16')).toBe(true) + expect(keys.has('16,80,16')).toBe(true) + expect(keys.size).toBe(27) + }) + + it('schedules geometry (16,16,16) when light (0,0,0) changes — west-face corner samples (15,15,15)', () => { + const dirty = dirtyMeshSectionsFromChangedLight([{ sx: 0, sy: 0, sz: 0 }], { coords: 'world' }) + expect(dirty.some(s => s.sx === 16 && s.sy === 16 && s.sz === 16)).toBe(true) + }) + + it('converts engine section-index publications to world origins before expanding', () => { + const dirty = dirtyMeshSectionsFromChangedLight([{ sx: 0, sy: 4, sz: 0 }], { coords: 'section-index' }) + expect(dirty.some(s => s.sx === 0 && s.sy === 64 && s.sz === 0)).toBe(true) + expect(dirty.some(s => s.sx === 0 && s.sy === 80 && s.sz === 0)).toBe(true) + }) +}) + +describe('versioned mesh drop', () => { + const gate: PublicationGate = { acceptedGeneration: 2, lastVersion: 5 } + + it('accepts geometry with no version (flag-off / pre-owner mesh path)', () => { + expect(shouldAcceptMeshGeometry({}, gate)).toBe(true) + }) + + it('owner-managed path rejects unversioned geometry as current', () => { + const required = { requiredVersion: 5, worldGeneration: 2 } + expect(shouldAcceptMeshGeometry({}, gate, required, { ownerManaged: true })).toBe(false) + }) + + it('accepts a mesh of an independent section after an unrelated later publication', () => { + expect(shouldAcceptMeshGeometry({ worldGeneration: 1, lightPublicationVersion: 1 }, { acceptedGeneration: 1, lastVersion: 2 })).toBe(true) + }) + + it('drops a mesh below the required revision of its own section', () => { + const required = { requiredVersion: 5, worldGeneration: 2 } + expect(shouldAcceptMeshGeometry({ worldGeneration: 1, lightPublicationVersion: 9 }, gate, required)).toBe(false) + expect(shouldAcceptMeshGeometry({ worldGeneration: 2, lightPublicationVersion: 4 }, gate, required)).toBe(false) + }) + + it('accepts geometry stamped with the required publication of its section', () => { + expect(shouldAcceptMeshGeometry({ worldGeneration: 2, lightPublicationVersion: 5 }, gate, { requiredVersion: 5, worldGeneration: 2 })).toBe(true) + }) +}) + +describe('raiseRequiredLightRevisions', () => { + it('keeps the higher topology revision when a later publication raises light version', () => { + const required = new Map() + raiseRequiredLightRevisions(required, [{ sx: 0, sy: 64, sz: 0 }], 2, 1) + required.set('0,64,0', { ...required.get('0,64,0')!, topologyRevision: 4 }) + raiseRequiredLightRevisions(required, [{ sx: 0, sy: 64, sz: 0 }], 5, 1) + expect(required.get('0,64,0')).toEqual({ requiredVersion: 5, worldGeneration: 1, topologyRevision: 4 }) + }) + + it('does not roll topology back when merging a lower live snapshot after a higher one', () => { + const required = new Map() + raiseRequiredLightRevisions(required, [{ sx: 0, sy: 64, sz: 0 }], 2, 1, new Map([['0,64,0', 7]])) + raiseRequiredLightRevisions(required, [{ sx: 0, sy: 64, sz: 0 }], 3, 1, new Map([['0,64,0', 5]])) + expect(required.get('0,64,0')).toEqual({ requiredVersion: 3, worldGeneration: 1, topologyRevision: 7 }) + }) +}) + +describe('packed nibble convert', () => { + it('round-trips unpacked 4096 nibble-bytes through packed 2048', () => { + const unpacked = unpackedSection(0) + unpacked[0] = 7 + unpacked[1] = 14 + unpacked[256] = 3 + const packed = packUnpackedLightSection(unpacked) + expect(packed).toHaveLength(2048) + const back = unpackPackedLightSection(packed) + expect(back[0]).toBe(7) + expect(back[1]).toBe(14) + expect(back[256]).toBe(3) + }) +}) + +describe('update_light → owner serverLight events', () => { + it('emits omitted/empty/data independently for sky and block', () => { + const skyLightMask = new Uint32Array(2) + const emptySkyLightMask = new Uint32Array(2) + const blockLightMask = new Uint32Array(2) + const emptyBlockLightMask = new Uint32Array(2) + maskBitSet(blockLightMask, worldSectionMaskBit(4)) + maskBitSet(emptyBlockLightMask, worldSectionMaskBit(0)) + maskBitSet(skyLightMask, worldSectionMaskBit(4)) + const blockLight = new Uint8Array(16 * 4096) + blockLight.fill(9, 4 * 4096, 5 * 4096) + const skyLight = new Uint8Array(16 * 4096) + skyLight.fill(15, 4 * 4096, 5 * 4096) + const parsed: ParsedUpdateLight = { + x: 1, + z: 2, + trustEdges: true, + numSections: 16, + skyLight, + blockLight, + skyLightMask, + emptySkyLightMask, + blockLightMask, + emptyBlockLightMask + } + const events = eventsFromParsedUpdateLight(parsed, 0) + const blockData = events.find(e => e.type === 'serverLight' && e.channel === 'block' && e.sy === 4 && e.kind === 'data') + const blockEmpty = events.find(e => e.type === 'serverLight' && e.channel === 'block' && e.sy === 0 && e.kind === 'empty') + const skyData = events.find(e => e.type === 'serverLight' && e.channel === 'sky' && e.sy === 4 && e.kind === 'data') + const omittedBlock = events.find(e => e.type === 'serverLight' && e.channel === 'block' && e.sy === 1) + expect(blockData).toBeTruthy() + expect(blockEmpty).toBeTruthy() + expect(skyData).toBeTruthy() + expect(omittedBlock).toBeUndefined() + if (blockData?.type === 'serverLight' && blockData.kind === 'data') { + expect(blockData.data).toHaveLength(2048) + expect(blockData.sx).toBe(1) + expect(blockData.sz).toBe(2) + } + }) +}) + +describe('column load events', () => { + it('ingests block sections from prismarine JSON as Loaded (main thread, not mesh workers)', () => { + const mcData = MinecraftData(VERSION) + const Chunk = Chunks(VERSION) as any + const chunk = new Chunk() + const torch = mcData.blocksByName.torch.defaultState + chunk.setBlockStateId(new Vec3(8, 64, 8), torch) + const events = eventsFromColumnLoad({ + chunkX: 0, + chunkZ: 16, + chunkJson: chunk.toJson(), + version: VERSION, + worldMinY: 0, + worldHeight: 256 + }) + const ingest = events.filter(e => e.type === 'ingestBlockSection') + const loaded = events.filter(e => e.type === 'setAvailability' && e.availability === 'loaded') + expect(ingest.length).toBeGreaterThan(0) + expect(loaded.length).toBe(ingest.length) + const section4 = ingest.find(e => e.type === 'ingestBlockSection' && e.sy === 4 && e.sz === 1) + expect(section4?.type === 'ingestBlockSection' && section4.states[8 + 8 * 16 + 0 * 256]).toBe(torch) + expect(blockChangeEvent(8, 64, 8, 0)).toEqual({ type: 'blockChange', x: 8, y: 64, z: 8, stateId: 0 }) + expect(eventsFromColumnUnload(32, 48)).toEqual({ type: 'unloadColumn', sx: 2, sz: 3 }) + }) + + it('does not call getBlockStateId 4096 times per section when section data exists', () => { + const mcData = MinecraftData(VERSION) + const Chunk = Chunks(VERSION) as any + const chunk = new Chunk() + const torch = mcData.blocksByName.torch.defaultState + const stone = mcData.blocksByName.stone.defaultState + chunk.setBlockStateId(new Vec3(8, 64, 8), torch) + chunk.setBlockStateId(new Vec3(3, 20, 3), stone) + let calls = 0 + const original = chunk.getBlockStateId.bind(chunk) + chunk.getBlockStateId = (pos: Vec3) => { + calls++ + return original(pos) + } + const events = eventsFromColumnLoad({ + chunkX: 0, + chunkZ: 0, + chunkJson: chunk, + version: VERSION, + worldMinY: 0, + worldHeight: 256 + }) + expect(calls).toBe(0) + const ingest = events.filter(e => e.type === 'ingestBlockSection') + const section4 = ingest.find(e => e.type === 'ingestBlockSection' && e.sy === 4) + const section1 = ingest.find(e => e.type === 'ingestBlockSection' && e.sy === 1) + expect(section4?.type === 'ingestBlockSection' && section4.states[8 + 8 * 16 + 0 * 256]).toBe(torch) + expect(section1?.type === 'ingestBlockSection' && section1.states[3 + 3 * 16 + 4 * 256]).toBe(stone) + }) +}) + +describe('apply publication → cache + dirty mesh + worker fan-out', () => { + it('writes packed channels directly and reports dirtyMeshSections plus a worker message', () => { + const cache = new RendererLightCache(VERSION) + cache.setWorldBounds(0, 256) + const pub: LightPublication = { + worldGeneration: 1, + publicationVersion: 3, + sections: [{ sx: 0, sy: 4, sz: 0, blockLight: packedNibble(9), skyLight: packedNibble(2) }] + } + const result = applyOwnerPublicationToRenderer(cache, pub, { acceptedGeneration: 1, lastVersion: 0 }, 'section-index') + expect(result.applied).toBe(true) + expect(result.lastVersion).toBe(3) + expect(cache.getLight(0, 64, 0).block).toBeCloseTo(11 / 15, 5) + expect(cache.getLight(0, 64, 0).sky).toBeCloseTo(4 / 15, 5) + expect(result.dirtyMeshSections.some(s => s.sx === 0 && s.sy === 64 && s.sz === 0)).toBe(true) + expect(result.dirtyMeshSections.some(s => s.sx === 16 && s.sy === 64 && s.sz === 0)).toBe(true) + expect(result.workerMessage?.type).toBe('applyOwnerLightPublication') + expect(result.workerMessage?.publicationVersion).toBe(3) + expect(result.workerMessage?.sections[0]?.blockLight).toHaveLength(2048) + }) + + it('rejects a foreign publication with the wrong packed length and does not write the cache', () => { + const cache = new RendererLightCache(VERSION) + cache.setWorldBounds(0, 256) + const result = applyOwnerPublicationToRenderer( + cache, + { + worldGeneration: 1, + publicationVersion: 4, + sections: [{ sx: 0, sy: 4, sz: 0, blockLight: new Uint8Array(8) }] + }, + { acceptedGeneration: 1, lastVersion: 0 }, + 'section-index' + ) + expect(result.applied).toBe(false) + expect(result.workerMessage).toBeNull() + expect(cache.getLight(0, 64, 0).block).toBe(0) + }) +}) + +describe('flag-on in-process host: load / setBlock / unload → publication → cache', () => { + it('ingests a torch place and then air through the owner ABI', async () => { + const cache = new RendererLightCache(VERSION) + cache.setWorldBounds(0, 256) + const host = await LightOwnerHost.createInProcess(cache, { worldMinY: 0, worldHeight: 256 }) + host.setLightTables({ + emission: Uint8Array.of(0, 0, 14), + opacity: Uint8Array.of(0, 15, 0) + }) + for (const event of eventsFromColumnLoad({ + chunkX: 0, + chunkZ: 0, + chunkJson: makeAirColumnJson(), + version: VERSION, + worldMinY: 0, + worldHeight: 256 + })) { + if ('sy' in event && event.sy !== 4) continue + host.pushEvent(event) + } + host.pushEvent(blockChangeEvent(8, 64, 8, 2)) + const placed = await host.stepUntilIdle(64) + expect(placed?.publicationVersion).toBeGreaterThan(0) + expect(cache.getLight(8, 64, 8).block).toBeGreaterThan(0.5) + host.pushEvent(blockChangeEvent(8, 64, 8, 0)) + await host.stepUntilIdle(32) + expect(cache.getLight(8, 64, 8).block).toBeCloseTo(2 / 15, 5) + host.pushEvent(eventsFromColumnUnload(0, 0)) + await host.stepUntilIdle(16) + }) +}) diff --git a/src/three/clientLightOwner.ts b/src/three/clientLightOwner.ts new file mode 100644 index 0000000..49f2abb --- /dev/null +++ b/src/three/clientLightOwner.ts @@ -0,0 +1,508 @@ +/** + * Production wiring for the dedicated WASM light-owner, behind + * `enableClientLightOwner` (default OFF). + * + * Product forks (matches §10, simpler option): + * - `ingestBlockSection` is sent from the main thread from prismarine column + * JSON in `addColumn`. Mesh workers stay read-only copies; N workers must + * not each ingest (would duplicate/reorder). The renderer is the sequencer. + * - Nether/End sky: `playerStateReactive.lightingDisabled` is already true for + * the_nether / the_end / !has_skylight. That drives `setSkyLightEnabled`. + */ + +import Chunks from 'prismarine-chunk' +import { Vec3 } from 'vec3' +import { RendererLightCache } from './rendererLightCache' +import { applyLightPublication, sectionIndexToWorldOrigin, type LightOwnerEvent, type LightPublication, type PublicationGate } from './lightOwnerHost' +import { buildLightTables1171, LIGHT_TABLES_MC_VERSION } from './lightTables1171' +import { + comparableNow, + ingestRemoteClientLightTraceMessage, + isClientLightTraceEnabled, + isClientLightTraceMessage, + recordClientLightTrace +} from '../lib/clientLightTrace' +import { + shouldAcceptVersionedMesh, + validateOwnerPublicationMessage, + type MeshGeometryMode, + type MeshSectionLightRequirement as VersionedMeshRequirement +} from '../lib/clientLightVersions' + +export { packUnpackedLightSection, unpackPackedLightSection } from '../mesher-shared/lightNibblePack' +export { eventsFromParsedUpdateLight } from '../wasm-mesher/worker/updateLightToOwnerEvents' + +export const LIGHT_OWNER_WORKER_SCRIPT = 'lightOwnerWorker.js' + +export type ClientLightOwnerLifecycle = 'starting' | 'ready' | 'failed' + +/** Owner tables are the 1.17.1 registry. Another session version must not start the worker. */ +export function shouldSpawnClientLightOwner(config: { enableClientLightOwner?: boolean }, version: string): boolean { + return config.enableClientLightOwner === true && version === LIGHT_TABLES_MC_VERSION +} + +export function clientLightOwnerVersionBlockReason(version: string): string | null { + if (version === LIGHT_TABLES_MC_VERSION) return null + return `client light owner supports ${LIGHT_TABLES_MC_VERSION} only (session ${version})` +} + +/** Sky engine on unless the dimension already reported no skylight (nether/end). */ +export function skyLightEnabledFromRendererState(state: { lightingDisabled?: boolean }): boolean { + return state.lightingDisabled !== true +} + +export function blockChangeEvent(x: number, y: number, z: number, stateId: number): LightOwnerEvent { + return { type: 'blockChange', x, y, z, stateId } +} + +export function eventsFromColumnUnload(chunkX: number, chunkZ: number): LightOwnerEvent { + return { type: 'unloadColumn', sx: Math.floor(chunkX / 16), sz: Math.floor(chunkZ / 16) } +} + +export function dirtyMeshSectionsFromChangedLight( + changed: Array<{ sx: number; sy: number; sz: number }>, + opts?: { coords?: 'world' | 'section-index' } +): Array<{ sx: number; sy: number; sz: number }> { + const coords = opts?.coords ?? 'world' + const keys = new Set() + const out: Array<{ sx: number; sy: number; sz: number }> = [] + const add = (sx: number, sy: number, sz: number) => { + const key = `${sx},${sy},${sz}` + if (keys.has(key)) return + keys.add(key) + out.push({ sx, sy, sz }) + } + for (const section of changed) { + const origin = coords === 'section-index' ? sectionIndexToWorldOrigin(section.sx, section.sy, section.sz) : section + for (let dx = -16; dx <= 16; dx += 16) { + for (let dy = -16; dy <= 16; dy += 16) { + for (let dz = -16; dz <= 16; dz += 16) { + add(origin.sx + dx, origin.sy + dy, origin.sz + dz) + } + } + } + } + return out +} + +export type MeshSectionLightRequirement = VersionedMeshRequirement + +export function meshSectionKey(sx: number, sy: number, sz: number): string { + return `${sx},${sy},${sz}` +} + +/** Workers that mesh any dirty section; empty means fall back to every worker. */ +export function meshWorkerIndexesForDirtySections( + dirty: Array<{ sx: number; sy: number; sz: number }>, + workerCount: number, + indexForSection: (sx: number, sy: number, sz: number) => number +): number[] { + if (workerCount <= 0 || dirty.length === 0) return [] + const indexes = new Set() + for (const section of dirty) { + const index = indexForSection(section.sx, section.sy, section.sz) + if (index >= 0 && index < workerCount) indexes.add(index) + } + return [...indexes] +} + +export function raiseRequiredLightRevisions( + required: Map, + dirtyMeshSections: Array<{ sx: number; sy: number; sz: number }>, + publicationVersion: number, + worldGeneration: number, + topologyBySection?: Map +): void { + for (const section of dirtyMeshSections) { + const key = meshSectionKey(section.sx, section.sy, section.sz) + const prev = required.get(key) + const liveTopology = topologyBySection?.get(key) + const topologyRevision = Math.max(prev?.topologyRevision ?? 0, liveTopology ?? 0) || undefined + const next: MeshSectionLightRequirement = { + requiredVersion: prev?.requiredVersion ?? publicationVersion, + worldGeneration: prev?.worldGeneration ?? worldGeneration, + ...(topologyRevision != null ? { topologyRevision } : {}) + } + if (!prev || worldGeneration > prev.worldGeneration) { + next.requiredVersion = publicationVersion + next.worldGeneration = worldGeneration + } else if (worldGeneration === prev.worldGeneration && publicationVersion > prev.requiredVersion) { + next.requiredVersion = publicationVersion + } + if ( + !prev || + next.requiredVersion !== prev.requiredVersion || + next.worldGeneration !== prev.worldGeneration || + next.topologyRevision !== prev.topologyRevision + ) { + required.set(key, next) + } + } +} + +export function coveringReplacementForReject(opts: { + accepted: boolean + required?: MeshSectionLightRequirement | null +}): { lightPublicationVersion: number; worldGeneration: number } | null { + if (opts.accepted || opts.required == null) return null + return { lightPublicationVersion: opts.required.requiredVersion, worldGeneration: opts.required.worldGeneration } +} + +export function shouldAcceptMeshGeometry( + mesh: { + worldGeneration?: number + lightPublicationVersion?: number + topologyRevision?: number + sessionEpoch?: number + columnIncarnation?: number + hadErrors?: boolean + meshMode?: MeshGeometryMode + }, + _gate: PublicationGate, + required?: MeshSectionLightRequirement | null, + opts?: { ownerManaged?: boolean; sessionEpoch?: number; columnIncarnation?: number } +): boolean { + return shouldAcceptVersionedMesh(mesh, required, { + ownerManaged: opts?.ownerManaged === true, + sessionEpoch: opts?.sessionEpoch, + columnIncarnation: opts?.columnIncarnation + }) +} + +export function eventsFromColumnLoad(opts: { + chunkX: number + chunkZ: number + chunkJson: unknown + version: string + worldMinY: number + worldHeight: number +}): LightOwnerEvent[] { + const Chunk = Chunks(opts.version) as ReturnType + let column: { getBlockStateId: (pos: Vec3) => number } + try { + if (opts.chunkJson && typeof (opts.chunkJson as { getBlockStateId?: unknown }).getBlockStateId === 'function') { + column = opts.chunkJson as { getBlockStateId: (pos: Vec3) => number } + } else { + column = Chunk.fromJson(opts.chunkJson as Parameters['fromJson']>[0]) as unknown as { + getBlockStateId: (pos: Vec3) => number + } + } + } catch { + return [] + } + + const sx = Math.floor(opts.chunkX / 16) + const sz = Math.floor(opts.chunkZ / 16) + const events: LightOwnerEvent[] = [] + const worldMaxY = opts.worldMinY + opts.worldHeight + for (let y0 = opts.worldMinY; y0 < worldMaxY; y0 += 16) { + const states = new Uint16Array(4096) + fillSectionStates(column, y0, states) + const sy = Math.floor(y0 / 16) + events.push({ type: 'ingestBlockSection', sx, sy, sz, states }) + events.push({ type: 'setAvailability', sx, sy, sz, availability: 'loaded' }) + } + return events +} + +type SectionLike = { + palette?: number[] | null + data?: { get: (index: number) => number } + isEmpty?: () => boolean + getBlock?: (pos: { x: number; y: number; z: number }) => number +} + +function sectionAt(column: object, y0: number): SectionLike | null | undefined { + const withGet = column as { getSection?: (pos: { x: number; y: number; z: number }) => SectionLike | null } + if (typeof withGet.getSection === 'function') { + return withGet.getSection({ x: 0, y: y0, z: 0 }) + } + const withArray = column as { sections?: Array; minY?: number } + if (Array.isArray(withArray.sections)) { + return withArray.sections[(y0 - (withArray.minY ?? 0)) >> 4] + } + return undefined +} + +function fillSectionStates(column: { getBlockStateId: (pos: Vec3) => number }, y0: number, states: Uint16Array) { + const section = sectionAt(column, y0) + if (section === null) { + return + } + if (section && typeof section.isEmpty === 'function' && section.isEmpty()) { + return + } + if (section && Array.isArray(section.palette) && section.palette.length === 1) { + const only = section.palette[0] || 0 + if (only !== 0) states.fill(only) + return + } + if (section?.data && typeof section.data.get === 'function') { + const palette = section.palette + for (let i = 0; i < 4096; i++) { + let stateId = section.data.get(i) + if (palette) stateId = palette[stateId] + states[i] = stateId || 0 + } + return + } + const pos = new Vec3(0, y0, 0) + for (let ly = 0; ly < 16; ly++) { + pos.y = y0 + ly + for (let lz = 0; lz < 16; lz++) { + pos.z = lz + for (let lx = 0; lx < 16; lx++) { + pos.x = lx + let stateId = 0 + try { + stateId = column.getBlockStateId(pos) || 0 + } catch { + stateId = 0 + } + states[lx + lz * 16 + ly * 256] = stateId + } + } + } +} + +export type OwnerWorkerLightMessage = { + type: 'applyOwnerLightPublication' + worldGeneration: number + publicationVersion: number + sessionEpoch?: number + sections: Array<{ sx: number; sy: number; sz: number; blockLight: Uint8Array; skyLight?: Uint8Array }> +} + +export type OwnerPublicationApplyResult = { + applied: boolean + lastVersion: number + acceptedGeneration: number + dirtyMeshSections: Array<{ sx: number; sy: number; sz: number }> + workerMessage: OwnerWorkerLightMessage | null +} + +export function applyOwnerPublicationToRenderer( + cache: RendererLightCache, + publication: LightPublication, + gate: PublicationGate, + coords: 'world' | 'section-index' = 'section-index' +): OwnerPublicationApplyResult { + const checked = validateOwnerPublicationMessage(publication) + if (!checked.ok) { + return { + applied: false, + lastVersion: gate.lastVersion, + acceptedGeneration: gate.acceptedGeneration, + dirtyMeshSections: [], + workerMessage: null + } + } + const result = applyLightPublication(cache, publication, gate, coords) + if (!result.applied) { + return { + applied: false, + lastVersion: result.lastVersion, + acceptedGeneration: result.acceptedGeneration, + dirtyMeshSections: [], + workerMessage: null + } + } + const worldSections = publication.sections.map(section => { + const origin = coords === 'section-index' ? sectionIndexToWorldOrigin(section.sx, section.sy, section.sz) : section + return { + sx: origin.sx, + sy: origin.sy, + sz: origin.sz, + blockLight: section.blockLight, + skyLight: section.skyLight + } + }) + return { + applied: true, + lastVersion: result.lastVersion, + acceptedGeneration: result.acceptedGeneration, + dirtyMeshSections: dirtyMeshSectionsFromChangedLight(worldSections, { coords: 'world' }), + workerMessage: { + type: 'applyOwnerLightPublication', + worldGeneration: publication.worldGeneration, + publicationVersion: publication.publicationVersion, + sections: worldSections + } + } +} + +export function loadDefaultOwnerLightTables(): { emission: Uint8Array; opacity: Uint8Array; occupancy: Uint8Array } { + const tables = buildLightTables1171() + return { emission: tables.emission, opacity: tables.opacity, occupancy: tables.occupancy } +} + +export class ClientLightOwnerSession { + readonly worker: Worker + gate: PublicationGate = { acceptedGeneration: 1, lastVersion: 0 } + readonly requiredLightBySection = new Map() + private stepScheduled = false + private latestEditGeneration = 0 + private lifecycle: ClientLightOwnerLifecycle = 'starting' + private lastOwnerEnqueueAt = 0 + private failureReason: string | null = null + + constructor( + private readonly cache: RendererLightCache, + opts: { + createWorker: (onMessage: (data: any) => void) => Worker + worldMinY: number + worldHeight: number + skyLightEnabled: boolean + onApplied: (result: OwnerPublicationApplyResult) => void + onFailed?: (reason: string) => void + onOwnerIdle?: (settledEditGeneration: number) => void + } + ) { + this.onApplied = opts.onApplied + this.onFailed = opts.onFailed + this.onOwnerIdle = opts.onOwnerIdle + this.worker = opts.createWorker(data => this.onMessage(data)) + this.worker.onerror = event => { + this.fail(event?.message || 'light owner worker error') + } + this.worker.postMessage({ type: 'init', worldMinY: opts.worldMinY, worldHeight: opts.worldHeight }) + // 1.17.1 state ids. `shouldSpawnClientLightOwner` refuses every other session version before this runs. + const tables = loadDefaultOwnerLightTables() + this.worker.postMessage({ type: 'setLightTables', emission: tables.emission, opacity: tables.opacity, occupancy: tables.occupancy }) + this.worker.postMessage({ type: 'setSkyLightEnabled', enabled: opts.skyLightEnabled }) + } + + get state(): ClientLightOwnerLifecycle { + return this.lifecycle + } + + get isReady(): boolean { + return this.lifecycle === 'ready' + } + + get lastFailureReason(): string | null { + return this.failureReason + } + + private readonly onApplied: (result: OwnerPublicationApplyResult) => void + private readonly onFailed?: (reason: string) => void + private readonly onOwnerIdle?: (settledEditGeneration: number) => void + + notePlayerEdit(): number { + this.latestEditGeneration += 1 + return this.latestEditGeneration + } + + pushEvent(event: LightOwnerEvent) { + if (this.lifecycle === 'failed') return + this.worker.postMessage({ type: 'pushEvent', event }) + this.scheduleStep() + this.lastOwnerEnqueueAt = comparableNow() + if (isClientLightTraceEnabled()) { + const currentColumn = + 'x' in event && typeof event.x === 'number' + ? `${Math.floor(event.x / 16)},${Math.floor((event as { z: number }).z / 16)}` + : 'sx' in event && typeof event.sx === 'number' + ? `${event.sx},${(event as { sz: number }).sz}` + : undefined + recordClientLightTrace({ + phase: 'ownerEnqueue', + currentColumn, + eventAgeMs: 0 + }) + } + } + + pushRawUpdateLight(kind: 'setUpdateLightV17' | 'setUpdateLightV16', payload: Record) { + if (this.lifecycle === 'failed') return + this.worker.postMessage({ type: kind, ...payload }) + this.scheduleStep() + } + + setSkyLightEnabled(enabled: boolean) { + if (this.lifecycle === 'failed') return + this.worker.postMessage({ type: 'setSkyLightEnabled', enabled }) + this.scheduleStep() + } + + ingestColumn(opts: Parameters[0]) { + for (const event of eventsFromColumnLoad(opts)) this.pushEvent(event) + } + + onBlockChange(x: number, y: number, z: number, stateId: number) { + this.pushEvent(blockChangeEvent(x, y, z, stateId)) + } + + requiredLightForSection(key: string): MeshSectionLightRequirement | undefined { + return this.requiredLightBySection.get(key) + } + + onUnload(chunkX: number, chunkZ: number) { + this.pushEvent(eventsFromColumnUnload(chunkX, chunkZ)) + for (const key of [...this.requiredLightBySection.keys()]) { + const [sx, , sz] = key.split(',').map(Number) + if (sx === chunkX && sz === chunkZ) this.requiredLightBySection.delete(key) + } + } + + terminate() { + this.worker.terminate() + } + + private fail(reason: string) { + if (this.lifecycle === 'failed') return + this.lifecycle = 'failed' + this.failureReason = reason + this.stepScheduled = false + this.onFailed?.(reason) + } + + private scheduleStep() { + if (this.stepScheduled || this.lifecycle === 'failed') return + this.stepScheduled = true + setTimeout(() => { + this.stepScheduled = false + if (this.lifecycle === 'failed') return + this.worker.postMessage({ type: 'step', budgetMs: 5, stepGeneration: this.latestEditGeneration }) + }, 0) + } + + private onMessage(data: any) { + if (!data || typeof data !== 'object') return + if (isClientLightTraceMessage(data)) { + ingestRemoteClientLightTraceMessage(data) + return + } + if (data.type === 'error') { + this.fail(typeof data.error === 'string' ? data.error : 'light owner worker error') + return + } + if (this.lifecycle === 'failed') return + if (data.type === 'ready' || data.type === 'tablesSet') { + this.lifecycle = 'ready' + } + if (data.type === 'stepped') { + if (data.publication) this.applyPublication(data.publication) + if (data.remaining !== true && typeof data.stepGeneration === 'number') this.onOwnerIdle?.(data.stepGeneration) + } + if (data.type === 'publication' && data.publication) this.applyPublication(data.publication) + } + + private applyPublication(publication: LightPublication) { + if (this.lifecycle !== 'ready') return + const result = applyOwnerPublicationToRenderer(this.cache, publication, this.gate, 'section-index') + if (!result.applied) return + this.gate = { acceptedGeneration: result.acceptedGeneration, lastVersion: result.lastVersion } + raiseRequiredLightRevisions(this.requiredLightBySection, result.dirtyMeshSections, result.lastVersion, result.acceptedGeneration) + this.onApplied(result) + if (isClientLightTraceEnabled()) { + recordClientLightTrace({ + phase: 'ownerComplete', + lightVersion: result.lastVersion, + worldGeneration: result.acceptedGeneration, + queueDepth: result.dirtyMeshSections.length, + txnMs: this.lastOwnerEnqueueAt ? comparableNow() - this.lastOwnerEnqueueAt : undefined + }) + } + } +} diff --git a/src/three/entities.ts b/src/three/entities.ts index ab9924c..fcc5275 100644 --- a/src/three/entities.ts +++ b/src/three/entities.ts @@ -33,6 +33,7 @@ import { releaseVehiclePassengerPosition } from './entity/vehiclePassengerRender import { updateVehiclePassengerPositions as applyVehiclePassengerPositions } from './entity/vehiclePassengerUpdate' import { applyNetworkHeadPitch, storeNetworkHeadPitch, storeNetworkHeadYaw } from './entity/networkHeadPitchRendering' import { processRemoteBoatPassengerRotations, type RemoteBoatPassengerEntity } from './entity/remoteBoatPassengerRotation' +import { isClientLightTraceEnabled, recordClientLightTrace } from '../lib/clientLightTrace' import { ENTITY_TWEEN_DURATION_MS, applyLocalHorseCameraYawLock, @@ -61,6 +62,7 @@ import { WalkingGeneralSwing } from './entity/animations' import { disposeObject, loadNearestFilterTexture, loadTexture, loadThreeJsTextureFromUrl } from './threeJsUtils' import { armorModel, armorTextures, elytraTexture } from './entity/armorModels' import { WorldRendererThree } from './worldRendererThree' +import type { EntityLightMeta } from './entityLightController' import { IndexedData } from 'minecraft-data' import { ItemSpecificContextProperties } from '../playerState/types' @@ -385,9 +387,12 @@ export class Entities { } this.updateEntityEquipment(this.playerEntity, playerData) + this.registerEntityLight(this.playerEntity, this.playerEntity.originalEntity) + this.invalidateEntityLight(this.playerEntity) } clear() { + this.worldRenderer.entityLightController?.clear() for (const mesh of Object.values(this.entities)) { this.worldRenderer.sceneOrigin.removeAndUntrack(mesh) disposeObject(mesh) @@ -557,6 +562,7 @@ export class Entities { this.applyRemoteBoatPassengerRotations() this.applyLocalThirdPersonPlayerRotation() this.updateBoatPaddleAnimations(dt) + this.applyEntityLights() } private applyRemoteBoatPassengerRotations() { @@ -922,6 +928,7 @@ export class Entities { if (!playerObject || !playerObject.playerObject?.animation) return const anim = playerObject.playerObject.animation as any if (anim.swingArm) { + if (isClientLightTraceEnabled()) recordClientLightTrace({ phase: 'inputHand' }) anim.swingArm() } } @@ -934,6 +941,7 @@ export class Entities { if (!anim) return if (animation === 'oneSwing') { + if (isClientLightTraceEnabled()) recordClientLightTrace({ phase: 'inputHand' }) anim.swingArm() return } @@ -1082,6 +1090,7 @@ export class Entities { if (c['additionalCleanup']) c['additionalCleanup']() }) this.onRemoveEntity(entity) + this.worldRenderer.entityLightController?.unregister(e) this.worldRenderer.sceneOrigin.removeAndUntrack(e) disposeObject(e) // todo dispose textures as well ? @@ -1403,6 +1412,12 @@ export class Entities { this.updateNameTagVisibility(e) this.updateEntityPosition(entity, justAdded, overrides) + if (justAdded) { + this.registerEntityLight(e, entity) + } else { + this.worldRenderer.entityLightController?.setMeta(e, this.entityLightMeta(entity)) + this.invalidateEntityLight(e) + } } applyEntityRenderHints(e: SceneEntity, entity: SceneEntity['originalEntity']) { @@ -1699,8 +1714,52 @@ export class Entities { } } + onEntityMeshReady(mesh: THREE.Object3D) { + this.worldRenderer.entityLightController?.invalidateFromDescendant(mesh) + } + + private entityLightSharedMaterials() { + return new Set([this.worldRenderer.material]) + } + + private entityLightMeta(entity: SceneEntity['originalEntity']): EntityLightMeta { + const localPlayerId = this.playerEntity?.originalEntity.id + const useLocalEyeHeight = entity.name === 'player' && localPlayerId !== undefined && entity.id === localPlayerId + return { + name: entity.name, + height: entity.height, + eyeHeight: useLocalEyeHeight ? this.worldRenderer.playerStateReactive.eyeHeight : undefined + } + } + + private registerEntityLight(root: SceneEntity, entity: SceneEntity['originalEntity']) { + this.worldRenderer.entityLightController?.register(root, this.entityLightMeta(entity), this.entityLightSharedMaterials()) + } + + private invalidateEntityLight(root: THREE.Object3D) { + this.worldRenderer.entityLightController?.invalidateMaterials(root) + } + + private applyEntityLights() { + const controller = this.worldRenderer.entityLightController + if (!controller) return + const shared = this.entityLightSharedMaterials() + const apply = (root: SceneEntity | null) => { + if (!root) return + if (root === this.playerEntity) { + controller.setMeta(root, this.entityLightMeta(root.originalEntity)) + } + const worldPos = this.worldRenderer.sceneOrigin.getWorldPosition(root) + if (!worldPos) return + controller.update(root, worldPos, shared) + } + for (const entity of Object.values(this.entities)) apply(entity) + apply(this.playerEntity) + } + handleDamageEvent(entityId, damageAmount) { - const entityMesh = this.entities[entityId]?.children.find(c => c.name === 'mesh') + const entity = this.entities[entityId] + const entityMesh = entity?.children.find(c => c.name === 'mesh') if (entityMesh) { entityMesh.traverse(child => { if (child instanceof THREE.Mesh && child.material.clone) { @@ -1713,6 +1772,18 @@ export class Entities { } }) } + if (entity) { + this.worldRenderer.entityLightController?.markFlash(entity) + this.invalidateEntityLight(entity) + setTimeout(() => { + if (!this.entities[entityId]) return + this.worldRenderer.entityLightController?.endFlash(entity) + const worldPos = this.worldRenderer.sceneOrigin.getWorldPosition(entity) + if (worldPos) { + this.worldRenderer.entityLightController?.update(entity, worldPos, this.entityLightSharedMaterials()) + } + }, 500) + } } raycastSceneDebug() { diff --git a/src/three/entity/EntityMesh.ts b/src/three/entity/EntityMesh.ts index 0e62723..b330bfa 100644 --- a/src/three/entity/EntityMesh.ts +++ b/src/three/entity/EntityMesh.ts @@ -535,6 +535,7 @@ export class EntityMesh { controller.playAnimation(gltf.animations[0].name, loop) } } + worldRenderer?.entities.onEntityMeshReady(this.mesh) }) .catch(err => { console.error('Failed to load GLTF model:', err) diff --git a/src/three/entityLightController.ts b/src/three/entityLightController.ts new file mode 100644 index 0000000..ab58f84 --- /dev/null +++ b/src/three/entityLightController.ts @@ -0,0 +1,321 @@ +import * as THREE from 'three' +import { blockEntityBrightness, DEFAULT_LIGHTMAP_PARAMS, type BlockLightmapParams } from '../lib/blockEntityLighting' + +export type EntityLightOverride = 'normal' | 'fullbright' + +export type EntityLightWorldPos = { x: number; y: number; z: number } + +export type EntityLightMeta = { + name?: string + height?: number + eyeHeight?: number +} + +export type EntityLightSampler = { + getLight: (x: number, y: number, z: number) => { block: number; sky: number } + getSkyLevel: () => number + getLightmapParams: () => BlockLightmapParams + getColumnRevision: (chunkX: number, chunkZ: number) => number | undefined + getGlobalRevision: () => number + lightingEnabled: () => boolean +} + +const BASE_COLOR_KEY = 'entityLightBaseColor' +const LAST_APPLIED_COLOR_KEY = 'entityLightLastAppliedColor' +const COLOR_EPSILON = 1e-4 +const LIVING_EYE_HEIGHT_RATIO = 0.85 + +type ColorBearingMaterial = THREE.Material & { color: THREE.Color } + +type ControlledMaterial = { + material: ColorBearingMaterial + base: THREE.Color +} + +type TrackedRoot = { + meta: EntityLightMeta + override: EntityLightOverride + materials: ControlledMaterial[] + materialsDirty: boolean + flashing: boolean + lastProbe?: { x: number; y: number; z: number } + lastColumnRevision?: number + lastGlobalRevision?: number + lastSkyLevel?: number + lastLightmapParams?: BlockLightmapParams + lastLightingEnabled?: boolean + lastBrightness?: number +} + +const alignColumn = (coord: number) => Math.floor(coord / 16) * 16 + +export function resolveEntityEyeHeight(meta: EntityLightMeta = {}): number { + if (Number.isFinite(meta.eyeHeight)) return meta.eyeHeight as number + if (Number.isFinite(meta.height)) return (meta.height as number) * LIVING_EYE_HEIGHT_RATIO + return 0 +} + +export function entityProbeBlock(worldPos: EntityLightWorldPos, eyeHeight = 0): { x: number; y: number; z: number } { + return { + x: Math.floor(worldPos.x), + y: Math.floor(worldPos.y + eyeHeight), + z: Math.floor(worldPos.z) + } +} + +export function resolveEntityLightOverride(name?: string): EntityLightOverride { + return name === 'glow_item_frame' ? 'fullbright' : 'normal' +} + +type Rgb = { r: number; g: number; b: number } + +const hasColor = (material: THREE.Material): material is ColorBearingMaterial => !!material && (material as ColorBearingMaterial).color instanceof THREE.Color + +const readRgb = (material: ColorBearingMaterial, key: string): THREE.Color | undefined => { + const stored = material.userData?.[key] as Rgb | undefined + if (!stored || !Number.isFinite(stored.r) || !Number.isFinite(stored.g) || !Number.isFinite(stored.b)) return undefined + return new THREE.Color(stored.r, stored.g, stored.b) +} + +const storeRgb = (material: ColorBearingMaterial, key: string, color: THREE.Color) => { + material.userData[key] = { r: color.r, g: color.g, b: color.b } +} + +const colorsClose = (a: THREE.Color, b: THREE.Color) => + Math.abs(a.r - b.r) < COLOR_EPSILON && Math.abs(a.g - b.g) < COLOR_EPSILON && Math.abs(a.b - b.b) < COLOR_EPSILON + +const readStoredBase = (material: ColorBearingMaterial): THREE.Color | undefined => readRgb(material, BASE_COLOR_KEY) + +const storeBase = (material: ColorBearingMaterial, base: THREE.Color) => { + storeRgb(material, BASE_COLOR_KEY, base) +} + +const readLastApplied = (material: ColorBearingMaterial): THREE.Color | undefined => readRgb(material, LAST_APPLIED_COLOR_KEY) + +const storeLastApplied = (material: ColorBearingMaterial, color: THREE.Color) => { + storeRgb(material, LAST_APPLIED_COLOR_KEY, color) +} + +const resolveBaseColor = (material: ColorBearingMaterial, flashing: boolean): THREE.Color => { + const stored = readStoredBase(material) + if (!stored) { + const base = material.color.clone() + storeBase(material, base) + return base + } + if (flashing) return stored + const lastApplied = readLastApplied(material) + if (lastApplied && colorsClose(material.color, lastApplied)) return stored + const base = material.color.clone() + storeBase(material, base) + return base +} + +const applyBrightnessToMaterial = (entry: ControlledMaterial, brightness: number) => { + entry.material.color.setRGB(entry.base.r * brightness, entry.base.g * brightness, entry.base.b * brightness) + storeLastApplied(entry.material, entry.material.color) +} + +const lightmapParamsEqual = (a?: BlockLightmapParams, b?: BlockLightmapParams) => + a === b || + (!!a && + !!b && + (a.curve ?? DEFAULT_LIGHTMAP_PARAMS.curve) === (b.curve ?? DEFAULT_LIGHTMAP_PARAMS.curve) && + (a.minBrightness ?? DEFAULT_LIGHTMAP_PARAMS.minBrightness) === (b.minBrightness ?? DEFAULT_LIGHTMAP_PARAMS.minBrightness) && + (a.gamma ?? DEFAULT_LIGHTMAP_PARAMS.gamma) === (b.gamma ?? DEFAULT_LIGHTMAP_PARAMS.gamma)) + +export class EntityLightController { + private readonly roots = new Map() + private skyLevel = 1 + private lightmapParams: BlockLightmapParams = { ...DEFAULT_LIGHTMAP_PARAMS } + + constructor(private readonly sampler: EntityLightSampler) { + this.skyLevel = sampler.getSkyLevel() + this.lightmapParams = { ...DEFAULT_LIGHTMAP_PARAMS, ...sampler.getLightmapParams() } + } + + setSkyLevel(value: number) { + this.skyLevel = value + this.refreshAll() + } + + setLightmapParams(params: BlockLightmapParams) { + this.lightmapParams = { ...this.lightmapParams, ...params } + this.refreshAll() + } + + register(root: THREE.Object3D, meta: EntityLightMeta = {}, sharedMaterials?: Iterable) { + const shared = sharedMaterials ? new Set(sharedMaterials) : undefined + const existing = this.roots.get(root) + if (existing) { + existing.meta = { ...existing.meta, ...meta } + existing.override = resolveEntityLightOverride(existing.meta.name) + existing.materialsDirty = true + existing.lastProbe = undefined + if (shared) this.discoverMaterials(root, existing, shared) + return + } + const tracked: TrackedRoot = { + meta, + override: resolveEntityLightOverride(meta.name), + materials: [], + materialsDirty: true, + flashing: false + } + this.roots.set(root, tracked) + if (shared) this.discoverMaterials(root, tracked, shared) + } + + setMeta(root: THREE.Object3D, meta: EntityLightMeta) { + const tracked = this.roots.get(root) + if (!tracked) return + tracked.meta = { ...tracked.meta, ...meta } + tracked.override = resolveEntityLightOverride(tracked.meta.name) + tracked.lastProbe = undefined + } + + unregister(root: THREE.Object3D) { + this.roots.delete(root) + } + + clear() { + this.roots.clear() + } + + invalidateMaterials(root: THREE.Object3D) { + const tracked = this.roots.get(root) + if (!tracked) return + tracked.materialsDirty = true + tracked.lastProbe = undefined + } + + invalidateFromDescendant(node: THREE.Object3D) { + let current: THREE.Object3D | null = node + while (current) { + if (this.roots.has(current)) { + this.invalidateMaterials(current) + return + } + current = current.parent + } + } + + markFlash(root: THREE.Object3D, _durationMs?: number) { + const tracked = this.roots.get(root) + if (!tracked) return + tracked.flashing = true + } + + endFlash(root: THREE.Object3D) { + const tracked = this.roots.get(root) + if (!tracked) return + tracked.flashing = false + tracked.lastProbe = undefined + } + + update(root: THREE.Object3D, worldPos: EntityLightWorldPos, sharedMaterials?: Iterable) { + const tracked = this.roots.get(root) + if (!tracked) return + const materialsWereDirty = tracked.materialsDirty + if (tracked.materialsDirty) { + this.discoverMaterials(root, tracked, sharedMaterials ? new Set(sharedMaterials) : undefined) + } + + const lightingEnabled = this.sampler.lightingEnabled() + const probe = entityProbeBlock(worldPos, resolveEntityEyeHeight(tracked.meta)) + const columnRevision = this.sampler.getColumnRevision(alignColumn(probe.x), alignColumn(probe.z)) + const globalRevision = this.sampler.getGlobalRevision() + const skyLevel = this.skyLevel + const lightmapParams = this.lightmapParams + + const unchanged = + tracked.lastProbe && + tracked.lastProbe.x === probe.x && + tracked.lastProbe.y === probe.y && + tracked.lastProbe.z === probe.z && + tracked.lastColumnRevision === columnRevision && + tracked.lastGlobalRevision === globalRevision && + tracked.lastSkyLevel === skyLevel && + tracked.lastLightingEnabled === lightingEnabled && + lightmapParamsEqual(tracked.lastLightmapParams, lightmapParams) + + if (unchanged && !materialsWereDirty) return + + tracked.lastProbe = probe + tracked.lastColumnRevision = columnRevision + tracked.lastGlobalRevision = globalRevision + tracked.lastSkyLevel = skyLevel + tracked.lastLightmapParams = lightmapParams + tracked.lastLightingEnabled = lightingEnabled + + if (tracked.flashing) return + + const brightness = this.computeBrightness(tracked, probe, lightingEnabled) + tracked.lastBrightness = brightness + for (const entry of tracked.materials) { + applyBrightnessToMaterial(entry, brightness) + } + } + + private refreshAll() { + for (const [root, tracked] of this.roots) { + if (tracked.flashing || tracked.materialsDirty) { + tracked.lastProbe = undefined + continue + } + const lightingEnabled = this.sampler.lightingEnabled() + const probe = tracked.lastProbe ?? { x: 0, y: 0, z: 0 } + const brightness = tracked.lastProbe ? this.computeBrightness(tracked, probe, lightingEnabled) : undefined + if (brightness === undefined) { + tracked.lastProbe = undefined + continue + } + tracked.lastSkyLevel = this.skyLevel + tracked.lastLightmapParams = this.lightmapParams + tracked.lastLightingEnabled = lightingEnabled + tracked.lastBrightness = brightness + for (const entry of tracked.materials) { + applyBrightnessToMaterial(entry, brightness) + } + void root + } + } + + private computeBrightness(tracked: TrackedRoot, probe: { x: number; y: number; z: number }, lightingEnabled: boolean) { + if (!lightingEnabled || tracked.override === 'fullbright') return 1 + const sample = this.sampler.getLight(probe.x, probe.y, probe.z) + return blockEntityBrightness(sample.block, sample.sky, this.skyLevel, this.lightmapParams) + } + + private discoverMaterials(root: THREE.Object3D, tracked: TrackedRoot, sharedMaterials?: Set) { + const next: ControlledMaterial[] = [] + const seen = new Set() + root.traverse(child => { + if (child.name === 'debug') return + const raw = (child as THREE.Mesh).material + if (!raw) return + const list = Array.isArray(raw) ? raw : [raw] + let replacedArray = false + const nextList = list.map((material, index) => { + if (!hasColor(material) || seen.has(material)) return material + seen.add(material) + let current = material + if (sharedMaterials?.has(current)) { + current = current.clone() as ColorBearingMaterial + if (Array.isArray(raw)) { + list[index] = current + replacedArray = true + } else { + ;(child as THREE.Mesh).material = current + } + } + const base = resolveBaseColor(current, tracked.flashing) + next.push({ material: current, base }) + return current + }) + if (replacedArray) (child as THREE.Mesh).material = nextList + }) + tracked.materials = next + tracked.materialsDirty = false + } +} diff --git a/src/three/globalBlockBuffer.ts b/src/three/globalBlockBuffer.ts index 88505fb..c701183 100644 --- a/src/three/globalBlockBuffer.ts +++ b/src/three/globalBlockBuffer.ts @@ -11,6 +11,7 @@ import { import { VERTICES_PER_FACE, computeSectionOriginRel } from './shaders/cubeBlockShader' import { computeCameraRelativeUniforms, type RenderOrigin } from './shaders/legacyBlockShader' import { packWord2Empty } from '../wasm-mesher/bridge/shaderCubeBridge' +import { isClientLightTraceEnabled, recordClientLightTrace, recordClientLightTraceGpuSample } from '../lib/clientLightTrace' type WebGLRendererInternals = THREE.WebGLRenderer & { properties: { @@ -161,9 +162,9 @@ export class GlobalBlockBuffer { getSectionDrawStart(sectionKey: string): number | undefined { const slot = this.sectionSlots.get(sectionKey) if (!slot) return undefined - if (this.pendingMove?.key === sectionKey) return this.pendingMove.oldStart const replace = this.pendingReplace.get(sectionKey) if (replace) return replace.oldStart + if (this.pendingMove?.key === sectionKey) return this.pendingMove.oldStart if (!this.rangeFullyUploaded(slot.start, slot.start + slot.count - 1)) return undefined return slot.start } @@ -171,13 +172,37 @@ export class GlobalBlockBuffer { getSectionDrawCount(sectionKey: string): number | undefined { const slot = this.sectionSlots.get(sectionKey) if (!slot) return undefined - if (this.pendingMove?.key === sectionKey) return this.pendingMove.count const replace = this.pendingReplace.get(sectionKey) if (replace) return replace.oldCount + if (this.pendingMove?.key === sectionKey) return this.pendingMove.count if (!this.rangeFullyUploaded(slot.start, slot.start + slot.count - 1)) return undefined return slot.count } + getGpuSlotTrace(sectionKey: string): + | { + displayedStart?: number + displayedCount?: number + candidateStart: number + candidateCount: number + pendingReplace: boolean + pendingMove: boolean + unuploadedRanges: number + } + | undefined { + const slot = this.sectionSlots.get(sectionKey) + if (!slot) return undefined + return { + displayedStart: this.getSectionDrawStart(sectionKey), + displayedCount: this.getSectionDrawCount(sectionKey), + candidateStart: slot.start, + candidateCount: slot.count, + pendingReplace: this.pendingReplace.has(sectionKey), + pendingMove: this.pendingMove?.key === sectionKey, + unuploadedRanges: this.pendingRanges.length + } + } + getUploadEpoch(): number { return this.uploadEpoch } @@ -281,6 +306,20 @@ export class GlobalBlockBuffer { this.markDirty(slot.start, slot.start + faceCount - 1) this.mesh.geometry.instanceCount = this.highWatermark this.layoutVersion++ + if (isClientLightTraceEnabled()) { + const gpu = this.getGpuSlotTrace(sectionKey) + recordClientLightTrace({ + phase: 'gpuStaged', + sectionKey, + displayedStart: gpu?.displayedStart, + displayedCount: gpu?.displayedCount, + candidateStart: gpu?.candidateStart, + candidateCount: gpu?.candidateCount, + pendingReplace: gpu?.pendingReplace, + pendingMove: gpu?.pendingMove, + unuploadedRanges: gpu?.unuploadedRanges + }) + } } getLayoutVersion(): number { @@ -387,6 +426,20 @@ export class GlobalBlockBuffer { this.markDirty(newStart, newStart + section.count - 1) this.pendingMove = { key: section.key, oldStart, newStart, count: section.count } this.layoutVersion++ + if (isClientLightTraceEnabled()) { + const gpu = this.getGpuSlotTrace(section.key) + recordClientLightTrace({ + phase: 'gpuStaged', + sectionKey: section.key, + displayedStart: gpu?.displayedStart, + displayedCount: gpu?.displayedCount, + candidateStart: gpu?.candidateStart, + candidateCount: gpu?.candidateCount, + pendingReplace: gpu?.pendingReplace, + pendingMove: true, + unuploadedRanges: gpu?.unuploadedRanges + }) + } } uploadDirtyRange(): void { @@ -410,6 +463,13 @@ export class GlobalBlockBuffer { r.start = offset + count } this.uploadEpoch++ + if (isClientLightTraceEnabled()) { + recordClientLightTraceGpuSample('gpuUploaded', this.highWatermark, () => ({ + phase: 'gpuUploaded', + unuploadedRanges: this.pendingRanges.length, + drawableFaces: this.highWatermark + })) + } } setCameraOrigin(renderOrigin: RenderOrigin, x: number, y: number, z: number): void { @@ -607,6 +667,9 @@ export class GlobalBlockBuffer { private findMovableSection(maxCount: number): { key: string; start: number; count: number } | undefined { const sections: Array<{ key: string; start: number; count: number }> = [] for (const [key, slot] of this.sectionSlots) { + if (this.pendingReplace.has(key)) continue + if (this.pendingMove?.key === key) continue + if (!this.rangeFullyUploaded(slot.start, slot.start + slot.count - 1)) continue sections.push({ key, start: slot.start, count: slot.count }) } if (sections.length === 0) return undefined @@ -683,11 +746,26 @@ export class GlobalBlockBuffer { this.mesh.geometry.instanceCount = this.highWatermark this.layoutVersion++ this.uploadEpoch++ + if (isClientLightTraceEnabled()) { + const gpu = this.getGpuSlotTrace(key) + recordClientLightTrace({ + phase: 'gpuCommitted', + sectionKey: key, + displayedStart: gpu?.displayedStart, + displayedCount: gpu?.displayedCount, + candidateStart: gpu?.candidateStart, + candidateCount: gpu?.candidateCount, + pendingReplace: false, + pendingMove: gpu?.pendingMove, + unuploadedRanges: gpu?.unuploadedRanges + }) + } } private finalizePendingMove(): void { const move = this.pendingMove if (!move) return + const movedKey = move.key const { oldStart, count } = move this.zeroAndFreeSlot(oldStart, count) @@ -696,6 +774,20 @@ export class GlobalBlockBuffer { this.pendingMove = null this.layoutVersion++ this.uploadEpoch++ + if (isClientLightTraceEnabled()) { + const gpu = this.getGpuSlotTrace(movedKey) + recordClientLightTrace({ + phase: 'gpuCommitted', + sectionKey: movedKey, + displayedStart: gpu?.displayedStart, + displayedCount: gpu?.displayedCount, + candidateStart: gpu?.candidateStart, + candidateCount: gpu?.candidateCount, + pendingReplace: gpu?.pendingReplace, + pendingMove: false, + unuploadedRanges: gpu?.unuploadedRanges + }) + } } private shrinkHighWatermark(): void { diff --git a/src/three/globalLegacyBuffer.ts b/src/three/globalLegacyBuffer.ts index 53eddee..ab5d207 100644 --- a/src/three/globalLegacyBuffer.ts +++ b/src/three/globalLegacyBuffer.ts @@ -9,6 +9,7 @@ import { type LegacyMultiDrawScratch } from './legacyMultiDraw' import { computeCameraRelativeUniforms, type RenderOrigin } from './shaders/legacyBlockShader' +import { isClientLightTraceEnabled, recordClientLightTrace, recordClientLightTraceGpuSample } from '../lib/clientLightTrace' const VERTS_PER_QUAD = 4 const INDICES_PER_QUAD = 6 @@ -336,6 +337,20 @@ export class GlobalLegacyBuffer { this.markDirty(slot.start, slot.start + quadCount - 1) this.syncDefaultDrawGroups() this.layoutVersion++ + if (isClientLightTraceEnabled()) { + const gpu = this.getGpuSlotTrace(sectionKey) + recordClientLightTrace({ + phase: 'gpuStaged', + sectionKey, + displayedStart: gpu?.displayedStart, + displayedCount: gpu?.displayedCount, + candidateStart: gpu?.candidateStart, + candidateCount: gpu?.candidateCount, + pendingReplace: gpu?.pendingReplace, + pendingMove: gpu?.pendingMove, + unuploadedRanges: gpu?.unuploadedRanges + }) + } return true } @@ -366,9 +381,9 @@ export class GlobalLegacyBuffer { getSectionDrawStart(sectionKey: string): number | undefined { const slot = this.sectionSlots.get(sectionKey) if (!slot) return undefined - if (this.pendingMove?.key === sectionKey) return this.pendingMove.oldStart const replace = this.pendingReplace.get(sectionKey) if (replace) return replace.oldStart + if (this.pendingMove?.key === sectionKey) return this.pendingMove.oldStart if (!this.rangeFullyUploaded(slot.start, slot.start + slot.count - 1)) return undefined return slot.start } @@ -376,13 +391,37 @@ export class GlobalLegacyBuffer { getSectionDrawCount(sectionKey: string): number | undefined { const slot = this.sectionSlots.get(sectionKey) if (!slot) return undefined - if (this.pendingMove?.key === sectionKey) return this.pendingMove.count const replace = this.pendingReplace.get(sectionKey) if (replace) return replace.oldCount + if (this.pendingMove?.key === sectionKey) return this.pendingMove.count if (!this.rangeFullyUploaded(slot.start, slot.start + slot.count - 1)) return undefined return slot.count } + getGpuSlotTrace(sectionKey: string): + | { + displayedStart?: number + displayedCount?: number + candidateStart: number + candidateCount: number + pendingReplace: boolean + pendingMove: boolean + unuploadedRanges: number + } + | undefined { + const slot = this.sectionSlots.get(sectionKey) + if (!slot) return undefined + return { + displayedStart: this.getSectionDrawStart(sectionKey), + displayedCount: this.getSectionDrawCount(sectionKey), + candidateStart: slot.start, + candidateCount: slot.count, + pendingReplace: this.pendingReplace.has(sectionKey), + pendingMove: this.pendingMove?.key === sectionKey, + unuploadedRanges: this.pendingRanges.length + } + } + getPendingMove(): PendingMove | null { return this.pendingMove } @@ -424,6 +463,20 @@ export class GlobalLegacyBuffer { this.markDirty(newStart, newStart + section.count - 1) this.pendingMove = { key: section.key, oldStart, newStart, count: section.count } this.layoutVersion++ + if (isClientLightTraceEnabled()) { + const gpu = this.getGpuSlotTrace(section.key) + recordClientLightTrace({ + phase: 'gpuStaged', + sectionKey: section.key, + displayedStart: gpu?.displayedStart, + displayedCount: gpu?.displayedCount, + candidateStart: gpu?.candidateStart, + candidateCount: gpu?.candidateCount, + pendingReplace: gpu?.pendingReplace, + pendingMove: true, + unuploadedRanges: gpu?.unuploadedRanges + }) + } } updateDrawSpans(visible: VisibleSectionSpan[], mode: 'opaque' | 'sortedBlend'): void { @@ -695,6 +748,13 @@ export class GlobalLegacyBuffer { r.start = quadOffset + quadCount } this.uploadEpoch++ + if (isClientLightTraceEnabled()) { + recordClientLightTraceGpuSample('gpuUploaded', this.highWatermark, () => ({ + phase: 'gpuUploaded', + unuploadedRanges: this.pendingRanges.length, + drawableFaces: this.highWatermark + })) + } } uploadDirtyIndexRange(): void { @@ -897,6 +957,9 @@ export class GlobalLegacyBuffer { private findMovableSection(maxCount: number): { key: string; start: number; count: number } | undefined { const sections: Array<{ key: string; start: number; count: number }> = [] for (const [key, slot] of this.sectionSlots) { + if (this.pendingReplace.has(key)) continue + if (this.pendingMove?.key === key) continue + if (!this.rangeFullyUploaded(slot.start, slot.start + slot.count - 1)) continue sections.push({ key, start: slot.start, count: slot.count }) } if (sections.length === 0) return undefined @@ -1005,11 +1068,26 @@ export class GlobalLegacyBuffer { this.syncDefaultDrawGroups() this.layoutVersion++ this.uploadEpoch++ + if (isClientLightTraceEnabled()) { + const gpu = this.getGpuSlotTrace(key) + recordClientLightTrace({ + phase: 'gpuCommitted', + sectionKey: key, + displayedStart: gpu?.displayedStart, + displayedCount: gpu?.displayedCount, + candidateStart: gpu?.candidateStart, + candidateCount: gpu?.candidateCount, + pendingReplace: false, + pendingMove: gpu?.pendingMove, + unuploadedRanges: gpu?.unuploadedRanges + }) + } } private finalizePendingMove(): void { const move = this.pendingMove if (!move) return + const movedKey = move.key const { oldStart, count } = move this.zeroAndFreeSlot(oldStart, count) @@ -1018,6 +1096,20 @@ export class GlobalLegacyBuffer { this.pendingMove = null this.layoutVersion++ this.uploadEpoch++ + if (isClientLightTraceEnabled()) { + const gpu = this.getGpuSlotTrace(movedKey) + recordClientLightTrace({ + phase: 'gpuCommitted', + sectionKey: movedKey, + displayedStart: gpu?.displayedStart, + displayedCount: gpu?.displayedCount, + candidateStart: gpu?.candidateStart, + candidateCount: gpu?.candidateCount, + pendingReplace: gpu?.pendingReplace, + pendingMove: false, + unuploadedRanges: gpu?.unuploadedRanges + }) + } } private growCapacity(minQuads: number): void { diff --git a/src/three/graphicsBackendBase.ts b/src/three/graphicsBackendBase.ts index 437cb87..e7849e8 100644 --- a/src/three/graphicsBackendBase.ts +++ b/src/three/graphicsBackendBase.ts @@ -85,15 +85,8 @@ export const getBackendMethods = (worldRenderer: WorldRendererThree): any => { }, feedChunkPacket(payload: FeedChunkPacketPayload) { // Forward parsed/raw map_chunk + update_light packets from the - // web-client to all WASM mesher workers. The fan-out below uses - // structured clone (one Uint8Array can only be transferred to a - // single recipient); useWorkerProxy still gives zero-copy transfer - // from main into the off-thread renderer worker for free. - const { kind, ...rest } = payload - const message = { type: kind, ...rest } - for (const worker of worldRenderer.workers) { - worker.postMessage(message) - } + // web-client to WASM mesher workers (and the light owner when flagged). + worldRenderer.feedChunkPacket(payload) }, getChunksDebugState() { const loadedSectionsChunks: Record = {} diff --git a/src/three/lightOwnerHost.test.ts b/src/three/lightOwnerHost.test.ts new file mode 100644 index 0000000..27a2442 --- /dev/null +++ b/src/three/lightOwnerHost.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { RendererLightCache } from './rendererLightCache' +import { applyLightPublication, LightOwnerHost, type LightPublication } from './lightOwnerHost' + +function packedNibble(value: number): Uint8Array { + const n = value & 0x0f + return new Uint8Array(2048).fill(n | (n << 4)) +} + +describe('LightOwnerHost publications', () => { + it('writes packed block-only sections without baking missing sky as 15', () => { + const cache = new RendererLightCache('1.17.1') + cache.setWorldBounds(0, 256) + const pub: LightPublication = { + worldGeneration: 1, + publicationVersion: 1, + sections: [{ sx: 0, sy: 64, sz: 0, blockLight: packedNibble(9) }] + } + const result = applyLightPublication(cache, pub, { acceptedGeneration: 1, lastVersion: 0 }) + expect(result.applied).toBe(true) + expect(cache.getLight(0, 64, 0).block).toBeCloseTo(11 / 15, 5) + expect(cache.getLight(0, 64, 0).sky).toBeCloseTo(2 / 15, 5) + }) + + it('discards a stale publication version', () => { + const cache = new RendererLightCache('1.17.1') + cache.setWorldBounds(0, 256) + const stale: LightPublication = { + worldGeneration: 1, + publicationVersion: 3, + sections: [{ sx: 0, sy: 64, sz: 0, blockLight: packedNibble(4) }] + } + const result = applyLightPublication(cache, stale, { acceptedGeneration: 1, lastVersion: 4 }) + expect(result.applied).toBe(false) + expect(cache.getLight(0, 64, 0)).toEqual({ block: 0, sky: 1 }) + }) + + it('steps an in-process owner and publishes increasing versions', async () => { + const cache = new RendererLightCache('1.17.1') + cache.setWorldBounds(0, 256) + const host = await LightOwnerHost.createInProcess(cache, { worldMinY: 0, worldHeight: 256 }) + host.setLightTables({ + emission: Uint8Array.of(0, 0, 14), + opacity: Uint8Array.of(0, 15, 0) + }) + host.pushEvent({ type: 'ingestBlockSection', sx: 0, sy: 4, sz: 0, states: new Uint16Array(4096) }) + host.pushEvent({ type: 'blockChange', x: 8, y: 64, z: 8, stateId: 2 }) + const first = await host.stepUntilIdle(16) + expect(first?.publicationVersion).toBeGreaterThan(0) + host.pushEvent({ type: 'blockChange', x: 8, y: 64, z: 8, stateId: 0 }) + const second = await host.stepUntilIdle(16) + expect(second?.publicationVersion).toBeGreaterThan(first!.publicationVersion) + expect(cache.getLight(8, 64, 8).block).toBeCloseTo(2 / 15, 5) + }) + + it('publishes sky so an opaque roof darkens the column', async () => { + const cache = new RendererLightCache('1.17.1') + cache.setWorldBounds(0, 256) + const host = await LightOwnerHost.createInProcess(cache, { worldMinY: 0, worldHeight: 256 }) + host.setLightTables({ + emission: Uint8Array.of(0, 0, 14), + opacity: Uint8Array.of(0, 15, 0) + }) + host.pushEvent({ type: 'ingestBlockSection', sx: 0, sy: 15, sz: 0, states: new Uint16Array(4096) }) + await host.stepUntilIdle(32) + expect(cache.getLight(8, 255, 8).sky).toBeCloseTo(1, 5) + expect(cache.getLight(8, 249, 8).sky).toBeCloseTo(1, 5) + for (let z = 0; z < 16; z++) { + for (let x = 0; x < 16; x++) { + host.pushEvent({ type: 'blockChange', x, y: 250, z, stateId: 1 }) + } + } + await host.stepUntilIdle(64) + expect(cache.getLight(8, 251, 8).sky).toBeCloseTo(1, 5) + expect(cache.getLight(8, 249, 8).sky).toBeCloseTo(2 / 15, 5) + }) +}) diff --git a/src/three/lightOwnerHost.ts b/src/three/lightOwnerHost.ts new file mode 100644 index 0000000..99992d6 --- /dev/null +++ b/src/three/lightOwnerHost.ts @@ -0,0 +1,190 @@ +import { RendererLightCache } from './rendererLightCache' + +export type LightPublicationSection = { + sx: number + sy: number + sz: number + blockLight: Uint8Array + skyLight?: Uint8Array +} + +export type LightPublication = { + worldGeneration: number + publicationVersion: number + sections: LightPublicationSection[] +} + +export type LightOwnerEvent = + | { type: 'ingestBlockSection'; sx: number; sy: number; sz: number; states: Uint16Array } + | { type: 'setAvailability'; sx: number; sy: number; sz: number; availability: 'loaded' | 'lightOnly' | 'unloaded' } + | { type: 'serverLight'; sx: number; sy: number; sz: number; channel: 'block' | 'sky'; kind: 'omitted' | 'empty' | 'data'; data?: Uint8Array } + | { type: 'blockChange'; x: number; y: number; z: number; stateId: number } + | { type: 'unloadColumn'; sx: number; sz: number } + +export type LightEngineBackend = { + setLightTables(tables: { emission: Uint8Array; opacity: Uint8Array; occupancy?: Uint8Array }): void + setSkyLightEnabled?(enabled: boolean): void + pushEvent(event: LightOwnerEvent): void + step(budgetMs: number): boolean + pollCompletedPublication(): LightPublication | null +} + +export type PublicationGate = { + acceptedGeneration: number + lastVersion: number +} + +export type ApplyPublicationResult = { applied: boolean; lastVersion: number; acceptedGeneration: number } + +/** Engine ABI uses section indices; RendererLightCache keys are world origins. */ +export function sectionIndexToWorldOrigin(sx: number, sy: number, sz: number) { + return { sx: sx * 16, sy: sy * 16, sz: sz * 16 } +} + +export function applyLightPublication( + cache: RendererLightCache, + publication: LightPublication, + gate: PublicationGate, + coords: 'world' | 'section-index' = 'world' +): ApplyPublicationResult { + if (publication.worldGeneration < gate.acceptedGeneration) { + return { applied: false, lastVersion: gate.lastVersion, acceptedGeneration: gate.acceptedGeneration } + } + if (publication.worldGeneration === gate.acceptedGeneration && publication.publicationVersion <= gate.lastVersion) { + return { applied: false, lastVersion: gate.lastVersion, acceptedGeneration: gate.acceptedGeneration } + } + const sections = publication.sections.map(section => { + const origin = coords === 'section-index' ? sectionIndexToWorldOrigin(section.sx, section.sy, section.sz) : section + return { + sx: origin.sx, + sy: origin.sy, + sz: origin.sz, + blockLight: section.blockLight, + skyLight: section.skyLight + } + }) + cache.ingestPackedSections(sections) + return { applied: true, lastVersion: publication.publicationVersion, acceptedGeneration: publication.worldGeneration } +} + +/** Production spawn stays behind `enableClientLightOwner` (default off). */ +export const CLIENT_LIGHT_OWNER_FLAG = 'enableClientLightOwner' as const + +export class LightOwnerHost { + private lastVersion = 0 + private generation: number + + private constructor( + private readonly cache: RendererLightCache, + private readonly backend: LightEngineBackend, + opts: { worldMinY: number; worldHeight: number; generation?: number } + ) { + this.generation = opts.generation ?? 1 + this.cache.setWorldBounds(opts.worldMinY, opts.worldHeight) + } + + static create(cache: RendererLightCache, backend: LightEngineBackend, opts: { worldMinY: number; worldHeight: number; generation?: number }) { + return new LightOwnerHost(cache, backend, opts) + } + + static async createInProcess(cache: RendererLightCache, opts: { worldMinY: number; worldHeight: number; generation?: number }) { + const backend = await createWasmLightBackend(opts.worldMinY, opts.worldHeight) + return new LightOwnerHost(cache, backend, opts) + } + + setLightTables(tables: { emission: Uint8Array; opacity: Uint8Array; occupancy?: Uint8Array }) { + this.backend.setLightTables(tables) + } + + setSkyLightEnabled(enabled: boolean) { + this.backend.setSkyLightEnabled?.(enabled) + } + + pushEvent(event: LightOwnerEvent) { + this.backend.pushEvent(event) + } + + async stepUntilIdle(maxSlices = 32, budgetMs = 16): Promise { + let last: LightPublication | undefined + for (let i = 0; i < maxSlices; i++) { + const remaining = this.backend.step(budgetMs) + const publication = this.backend.pollCompletedPublication() + if (publication) { + const result = applyLightPublication(this.cache, publication, { acceptedGeneration: this.generation, lastVersion: this.lastVersion }, 'section-index') + if (result.applied) { + this.lastVersion = result.lastVersion + this.generation = result.acceptedGeneration + last = publication + } + } + if (!remaining) break + } + return last + } +} + +async function createWasmLightBackend(worldMinY: number, worldHeight: number): Promise { + const { readFileSync } = await import('node:fs') + const { dirname, join } = await import('node:path') + const { fileURLToPath } = await import('node:url') + const wasmModule = await import('../wasm-mesher/runtime-build/wasm_mesher.js') + const wasmDir = dirname(fileURLToPath(import.meta.url)) + const wasmBytes = readFileSync(join(wasmDir, '../wasm-mesher/runtime-build/wasm_mesher_bg.wasm')) + try { + if (typeof wasmModule.initSync === 'function') { + wasmModule.initSync(wasmBytes) + } else { + await wasmModule.default(wasmBytes) + } + } catch { + // wasm already initialized in this vitest worker + } + const Engine = (wasmModule as { JsLightEngine?: new (minY: number, height: number) => WasmEngine }).JsLightEngine + if (!Engine) { + throw new Error('JsLightEngine is not exported from wasm_mesher') + } + const engine = new Engine(worldMinY, worldHeight) + return { + setLightTables({ emission, opacity, occupancy }) { + engine.setLightTables(emission, opacity) + if (occupancy && engine.setOcclusionTable) engine.setOcclusionTable(occupancy) + }, + setSkyLightEnabled(enabled) { + engine.setSkyLightEnabled?.(enabled) + }, + pushEvent(event) { + engine.pushEvent(event) + }, + step(budgetMs) { + return engine.step(budgetMs) + }, + pollCompletedPublication() { + const raw = engine.pollCompletedPublication() + if (!raw) return null + return { + worldGeneration: raw.worldGeneration, + publicationVersion: raw.publicationVersion, + sections: raw.sections.map((section: { sx: number; sy: number; sz: number; blockLight: Uint8Array; skyLight?: Uint8Array }) => ({ + sx: section.sx, + sy: section.sy, + sz: section.sz, + blockLight: section.blockLight, + skyLight: section.skyLight + })) + } + } + } +} + +type WasmEngine = { + setLightTables(emission: Uint8Array, opacity: Uint8Array): void + setOcclusionTable?(occupancy: Uint8Array): void + setSkyLightEnabled?(enabled: boolean): void + pushEvent(event: LightOwnerEvent): void + step(budgetMs: number): boolean + pollCompletedPublication(): null | { + worldGeneration: number + publicationVersion: number + sections: Array<{ sx: number; sy: number; sz: number; blockLight: Uint8Array; skyLight?: Uint8Array }> + } +} diff --git a/src/three/lightTables1171.test.ts b/src/three/lightTables1171.test.ts new file mode 100644 index 0000000..c859f40 --- /dev/null +++ b/src/three/lightTables1171.test.ts @@ -0,0 +1,168 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import MinecraftData from 'minecraft-data' +import { buildVanillaLightTables1171 } from '../../scripts/generate-light-tables-1171.mjs' +import { buildLightTables1171 } from './lightTables1171' + +const mcData = MinecraftData('1.17.1') + +function propsAt( + block: { minStateId: number; states?: Array<{ name: string; type: string; num_values: number; values?: readonly unknown[] }> }, + stateId: number +) { + let data = stateId - block.minStateId + const out: Record = {} + const states = block.states ?? [] + for (let i = states.length - 1; i >= 0; i--) { + const prop = states[i]! + const idx = data % prop.num_values + data = Math.floor(data / prop.num_values) + if (prop.type === 'bool') out[prop.name] = idx === 0 + else if (prop.values) out[prop.name] = prop.values[idx] as string + else out[prop.name] = idx + } + return out +} + +function idsNamed(name: string) { + const block = mcData.blocksByName[name] + expect(block, name).toBeTruthy() + return block +} + +describe('buildLightTables1171', () => { + it('covers every 1.17.1 stateId from the vanilla Blocks.java dump, not 1.21.7', () => { + const tables = buildLightTables1171() + expect(tables.version).toBe('1.17.1') + expect(tables.source).toBe('vanilla-1.17.1-blocks-java') + expect(tables.vanillaRegistryComplete).toBe(true) + expect(tables.vanillaWorldVersion).toBe(2730) + + let maxState = 0 + for (const block of mcData.blocksArray) { + const hi = block.maxStateId ?? block.defaultState + if (hi > maxState) maxState = hi + } + expect(tables.emission.length).toBe(maxState + 1) + expect(tables.opacity.length).toBe(maxState + 1) + expect(tables.occupancy.length).toBe(maxState + 1) + + for (const block of mcData.blocksArray) { + const lo = block.minStateId ?? block.defaultState + const hi = block.maxStateId ?? block.defaultState + for (let id = lo; id <= hi; id++) { + expect(tables.emission[id], `${block.name}#${id} emission`).toBeGreaterThanOrEqual(0) + expect(tables.emission[id], `${block.name}#${id} emission`).toBeLessThanOrEqual(15) + expect(tables.opacity[id], `${block.name}#${id} lightBlock`).toBeGreaterThanOrEqual(0) + expect(tables.opacity[id], `${block.name}#${id} lightBlock`).toBeLessThanOrEqual(15) + expect(tables.occupancy[id], `${block.name}#${id} occupancy`).toBeGreaterThanOrEqual(0) + expect(tables.occupancy[id], `${block.name}#${id} occupancy`).toBeLessThanOrEqual(255) + } + } + }) + + it('gives vanilla emission for torch, lit furnace, lit lamp, soul lantern, candles', () => { + const tables = buildLightTables1171() + expect(tables.emission[idsNamed('torch').defaultState]).toBe(14) + + const furnace = idsNamed('furnace') + let sawLitFurnace = false + for (let id = furnace.minStateId; id <= furnace.maxStateId; id++) { + const lit = propsAt(furnace, id).lit === true + if (lit) { + expect(tables.emission[id], `furnace#${id} lit`).toBe(13) + sawLitFurnace = true + } else { + expect(tables.emission[id], `furnace#${id} unlit`).toBe(0) + } + } + expect(sawLitFurnace).toBe(true) + + const lamp = idsNamed('redstone_lamp') + let sawLitLamp = false + for (let id = lamp.minStateId; id <= lamp.maxStateId; id++) { + const lit = propsAt(lamp, id).lit === true + if (lit) { + expect(tables.emission[id], `redstone_lamp#${id} on`).toBe(15) + sawLitLamp = true + } else { + expect(tables.emission[id], `redstone_lamp#${id} off`).toBe(0) + } + } + expect(sawLitLamp).toBe(true) + + const soul = idsNamed('soul_lantern') + for (let id = soul.minStateId; id <= soul.maxStateId; id++) { + expect(tables.emission[id], `soul_lantern#${id}`).toBe(10) + } + + const candle = idsNamed('candle') + let sawLitCandle = false + for (let id = candle.minStateId; id <= candle.maxStateId; id++) { + const props = propsAt(candle, id) + const lit = props.lit === true + const count = Number(props.candles) + if (lit) { + expect(tables.emission[id], `candle#${id} ${count}`).toBe(3 * count) + sawLitCandle = true + } else { + expect(tables.emission[id], `candle#${id} unlit`).toBe(0) + } + } + expect(sawLitCandle).toBe(true) + }) + + it('regenerates the committed artifact from vendored 1.17.1 Blocks.java', () => { + const java = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../lib/vanilla-1.17.1/Blocks.java'), 'utf8') + const fresh = buildVanillaLightTables1171(java) + const tables = buildLightTables1171() + expect(fresh.vanillaCommit).toBe('72447031d70fca0cfc9087de2704da83bc12fa79') + expect(fresh.vanillaWorldVersion).toBe(2730) + expect(fresh.emission).toEqual(tables.emission) + expect(fresh.opacity).toEqual(tables.opacity) + expect(fresh.occupancy).toEqual(tables.occupancy) + }) + + it('dumps raw getLightBlock, not phase-1 max(1, lightBlock)', () => { + const tables = buildLightTables1171() + expect(tables.opacity[idsNamed('air').defaultState]).toBe(0) + expect(tables.opacity[idsNamed('water').defaultState]).toBe(1) + expect(tables.opacity[idsNamed('oak_leaves').defaultState]).toBe(1) + expect(tables.opacity[idsNamed('glass').defaultState]).toBe(0) + expect(tables.opacity[idsNamed('tinted_glass').defaultState]).toBe(15) + expect(tables.opacity[idsNamed('stone').defaultState]).toBe(15) + + const slab = idsNamed('oak_slab') + let sawBottom = false + let sawDouble = false + for (let id = slab.minStateId; id <= slab.maxStateId; id++) { + const props = propsAt(slab, id) + const type = props.type + if (type === 'double') { + expect(tables.opacity[id], `oak_slab#${id} double`).toBe(15) + expect(tables.occupancy[id], `oak_slab#${id} double occ`).toBe(0) + sawDouble = true + } else { + const want = props.waterlogged === true ? 1 : 0 + expect(tables.opacity[id], `oak_slab#${id} ${String(type)}`).toBe(want) + expect(tables.occupancy[id], `oak_slab#${id} occ`).toBe(type === 'top' ? 0xf0 : 0x0f) + if (type === 'bottom' && props.waterlogged !== true) sawBottom = true + } + } + expect(sawBottom).toBe(true) + expect(sawDouble).toBe(true) + + const stairs = idsNamed('oak_stairs') + for (let id = stairs.minStateId; id <= stairs.maxStateId; id++) { + const want = propsAt(stairs, id).waterlogged === true ? 1 : 0 + expect(tables.opacity[id], `oak_stairs#${id}`).toBe(want) + expect(tables.occupancy[id], `oak_stairs#${id} occ`).toBeGreaterThan(0) + } + + expect(tables.occupancy[idsNamed('glass').defaultState]).toBe(0) + expect(tables.occupancy[idsNamed('stone').defaultState]).toBe(0) + expect(tables.occupancy[idsNamed('air').defaultState]).toBe(0) + }) +}) diff --git a/src/three/lightTables1171.ts b/src/three/lightTables1171.ts new file mode 100644 index 0000000..f0b5488 --- /dev/null +++ b/src/three/lightTables1171.ts @@ -0,0 +1,58 @@ +import artifact from '../lib/lightTables1171.generated.json' + +export const LIGHT_TABLES_MC_VERSION = '1.17.1' as const + +export type LightTables1171 = { + version: typeof LIGHT_TABLES_MC_VERSION + /** Per-state emission from vanilla 1.17.1 Blocks.java lightLevel(...). */ + source: 'vanilla-1.17.1-blocks-java' + vanillaWorldVersion: 2730 + vanillaCommit: string + vanillaRegistryComplete: true + emission: Uint8Array + /** Raw 1.17.1 getLightBlock per stateId. Engine decay uses max(1, this). */ + opacity: Uint8Array + /** 2×2×2 occupancy for useShapeForLightOcclusion (slabs/stairs). */ + occupancy: Uint8Array +} + +/** + * 1.17.1 light tables for `set_light_tables`. + * + * Emission is evaluated from the vendored vanilla 1.17.1 Blocks.java snapshot + * (WORLD_VERSION 2730), mapped onto minecraft-data stateIds. + * `opacity` is raw getLightBlock (air 0, water/leaves 1, solid cube 15). + * `occupancy` is 2×2×2 face-occlusion for slabs/stairs; full cubes stay 0 + * because vanilla blocks them via lightBlock 15, not shapes. + */ +export function buildLightTables1171(): LightTables1171 { + if (artifact.version !== LIGHT_TABLES_MC_VERSION) { + throw new Error(`light table artifact version ${artifact.version} is not 1.17.1`) + } + if (artifact.source !== 'vanilla-1.17.1-blocks-java') { + throw new Error(`refusing non-vanilla light table source ${artifact.source}`) + } + if (artifact.vanillaWorldVersion !== 2730 || !artifact.vanillaRegistryComplete) { + throw new Error('light table artifact is not a complete 1.17.1 vanilla registry') + } + return { + version: LIGHT_TABLES_MC_VERSION, + source: 'vanilla-1.17.1-blocks-java', + vanillaWorldVersion: 2730, + vanillaCommit: artifact.vanillaCommit, + vanillaRegistryComplete: true, + emission: decodeB64(artifact.emissionB64), + opacity: decodeB64(artifact.opacityB64), + occupancy: decodeB64((artifact as { occupancyB64?: string }).occupancyB64 ?? '') + } +} + +function decodeB64(value: string): Uint8Array { + if (typeof Buffer !== 'undefined') { + return Uint8Array.from(Buffer.from(value, 'base64')) + } + const binary = atob(value) + const out = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i) + return out +} diff --git a/src/three/pendingSectionFlush.ts b/src/three/pendingSectionFlush.ts new file mode 100644 index 0000000..e032027 --- /dev/null +++ b/src/three/pendingSectionFlush.ts @@ -0,0 +1,122 @@ +/** + * Flush policy for buffered section geometry (`pendingSectionUpdates`). + * + * A remesh of an already-visible section must not be installed alone while a + * face neighbour is still being re-meshed: the neighbour's faces towards this + * section were culled against the OLD block state, so installing one side + * first exposes a see-through hole (the "sky flash" on dig/place). + * + * The policy therefore works on *groups*: pending sections that are face + * adjacent form a connected group, and a group is installed only when every + * outstanding neighbour of every member has arrived. When the buffer deadline + * expires we still flush, but we flush the whole group in one batch — never a + * single member, which is exactly the hole the buffer exists to prevent. + */ + +export type PendingFlushQuery = { + /** Keys currently buffered, in insertion order. */ + pendingKeys: Iterable + /** When each key was first buffered. */ + startedAt: (key: string) => number | undefined + now: number + maxBufferMs: number + sectionHeight: number + /** Section is dirty on a worker and its geometry has not arrived yet. */ + isOutstanding: (key: string) => boolean + /** + * When set, neighbour waits use topology outstanding instead of covering/light + * outstanding. Omit for bit-for-bit legacy flush behavior. + */ + isTopologyOutstanding?: (key: string) => boolean + /** Section currently has geometry on screen (an air section has none). */ + hasSectionObject: (key: string) => boolean +} + +export function faceNeighborKeys(key: string, sectionHeight: number): string[] { + const [sx, sy, sz] = key.split(',').map(Number) + return [ + `${sx - 16},${sy},${sz}`, + `${sx + 16},${sy},${sz}`, + `${sx},${sy - sectionHeight},${sz}`, + `${sx},${sy + sectionHeight},${sz}`, + `${sx},${sy},${sz - 16}`, + `${sx},${sy},${sz + 16}` + ] +} + +/** + * Connected components of the buffered sections under face adjacency. Members + * of one component must be installed in the same frame. + */ +export function pendingSectionGroups(pendingKeys: Iterable, sectionHeight: number): string[][] { + const remaining = new Set(pendingKeys) + const groups: string[][] = [] + for (const seed of [...remaining]) { + if (!remaining.has(seed)) continue + const group: string[] = [] + const stack = [seed] + remaining.delete(seed) + while (stack.length > 0) { + const key = stack.pop()! + group.push(key) + for (const neighbor of faceNeighborKeys(key, sectionHeight)) { + if (remaining.delete(neighbor)) stack.push(neighbor) + } + } + groups.push(group) + } + return groups +} + +export type PendingFlushReason = 'group-complete' | 'deadline' + +export type ReadySectionFlush = { + key: string + reason: PendingFlushReason +} + +/** + * Keys to install this frame. Either a group is complete (no member still + * waiting on a visible neighbour) or its oldest member has run out of buffer + * time — in both cases the whole group goes out together. + */ +export function selectReadySectionFlushes(query: PendingFlushQuery): ReadySectionFlush[] { + const { now, maxBufferMs, sectionHeight } = query + const ready: ReadySectionFlush[] = [] + + for (const group of pendingSectionGroups(query.pendingKeys, sectionHeight)) { + const members = new Set(group) + + let oldestStart = now + for (const key of group) { + const startedAt = query.startedAt(key) ?? now + if (startedAt < oldestStart) oldestStart = startedAt + } + const expired = now - oldestStart >= maxBufferMs + + let waitingOnNeighbor = false + if (!expired) { + for (const key of group) { + for (const neighbor of faceNeighborKeys(key, sectionHeight)) { + if (members.has(neighbor)) continue + const neighborOutstanding = query.isTopologyOutstanding ? query.isTopologyOutstanding(neighbor) : query.isOutstanding(neighbor) + if (neighborOutstanding && query.hasSectionObject(neighbor)) { + waitingOnNeighbor = true + break + } + } + if (waitingOnNeighbor) break + } + } + + if (waitingOnNeighbor) continue + const reason: PendingFlushReason = expired ? 'deadline' : 'group-complete' + for (const key of group) ready.push({ key, reason }) + } + + return ready +} + +export function selectReadySectionUpdates(query: PendingFlushQuery): string[] { + return selectReadySectionFlushes(query).map(item => item.key) +} diff --git a/src/three/rendererLightCache.ts b/src/three/rendererLightCache.ts new file mode 100644 index 0000000..a0c38ef --- /dev/null +++ b/src/three/rendererLightCache.ts @@ -0,0 +1,247 @@ +import Chunks from 'prismarine-chunk' +import { Vec3 } from 'vec3' + +/** 16³ blocks, one packed byte (block<<4 | sky) per block. */ +export const RENDERER_LIGHT_BYTES_PER_SECTION = 4096 + +const FULLBRIGHT = { block: 0, sky: 1 } as const + +const sectionKey = (sx: number, sy: number, sz: number) => `${sx},${sy},${sz}` +const columnKey = (sx: number, sz: number) => `${sx},${sz}` +const alignSection = (coord: number) => Math.floor(coord / 16) * 16 +const localCoord = (coord: number) => ((coord % 16) + 16) % 16 +const blockIndex = (lx: number, ly: number, lz: number) => lx + lz * 16 + ly * 256 + +const channelNorm = (raw: number) => Math.min(15, raw + 2) / 15 + +const getLightSectionIndex = (y: number, minY = 0) => Math.floor((y - minY) / 16) + 1 + +const hasChunkSection = (column: any, pos: Vec3) => { + if (column._getSection) return column._getSection(pos) + if (column.skyLightSections) { + return column.skyLightSections[getLightSectionIndex(pos.y, column.minY)] || column.blockLightSections[getLightSectionIndex(pos.y, column.minY)] + } + if (column.sections) return column.sections[pos.y >> 4] + return false +} + +const unpackNibble = (packed: Uint8Array, lx: number, ly: number, lz: number) => { + const local = (ly << 8) | (lz << 4) | lx + const byte = packed[local >> 1] ?? 0 + return local & 1 ? byte >> 4 : byte & 0x0f +} + +const hashBytes = (sections: Map) => { + let hash = 2166136261 + const keys = [...sections.keys()].sort() + for (const key of keys) { + const data = sections.get(key)! + for (let i = 0; i < key.length; i++) { + hash ^= key.charCodeAt(i) + hash = Math.imul(hash, 16777619) + } + for (let i = 0; i < data.length; i++) { + hash ^= data[i]! + hash = Math.imul(hash, 16777619) + } + } + return hash >>> 0 +} + +type LightChunk = { + getBlockLight: (pos: Vec3) => number + getSkyLight: (pos: Vec3) => number + minY?: number + _getSection?: (pos: Vec3) => unknown + skyLightSections?: unknown[] + blockLightSections?: unknown[] + sections?: unknown +} + +export type RendererLightSample = { block: number; sky: number } + +export type RendererLightIngestResult = { changed: boolean; revision: number } + +/** + * Main-thread packed block/sky light for entity sampling. + * Absent sections stay fullbright; present raw-zero gets the project +2 floor. + */ +export class RendererLightCache { + private readonly sections = new Map() + private readonly columnHashes = new Map() + private readonly columnRevisions = new Map() + private Chunk: ReturnType + private worldMinY = 0 + private worldMaxY = 256 + private globalRevision = 0 + private nextColumnRevision = 1 + + constructor(version: string) { + this.Chunk = Chunks(version) as ReturnType + } + + setVersion(version: string) { + this.Chunk = Chunks(version) as ReturnType + this.clear() + } + + setWorldBounds(minY: number, worldHeight: number) { + if (this.worldMinY === minY && this.worldMaxY === worldHeight) return + this.worldMinY = minY + this.worldMaxY = worldHeight + this.clear() + } + + clear() { + if (this.sections.size === 0 && this.columnRevisions.size === 0) { + this.globalRevision++ + return + } + this.sections.clear() + this.columnHashes.clear() + this.columnRevisions.clear() + this.globalRevision++ + } + + getAllocatedSectionCount(): number { + return this.sections.size + } + + getAllocatedBytes(): number { + return this.sections.size * RENDERER_LIGHT_BYTES_PER_SECTION + } + + getGlobalRevision(): number { + return this.globalRevision + } + + getColumnRevision(chunkX: number, chunkZ: number): number | undefined { + return this.columnRevisions.get(columnKey(alignSection(chunkX), alignSection(chunkZ))) + } + + removeColumn(chunkX: number, chunkZ: number) { + const sx = alignSection(chunkX) + const sz = alignSection(chunkZ) + const col = columnKey(sx, sz) + const syStart = alignSection(this.worldMinY) + for (let sy = syStart; sy < this.worldMaxY; sy += 16) { + this.sections.delete(sectionKey(sx, sy, sz)) + } + this.columnHashes.delete(col) + this.columnRevisions.delete(col) + } + + /** + * Direct publication write: packed 2048-byte nibble channels. + * Block-only sections keep the current sky nibble; missing sky is 0, never 15. + * `sx/sy/sz` are world-aligned section origins. + */ + ingestPackedSections(sections: Array<{ sx: number; sy: number; sz: number; blockLight: Uint8Array; skyLight?: Uint8Array }>): RendererLightIngestResult { + const touchedColumns = new Set() + for (const section of sections) { + const sx = alignSection(section.sx) + const sy = alignSection(section.sy) + const sz = alignSection(section.sz) + if (section.blockLight.length !== 2048) continue + if (section.skyLight && section.skyLight.length !== 2048) continue + const key = sectionKey(sx, sy, sz) + const prev = this.sections.get(key) + const data = prev ? new Uint8Array(prev) : new Uint8Array(RENDERER_LIGHT_BYTES_PER_SECTION) + for (let ly = 0; ly < 16; ly++) { + for (let lz = 0; lz < 16; lz++) { + for (let lx = 0; lx < 16; lx++) { + const idx = blockIndex(lx, ly, lz) + const block = unpackNibble(section.blockLight, lx, ly, lz) + const sky = section.skyLight ? unpackNibble(section.skyLight, lx, ly, lz) : data[idx]! & 0x0f + data[idx] = (block << 4) | sky + } + } + } + this.sections.set(key, data) + touchedColumns.add(columnKey(sx, sz)) + } + + let revision = 0 + for (const col of touchedColumns) { + revision = this.nextColumnRevision++ + this.columnRevisions.set(col, revision) + const packed = new Map() + for (const [key, data] of this.sections) { + const [colSx, , colSz] = key.split(',').map(Number) + if (`${colSx},${colSz}` === col) packed.set(key, data) + } + this.columnHashes.set(col, hashBytes(packed)) + } + return { changed: touchedColumns.size > 0, revision } + } + + ingestColumn(chunkX: number, chunkZ: number, chunkJson: unknown): RendererLightIngestResult { + const sx = alignSection(chunkX) + const sz = alignSection(chunkZ) + const col = columnKey(sx, sz) + const previousRevision = this.columnRevisions.get(col) ?? 0 + + let chunk: LightChunk + try { + chunk = this.Chunk.fromJson(chunkJson as Parameters['fromJson']>[0]) as unknown as LightChunk + if (typeof chunk?.getBlockLight !== 'function' || typeof chunk?.getSkyLight !== 'function') { + throw new Error('invalid chunk light API') + } + } catch { + return { changed: false, revision: previousRevision } + } + + const packed = new Map() + const pos = new Vec3(0, 0, 0) + const syStart = alignSection(this.worldMinY) + for (let sy = syStart; sy < this.worldMaxY; sy += 16) { + pos.set(0, sy, 0) + if (!hasChunkSection(chunk, pos)) continue + const data = new Uint8Array(RENDERER_LIGHT_BYTES_PER_SECTION) + for (let ly = 0; ly < 16; ly++) { + pos.y = sy + ly + for (let lz = 0; lz < 16; lz++) { + pos.z = lz + for (let lx = 0; lx < 16; lx++) { + pos.x = lx + const block = Math.max(0, Math.min(15, chunk.getBlockLight(pos) | 0)) + const sky = Math.max(0, Math.min(15, chunk.getSkyLight(pos) | 0)) + data[blockIndex(lx, ly, lz)] = (block << 4) | sky + } + } + } + packed.set(sectionKey(sx, sy, sz), data) + } + + const hash = hashBytes(packed) + if (this.columnHashes.get(col) === hash && this.columnRevisions.has(col)) { + return { changed: false, revision: previousRevision } + } + + const syStartRemove = alignSection(this.worldMinY) + for (let sy = syStartRemove; sy < this.worldMaxY; sy += 16) { + this.sections.delete(sectionKey(sx, sy, sz)) + } + for (const [key, data] of packed) { + this.sections.set(key, data) + } + this.columnHashes.set(col, hash) + const revision = this.nextColumnRevision++ + this.columnRevisions.set(col, revision) + return { changed: true, revision } + } + + getLight(x: number, y: number, z: number): RendererLightSample { + if (y < this.worldMinY || y >= this.worldMaxY) return { ...FULLBRIGHT } + const sx = alignSection(x) + const sy = alignSection(y) + const sz = alignSection(z) + const data = this.sections.get(sectionKey(sx, sy, sz)) + if (!data) return { ...FULLBRIGHT } + const packed = data[blockIndex(localCoord(x), y - sy, localCoord(z))] ?? 0 + return { + block: channelNorm(packed >> 4), + sky: channelNorm(packed & 0x0f) + } + } +} diff --git a/src/three/tests/entityLightController.test.ts b/src/three/tests/entityLightController.test.ts new file mode 100644 index 0000000..fb8b1dc --- /dev/null +++ b/src/three/tests/entityLightController.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'vitest' +import * as THREE from 'three' +import { blockEntityBrightness, DEFAULT_LIGHTMAP_PARAMS } from '../../lib/blockEntityLighting' +import { EntityLightController, entityProbeBlock, resolveEntityLightOverride } from '../entityLightController' + +const nightSky = 4 / 15 + +function makeController( + getLight: (x: number, y: number, z: number) => { block: number; sky: number } = () => ({ block: 0, sky: 1 }), + extras: Partial[0]> = {} +) { + let skyLevel = 1 + let lightingEnabled = true + let globalRevision = 1 + const columnRevisions = new Map([['0,0', 1]]) + const controller = new EntityLightController({ + getLight, + getSkyLevel: () => skyLevel, + getLightmapParams: () => DEFAULT_LIGHTMAP_PARAMS, + getColumnRevision: (cx, cz) => columnRevisions.get(`${cx},${cz}`), + getGlobalRevision: () => globalRevision, + lightingEnabled: () => lightingEnabled, + ...extras + }) + return { + controller, + setSkyLevel: (value: number) => { + skyLevel = value + controller.setSkyLevel(value) + }, + setLightingEnabled: (value: boolean) => { + lightingEnabled = value + }, + bumpColumn: () => { + columnRevisions.set('0,0', (columnRevisions.get('0,0') ?? 0) + 1) + }, + bumpFarColumn: () => { + columnRevisions.set('16,0', (columnRevisions.get('16,0') ?? 0) + 1) + } + } +} + +describe('entityProbeBlock', () => { + it('samples the eye block, not the block above the AABB', () => { + expect(entityProbeBlock({ x: 8.2, y: 64.3, z: 8.7 }, 1.62)).toEqual({ x: 8, y: 65, z: 8 }) + expect(entityProbeBlock({ x: 8.2, y: 64.3, z: 8.7 }, 1.8)).toEqual({ x: 8, y: 66, z: 8 }) + }) +}) + +describe('resolveEntityLightOverride', () => { + it('fullbrights only reliably identified glow item frames', () => { + expect(resolveEntityLightOverride('glow_item_frame')).toBe('fullbright') + expect(resolveEntityLightOverride('item_frame')).toBe('normal') + expect(resolveEntityLightOverride('blaze')).toBe('normal') + }) +}) + +describe('EntityLightController', () => { + it('multiplies immutable base tint and is idempotent', () => { + const { controller } = makeController(() => ({ block: 0, sky: 1 })) + const material = new THREE.MeshLambertMaterial({ color: 0xb5_6d_51 }) + const mesh = new THREE.Mesh(new THREE.BoxGeometry(), material) + const root = new THREE.Group() + root.add(mesh) + const base = material.color.clone() + controller.register(root, { eyeHeight: 1.62 }) + controller.update(root, { x: 8, y: 64, z: 8 }) + const brightness = blockEntityBrightness(0, 1, 1) + expect(material.color.r).toBeCloseTo(base.r * brightness, 5) + expect(material.color.g).toBeCloseTo(base.g * brightness, 5) + expect(material.color.b).toBeCloseTo(base.b * brightness, 5) + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(material.color.r).toBeCloseTo(base.r * brightness, 5) + }) + + it('clones a shared material and leaves the original fullbright', () => { + const shared = new THREE.MeshBasicMaterial({ color: 0xff_00_00 }) + const { controller } = makeController(() => ({ block: 0, sky: 0 })) + const mesh = new THREE.Mesh(new THREE.BoxGeometry(), shared) + const root = new THREE.Group() + root.add(mesh) + controller.register(root, { eyeHeight: 0 }, new Set([shared])) + controller.update(root, { x: 1, y: 64, z: 1 }) + expect(shared.color.getHex()).toBe(0xff_00_00) + expect(mesh.material).not.toBe(shared) + const brightness = blockEntityBrightness(0, 0, 1) + expect((mesh.material as THREE.MeshBasicMaterial).color.r).toBeCloseTo(1 * brightness, 5) + }) + + it('keeps two roots independent and skips debug helpers', () => { + let sample = { block: 1, sky: 1 } + const { controller } = makeController(() => sample) + const brightMat = new THREE.MeshBasicMaterial({ color: 0xff_ff_ff }) + const darkMat = new THREE.MeshBasicMaterial({ color: 0xff_ff_ff }) + const debugMat = new THREE.MeshBasicMaterial({ color: 0x00_ff_00 }) + const bright = new THREE.Group() + bright.add(new THREE.Mesh(new THREE.BoxGeometry(), brightMat)) + const dark = new THREE.Group() + dark.add(new THREE.Mesh(new THREE.BoxGeometry(), darkMat)) + const debug = new THREE.Mesh(new THREE.BoxGeometry(), debugMat) + debug.name = 'debug' + dark.add(debug) + controller.register(bright, { eyeHeight: 0 }) + controller.register(dark, { eyeHeight: 0 }) + controller.update(bright, { x: 1, y: 64, z: 1 }) + sample = { block: 0, sky: 0 } + controller.update(dark, { x: 20, y: 64, z: 20 }) + expect(brightMat.color.r).toBeCloseTo(1, 5) + expect(darkMat.color.r).toBeCloseTo(blockEntityBrightness(0, 0, 1), 5) + expect(debugMat.color.getHex()).toBe(0x00_ff_00) + }) + + it('refreshes on sky change and stays fullbright when lighting is disabled', () => { + const { controller, setSkyLevel, setLightingEnabled } = makeController(() => ({ block: 0, sky: 1 })) + const material = new THREE.MeshBasicMaterial({ color: 0xff_ff_ff }) + const root = new THREE.Group() + root.add(new THREE.Mesh(new THREE.BoxGeometry(), material)) + controller.register(root, { eyeHeight: 0 }) + controller.update(root, { x: 8, y: 64, z: 8 }) + setSkyLevel(nightSky) + expect(material.color.r).toBeCloseTo(blockEntityBrightness(0, 1, nightSky), 5) + setLightingEnabled(false) + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(material.color.r).toBeCloseTo(1, 5) + }) + + it('resamples a stationary root when the column revision changes', () => { + let sample = { block: 1, sky: 1 } + const { controller, bumpColumn } = makeController(() => sample) + const material = new THREE.MeshBasicMaterial({ color: 0xff_ff_ff }) + const root = new THREE.Group() + root.add(new THREE.Mesh(new THREE.BoxGeometry(), material)) + controller.register(root, { eyeHeight: 0 }) + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(material.color.r).toBeCloseTo(1, 5) + sample = { block: 0, sky: 0 } + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(material.color.r).toBeCloseTo(1, 5) + bumpColumn() + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(material.color.r).toBeCloseTo(blockEntityBrightness(0, 0, 1), 5) + }) + + it('does not resample when only a distant column revision changes', () => { + let sample = { block: 1, sky: 1 } + const { controller, bumpFarColumn } = makeController(() => sample) + const material = new THREE.MeshBasicMaterial({ color: 0xff_ff_ff }) + const root = new THREE.Group() + root.add(new THREE.Mesh(new THREE.BoxGeometry(), material)) + controller.register(root, { eyeHeight: 0 }) + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(material.color.r).toBeCloseTo(1, 5) + sample = { block: 0, sky: 0 } + bumpFarColumn() + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(material.color.r).toBeCloseTo(1, 5) + }) + + it('adopts an authored material retint after invalidation', () => { + const { controller } = makeController(() => ({ block: 0, sky: 0 })) + const material = new THREE.MeshBasicMaterial({ color: 0xb5_6d_51 }) + const mesh = new THREE.Mesh(new THREE.BoxGeometry(), material) + const root = new THREE.Group() + root.add(mesh) + controller.register(root, { eyeHeight: 0 }) + controller.update(root, { x: 8, y: 64, z: 8 }) + const brightness = blockEntityBrightness(0, 0, 1) + const brown = new THREE.Color(0xb5_6d_51) + expect(material.color.r).toBeCloseTo(brown.r * brightness, 5) + material.color.setHex(0x00_ff_00) + controller.invalidateMaterials(root) + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(material.color.r).toBeCloseTo(0, 5) + expect(material.color.g).toBeCloseTo(brightness, 5) + expect(material.color.b).toBeCloseTo(0, 5) + }) + + it('keeps glow item frames fullbright and recovers after a damage flash', () => { + const { controller } = makeController(() => ({ block: 0, sky: 0 })) + const material = new THREE.MeshBasicMaterial({ color: 0xaa_aa_aa }) + const baseR = material.color.r + const mesh = new THREE.Mesh(new THREE.BoxGeometry(), material) + const root = new THREE.Group() + root.add(mesh) + controller.register(root, { name: 'glow_item_frame', eyeHeight: 0 }) + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(material.color.r).toBeCloseTo(baseR, 5) + + const flashed = material.clone() + flashed.color.set(0xff_00_00) + mesh.material = flashed + controller.markFlash(root, 500) + controller.invalidateMaterials(root) + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(flashed.color.getHex()).toBe(0xff_00_00) + controller.endFlash(root) + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(flashed.color.r).toBeCloseTo(baseR, 5) + }) + + it('unregisters so later updates do not keep mutating materials', () => { + const { controller } = makeController(() => ({ block: 0, sky: 0 })) + const material = new THREE.MeshBasicMaterial({ color: 0xff_ff_ff }) + const root = new THREE.Group() + root.add(new THREE.Mesh(new THREE.BoxGeometry(), material)) + controller.register(root, { eyeHeight: 0 }) + controller.update(root, { x: 8, y: 64, z: 8 }) + const dim = material.color.r + controller.unregister(root) + material.color.setHex(0xff_ff_ff) + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(material.color.getHex()).toBe(0xff_ff_ff) + expect(dim).toBeLessThan(1) + }) +}) diff --git a/src/three/tests/globalBlockBuffer.test.ts b/src/three/tests/globalBlockBuffer.test.ts index 0a3444f..3cd7bb4 100644 --- a/src/three/tests/globalBlockBuffer.test.ts +++ b/src/three/tests/globalBlockBuffer.test.ts @@ -214,3 +214,101 @@ test('GlobalBlockBuffer: uploadEpoch increments when dirty range drains', () => buffer.dispose() mat.dispose() }) + +function faceWords(faceCount: number): Uint32Array { + return new Uint32Array(faceCount * 4) +} + +function drawableCubeFaces(buffer: GlobalBlockBuffer, key: string): number { + const start = buffer.getSectionDrawStart(key) + const count = buffer.getSectionDrawCount(key) + if (start === undefined || count === undefined) return 0 + const spans = buildVisibleCubeSpans([{ start, count }], buffer.getHighWatermark(), false, undefined, buffer.getPendingDirtyRanges()) + return spans.reduce((sum, span) => sum + span.count, 0) +} + +test('GlobalBlockBuffer: compaction must not hide pending-replace displayed faces', () => { + const scene = new THREE.Scene() + const mat = createCubeBlockMaterial() + const buffer = new GlobalBlockBuffer(mat, scene) + + buffer.addSection('H', faceWords(20_000), 20_000) + buffer.addSection('A', faceWords(8000), 8000) + buffer.addSection('B', faceWords(1000), 1000) + drainUploads(buffer) + const original = buffer.getSectionDrawStart('A') + + buffer.addSection('A', faceWords(8000), 8000) + buffer.removeSection('H') + buffer.compactStep() + buffer.uploadDirtyRange() + + expect(drawableCubeFaces(buffer, 'A')).toBe(8000) + expect(buffer.getSectionDrawStart('A')).toBe(original) + const move = buffer.getPendingMove() + expect(move?.key === 'A').toBe(false) + + buffer.dispose() + mat.dispose() +}) + +test('GlobalBlockBuffer: 27 simultaneous remeshes keep 8000 faces on every frame', () => { + const scene = new THREE.Scene() + const mat = createCubeBlockMaterial() + const buffer = new GlobalBlockBuffer(mat, scene) + const n = 27 + const faces = 8000 + + for (let i = 0; i < n; i++) buffer.addSection(String(i), faceWords(faces), faces) + drainUploads(buffer) + for (let i = 0; i < n; i++) buffer.addSection(String(i), faceWords(faces), faces) + + for (let frame = 1; frame <= 120; frame++) { + buffer.compactStep() + buffer.uploadDirtyRange() + for (let i = 0; i < n; i++) { + expect(drawableCubeFaces(buffer, String(i)), `section ${i} on frame ${frame}`).toBe(faces) + } + if (!buffer.hasPendingUploads() && !buffer.hasPendingReplace() && !buffer.getPendingMove()) break + } + + buffer.dispose() + mat.dispose() +}) + +test('GlobalBlockBuffer: remesh during move keeps original displayed slot', () => { + const scene = new THREE.Scene() + const mat = createCubeBlockMaterial() + const buffer = new GlobalBlockBuffer(mat, scene) + + buffer.addSection('H', faceWords(20_000), 20_000) + buffer.addSection('B', faceWords(1000), 1000) + buffer.addSection('A', faceWords(8000), 8000) + drainUploads(buffer) + buffer.removeSection('H') + drainUploads(buffer) + buffer.compactStep() + expect(buffer.getPendingMove()?.key).toBe('A') + const displayed = buffer.getSectionDrawStart('A') + expect(displayed).toBeDefined() + + buffer.addSection('A', faceWords(8000), 8000) + expect(buffer.getSectionDrawStart('A')).toBe(displayed) + expect(buffer.getPendingMove()).toBeNull() + expect(buffer.hasPendingReplace()).toBe(true) + expect(drawableCubeFaces(buffer, 'A')).toBe(8000) + + buffer.compactStep() + buffer.uploadDirtyRange() + expect(buffer.getSectionDrawStart('A')).toBe(displayed) + expect(buffer.getPendingMove()?.key === 'A').toBe(false) + expect(drawableCubeFaces(buffer, 'A')).toBe(8000) + + drainUploads(buffer) + buffer.compactStep() + finishCurrentMove(buffer) + expect(drawableCubeFaces(buffer, 'A')).toBe(8000) + + buffer.dispose() + mat.dispose() +}) diff --git a/src/three/tests/globalLegacyBuffer.test.ts b/src/three/tests/globalLegacyBuffer.test.ts index 4926e41..3c92ef5 100644 --- a/src/three/tests/globalLegacyBuffer.test.ts +++ b/src/three/tests/globalLegacyBuffer.test.ts @@ -891,3 +891,111 @@ test('GlobalLegacyBuffer: blend reorder metadata survives compaction', () => { buffer.dispose() mat.dispose() }) + +function quadGeometry(quadCount: number): ReturnType { + const indices = new Uint32Array(quadCount * 6) + for (let i = 0; i < quadCount; i++) { + const v = i * 4 + indices.set([v, v + 1, v + 2, v, v + 2, v + 3], i * 6) + } + return { + positions: new Float32Array(quadCount * 12), + colors: new Float32Array(quadCount * 12), + skyLights: new Float32Array(quadCount * 4), + blockLights: new Float32Array(quadCount * 4), + uvs: new Float32Array(quadCount * 8), + indices + } +} + +function drawableLegacyIndices(buffer: GlobalLegacyBuffer, key: string): number { + buffer.updateDrawSpans([{ key, distSq: 0 }], 'opaque') + return buffer.getVisibleIndexSpans().reduce((sum, span) => sum + span.indexCount, 0) +} + +test('GlobalLegacyBuffer: compaction must not hide pending-replace displayed indices', () => { + const scene = new THREE.Scene() + const mat = createGlobalLegacyBlockMaterial() + const buffer = new GlobalLegacyBuffer(mat, scene) + + buffer.addSection('H', quadGeometry(8000), 0, 0, 0) + buffer.addSection('A', quadGeometry(3000), 0, 0, 0) + buffer.addSection('B', quadGeometry(500), 0, 0, 0) + drainUploads(buffer) + const original = buffer.getSectionDrawStart('A') + + buffer.addSection('A', quadGeometry(3000), 0, 0, 0) + buffer.removeSection('H') + buffer.compactStep() + buffer.uploadDirtyRange() + buffer.updateDrawSpans([{ key: 'A', distSq: 0 }], 'opaque') + + expect(drawableLegacyIndices(buffer, 'A')).toBe(18_000) + expect(buffer.getSectionDrawStart('A')).toBe(original) + expect(buffer.getPendingMove()?.key === 'A').toBe(false) + + buffer.dispose() + mat.dispose() +}) + +test('GlobalLegacyBuffer: 27 simultaneous remeshes keep displayed quads on every frame', () => { + const scene = new THREE.Scene() + const mat = createGlobalLegacyBlockMaterial() + const buffer = new GlobalLegacyBuffer(mat, scene) + const n = 27 + const quads = 1000 + const expectedIndices = quads * 6 + + for (let i = 0; i < n; i++) buffer.addSection(String(i), quadGeometry(quads), 0, 0, 0) + drainUploads(buffer) + for (let i = 0; i < n; i++) buffer.addSection(String(i), quadGeometry(quads), 0, 0, 0) + + for (let frame = 1; frame <= 160; frame++) { + buffer.compactStep() + buffer.uploadDirtyRange() + for (let i = 0; i < n; i++) { + expect(drawableLegacyIndices(buffer, String(i)), `section ${i} on frame ${frame}`).toBe(expectedIndices) + } + if (!buffer.hasPendingUploads() && !buffer.hasPendingReplace() && !buffer.getPendingMove()) break + } + + buffer.dispose() + mat.dispose() +}) + +test('GlobalLegacyBuffer: remesh during move keeps original displayed slot', () => { + const scene = new THREE.Scene() + const mat = createGlobalLegacyBlockMaterial() + const buffer = new GlobalLegacyBuffer(mat, scene) + + buffer.addSection('H', quadGeometry(8000), 0, 0, 0) + buffer.addSection('B', quadGeometry(500), 0, 0, 0) + buffer.addSection('A', quadGeometry(3000), 0, 0, 0) + drainUploads(buffer) + buffer.removeSection('H') + drainUploads(buffer) + buffer.compactStep() + expect(buffer.getPendingMove()?.key).toBe('A') + const displayed = buffer.getSectionDrawStart('A') + expect(displayed).toBeDefined() + + buffer.addSection('A', quadGeometry(3000), 0, 0, 0) + expect(buffer.getSectionDrawStart('A')).toBe(displayed) + expect(buffer.getPendingMove()).toBeNull() + expect(buffer.hasPendingReplace()).toBe(true) + expect(drawableLegacyIndices(buffer, 'A')).toBe(18_000) + + buffer.compactStep() + buffer.uploadDirtyRange() + expect(buffer.getSectionDrawStart('A')).toBe(displayed) + expect(buffer.getPendingMove()?.key === 'A').toBe(false) + expect(drawableLegacyIndices(buffer, 'A')).toBe(18_000) + + drainUploads(buffer) + buffer.compactStep() + finishCurrentMove(buffer) + expect(drawableLegacyIndices(buffer, 'A')).toBe(18_000) + + buffer.dispose() + mat.dispose() +}) diff --git a/src/three/tests/pendingSectionFlush.test.ts b/src/three/tests/pendingSectionFlush.test.ts new file mode 100644 index 0000000..6c265fb --- /dev/null +++ b/src/three/tests/pendingSectionFlush.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'vitest' +import { faceNeighborKeys, pendingSectionGroups, selectReadySectionFlushes, selectReadySectionUpdates } from '../pendingSectionFlush' + +const SECTION_HEIGHT = 16 +const MAX_BUFFER_MS = 500 + +type Scene = { + pending: Map + outstanding: Set + visible: Set +} + +function select(scene: Scene, now: number) { + return selectReadySectionUpdates({ + pendingKeys: scene.pending.keys(), + startedAt: key => scene.pending.get(key), + now, + maxBufferMs: MAX_BUFFER_MS, + sectionHeight: SECTION_HEIGHT, + isOutstanding: key => scene.outstanding.has(key), + hasSectionObject: key => scene.visible.has(key) + }) +} + +/** + * The per-key policy this module replaced (worldRendererThree before the group + * flush): each key waited out its OWN deadline and was released on its own. + * Kept here so the regression it caused stays pinned by a test. + */ +function selectPerKeyLegacy(scene: Scene, now: number) { + const ready: string[] = [] + for (const key of scene.pending.keys()) { + const startedAt = scene.pending.get(key) ?? now + if (now - startedAt < MAX_BUFFER_MS) { + const busy = faceNeighborKeys(key, SECTION_HEIGHT).some( + neighbor => scene.outstanding.has(neighbor) && !scene.pending.has(neighbor) && scene.visible.has(neighbor) + ) + if (busy) continue + } + ready.push(key) + } + return ready +} + +describe('pendingSectionGroups', () => { + it('splits buffered sections into face-connected components', () => { + const groups = pendingSectionGroups(['0,64,0', '0,80,0', '160,64,160'], SECTION_HEIGHT) + expect(groups).toHaveLength(2) + expect(groups.find(g => g.length === 2)?.sort()).toEqual(['0,64,0', '0,80,0']) + expect(groups.find(g => g.length === 1)).toEqual(['160,64,160']) + }) +}) + +describe('selectReadySectionUpdates', () => { + it('holds a section while a visible face neighbour is still being re-meshed', () => { + const scene: Scene = { + pending: new Map([['0,64,0', 1000]]), + outstanding: new Set(['16,64,0']), + visible: new Set(['16,64,0']) + } + expect(select(scene, 1050)).toEqual([]) + }) + + it('flushes as soon as every neighbour of the group has arrived, without waiting the deadline', () => { + const scene: Scene = { + pending: new Map([ + ['0,64,0', 1000], + ['16,64,0', 1040] + ]), + outstanding: new Set(), + visible: new Set(['0,64,0', '16,64,0']) + } + expect(select(scene, 1050).sort()).toEqual(['0,64,0', '16,64,0']) + }) + + it('does not wait on an air neighbour that has no geometry on screen', () => { + const scene: Scene = { + pending: new Map([['0,64,0', 1000]]), + outstanding: new Set(['0,80,0']), + visible: new Set(['0,64,0']) + } + expect(select(scene, 1010)).toEqual(['0,64,0']) + }) + + // The sky-flash regression: the dug section and its face neighbours are + // buffered at staggered times (they come back on different worker ticks). + // Releasing the earliest one alone shows the neighbour's still-culled faces + // as a hole, so the whole group must go out in the same frame. + it('releases a staggered group in one batch when the deadline expires', () => { + const scene: Scene = { + pending: new Map([ + ['0,64,0', 1000], + ['16,64,0', 1120], + ['0,80,0', 1180] + ]), + outstanding: new Set(['32,64,0']), + visible: new Set(['0,64,0', '16,64,0', '0,80,0', '32,64,0']) + } + expect(select(scene, 1500).sort()).toEqual(['0,64,0', '0,80,0', '16,64,0']) + // The legacy policy installs 0,64,0 while 16,64,0 keeps the geometry it was + // meshed with before the edit — the faces between them stay culled on both + // sides for a frame, which is the hole seen as a sky flash. + expect(selectPerKeyLegacy(scene, 1500)).not.toContain('16,64,0') + expect(selectPerKeyLegacy(scene, 1500)).toContain('0,64,0') + }) + + it('measures the deadline from the oldest member so a group cannot be starved', () => { + const scene: Scene = { + pending: new Map([ + ['0,64,0', 1000], + ['16,64,0', 1400] + ]), + outstanding: new Set(['32,64,0']), + visible: new Set(['0,64,0', '16,64,0', '32,64,0']) + } + expect(select(scene, 1499)).toEqual([]) + expect(select(scene, 1500).sort()).toEqual(['0,64,0', '16,64,0']) + }) + + it('keeps unrelated groups independent', () => { + const scene: Scene = { + pending: new Map([ + ['0,64,0', 1000], + ['160,64,160', 1000] + ]), + outstanding: new Set(['16,64,0']), + visible: new Set(['0,64,0', '16,64,0', '160,64,160']) + } + expect(select(scene, 1010)).toEqual(['160,64,160']) + }) + + it('labels complete groups vs deadline flushes', () => { + const complete: Scene = { + pending: new Map([['160,64,160', 1000]]), + outstanding: new Set(), + visible: new Set(['160,64,160']) + } + expect( + selectReadySectionFlushes({ + pendingKeys: complete.pending.keys(), + startedAt: key => complete.pending.get(key), + now: 1010, + maxBufferMs: MAX_BUFFER_MS, + sectionHeight: SECTION_HEIGHT, + isOutstanding: key => complete.outstanding.has(key), + hasSectionObject: key => complete.visible.has(key) + }) + ).toEqual([{ key: '160,64,160', reason: 'group-complete' }]) + + const expired: Scene = { + pending: new Map([ + ['0,64,0', 1000], + ['16,64,0', 1120] + ]), + outstanding: new Set(['32,64,0']), + visible: new Set(['0,64,0', '16,64,0', '32,64,0']) + } + expect( + selectReadySectionFlushes({ + pendingKeys: expired.pending.keys(), + startedAt: key => expired.pending.get(key), + now: 1500, + maxBufferMs: MAX_BUFFER_MS, + sectionHeight: SECTION_HEIGHT, + isOutstanding: key => expired.outstanding.has(key), + hasSectionObject: key => expired.visible.has(key) + }).map(item => item.reason) + ).toEqual(['deadline', 'deadline']) + }) +}) + +describe('selectReadySectionFlushes topology vs covering', () => { + const flushQuery = (scene: Scene, now: number, topologyOutstanding?: Set) => + selectReadySectionFlushes({ + pendingKeys: scene.pending.keys(), + startedAt: key => scene.pending.get(key), + now, + maxBufferMs: MAX_BUFFER_MS, + sectionHeight: SECTION_HEIGHT, + isOutstanding: key => scene.outstanding.has(key), + isTopologyOutstanding: topologyOutstanding ? key => topologyOutstanding.has(key) : undefined, + hasSectionObject: key => scene.visible.has(key) + }) + + it('holds a group while a visible neighbour is topology-outstanding', () => { + const scene: Scene = { + pending: new Map([['0,64,0', 1000]]), + outstanding: new Set(), + visible: new Set(['0,64,0', '16,64,0']) + } + expect(flushQuery(scene, 1050, new Set(['16,64,0'])).map(item => item.key)).toEqual([]) + }) + + it('does not hold a group for covering-only outstanding neighbours', () => { + const scene: Scene = { + pending: new Map([['0,64,0', 1000]]), + outstanding: new Set(['16,64,0']), + visible: new Set(['0,64,0', '16,64,0']) + } + expect(flushQuery(scene, 1050, new Set()).map(item => item.key)).toEqual(['0,64,0']) + expect(flushQuery(scene, 1050, new Set())[0]?.reason).toBe('group-complete') + }) + + it('holds a singleton with a topology-outstanding neighbour until the deadline', () => { + const scene: Scene = { + pending: new Map([['0,64,0', 1000]]), + outstanding: new Set(['16,64,0']), + visible: new Set(['0,64,0', '16,64,0']) + } + expect(flushQuery(scene, 1499, new Set(['16,64,0']))).toEqual([]) + expect(flushQuery(scene, 1500, new Set(['16,64,0']))).toEqual([{ key: '0,64,0', reason: 'deadline' }]) + }) +}) diff --git a/src/three/tests/rendererLightCache.test.ts b/src/three/tests/rendererLightCache.test.ts new file mode 100644 index 0000000..86465ef --- /dev/null +++ b/src/three/tests/rendererLightCache.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import Chunks from 'prismarine-chunk' +import MinecraftData from 'minecraft-data' +import { Vec3 } from 'vec3' +import { RENDERER_LIGHT_BYTES_PER_SECTION, RendererLightCache } from '../rendererLightCache' + +const VERSION = '1.16.5' + +function makeChunk() { + const Chunk = Chunks(VERSION) as any + return new Chunk(undefined as any) +} + +function makePresentZeroSection(y = 64) { + const mcData = MinecraftData(VERSION) + const chunk = makeChunk() + const stoneId = mcData.blocksByName.stone.defaultState + chunk.setBlockStateId(new Vec3(8, y, 8), stoneId) + // Allocate light sections, then zero them. All-zero light is otherwise omitted + // and must stay the fullbright "absent section" fallback. + chunk.setBlockLight(new Vec3(8, y, 8), 1) + chunk.setSkyLight(new Vec3(8, y, 8), 1) + chunk.setBlockLight(new Vec3(8, y, 8), 0) + chunk.setSkyLight(new Vec3(8, y, 8), 0) + return chunk +} + +function makeLitBlock(blockLight: number, skyLight: number, x = 8, y = 64, z = 8) { + const mcData = MinecraftData(VERSION) + const chunk = makeChunk() + const stoneId = mcData.blocksByName.stone.defaultState + chunk.setBlockStateId(new Vec3(x, y, z), stoneId) + chunk.setBlockLight(new Vec3(x, y, z), blockLight) + chunk.setSkyLight(new Vec3(x, y, z), skyLight) + return chunk +} + +describe('RendererLightCache', () => { + it('returns fullbright for a missing column or absent section', () => { + const cache = new RendererLightCache(VERSION) + cache.setWorldBounds(0, 256) + expect(cache.getLight(8, 64, 8)).toEqual({ block: 0, sky: 1 }) + + cache.ingestColumn(0, 0, makeChunk().toJson()) + expect(cache.getLight(8, 64, 8)).toEqual({ block: 0, sky: 1 }) + }) + + it('applies +2 floor for a present raw-zero section and clamps at 15', () => { + const cache = new RendererLightCache(VERSION) + cache.setWorldBounds(0, 256) + cache.ingestColumn(0, 0, makePresentZeroSection().toJson()) + expect(cache.getLight(8, 64, 8).block).toBeCloseTo(2 / 15, 5) + expect(cache.getLight(8, 64, 8).sky).toBeCloseTo(2 / 15, 5) + + cache.ingestColumn(0, 0, makeLitBlock(14, 14).toJson()) + expect(cache.getLight(8, 64, 8)).toEqual({ block: 1, sky: 1 }) + }) + + it('reads packed channels at negative chunk coordinates', () => { + const cache = new RendererLightCache(VERSION) + cache.setWorldBounds(0, 256) + cache.ingestColumn(-16, -16, makeLitBlock(13, 4, 15, 64, 15).toJson()) + const light = cache.getLight(-1, 64, -1) + expect(light.block).toBeCloseTo(15 / 15, 5) + expect(light.sky).toBeCloseTo(6 / 15, 5) + }) + + it('does not bump column revision on identical re-ingest', () => { + const cache = new RendererLightCache(VERSION) + cache.setWorldBounds(0, 256) + const json = makeLitBlock(10, 5).toJson() + const first = cache.ingestColumn(0, 0, json) + const second = cache.ingestColumn(0, 0, json) + expect(first.changed).toBe(true) + expect(second.changed).toBe(false) + expect(second.revision).toBe(first.revision) + expect(cache.getColumnRevision(0, 0)).toBe(first.revision) + }) + + it('bumps revision when light changes and forgets removed columns', () => { + const cache = new RendererLightCache(VERSION) + cache.setWorldBounds(0, 256) + const first = cache.ingestColumn(0, 0, makeLitBlock(3, 0).toJson()) + const second = cache.ingestColumn(0, 0, makeLitBlock(12, 0).toJson()) + expect(second.changed).toBe(true) + expect(second.revision).toBeGreaterThan(first.revision) + expect(cache.getLight(8, 64, 8).block).toBeCloseTo(14 / 15, 5) + + const globalBefore = cache.getGlobalRevision() + cache.removeColumn(0, 0) + expect(cache.getLight(8, 64, 8)).toEqual({ block: 0, sky: 1 }) + expect(cache.getGlobalRevision()).toBe(globalBefore) + expect(cache.getAllocatedSectionCount()).toBe(0) + }) + + it('does not bump global revision on ingest of another column', () => { + const cache = new RendererLightCache(VERSION) + cache.setWorldBounds(0, 256) + cache.ingestColumn(0, 0, makeLitBlock(3, 0).toJson()) + const globalBefore = cache.getGlobalRevision() + cache.ingestColumn(16, 0, makeLitBlock(12, 0).toJson()) + expect(cache.getGlobalRevision()).toBe(globalBefore) + expect(cache.getColumnRevision(16, 0)).toBeGreaterThan(0) + }) + + it('keeps prior column state when ingest fails', () => { + const cache = new RendererLightCache(VERSION) + cache.setWorldBounds(0, 256) + const ok = cache.ingestColumn(0, 0, makeLitBlock(8, 0).toJson()) + const failed = cache.ingestColumn(0, 0, { not: 'a-chunk' }) + expect(failed.changed).toBe(false) + expect(failed.revision).toBe(ok.revision) + expect(cache.getLight(8, 64, 8).block).toBeCloseTo(10 / 15, 5) + }) + + it('clear and bounds change drop light and bump the global revision', () => { + const cache = new RendererLightCache(VERSION) + cache.setWorldBounds(0, 256) + cache.ingestColumn(16, 32, makeLitBlock(15, 15).toJson()) + expect(cache.getAllocatedBytes()).toBeGreaterThanOrEqual(RENDERER_LIGHT_BYTES_PER_SECTION) + const globalBefore = cache.getGlobalRevision() + cache.clear() + expect(cache.getLight(24, 64, 40)).toEqual({ block: 0, sky: 1 }) + expect(cache.getGlobalRevision()).toBeGreaterThan(globalBefore) + }) +}) + +describe('RendererLightCache 1.18+', () => { + const VERSION_118 = '1.18.2' + const MIN_Y = -64 + const WORLD_HEIGHT = 384 + + function make118Chunk() { + const Chunk = Chunks(VERSION_118) as any + return new Chunk({ minY: MIN_Y, worldHeight: WORLD_HEIGHT }) + } + + function make118LitBlock(blockLight: number, skyLight: number, x = 8, y = -60, z = 8) { + const mcData = MinecraftData(VERSION_118) + const chunk = make118Chunk() + const stoneId = mcData.blocksByName.stone.defaultState + chunk.setBlockStateId(new Vec3(x, y, z), stoneId) + chunk.setBlockLight(new Vec3(x, y, z), blockLight) + chunk.setSkyLight(new Vec3(x, y, z), skyLight) + return chunk + } + + it('packs and reads light in a negative-Y section', () => { + const cache = new RendererLightCache(VERSION_118) + cache.setWorldBounds(MIN_Y, WORLD_HEIGHT) + cache.ingestColumn(0, 0, make118LitBlock(11, 3).toJson()) + const light = cache.getLight(8, -60, 8) + expect(light.block).toBeCloseTo(13 / 15, 5) + expect(light.sky).toBeCloseTo(5 / 15, 5) + expect(cache.getLight(8, -64, 8).block).toBeGreaterThan(0) + }) +}) diff --git a/src/three/worldRendererThree.ts b/src/three/worldRendererThree.ts index 85bf0b5..9f33d67 100644 --- a/src/three/worldRendererThree.ts +++ b/src/three/worldRendererThree.ts @@ -39,6 +39,9 @@ import { FireworksManager } from './fireworks' import { SceneOrigin } from './sceneOrigin' import { downloadWorldGeometry } from './worldGeometryExport' import { ChunkMeshManager } from './chunkMeshManager' +import { selectReadySectionFlushes } from './pendingSectionFlush' +import { isClientLightTraceEnabled, recordClientLightTrace } from '../lib/clientLightTrace' +import { EntityLightController } from './entityLightController' import { raycastVoxelSolid } from './thirdPersonVoxelRaycast' import type { RendererModuleManifest, RegisteredModule, RendererModuleController } from './rendererModuleSystem' import { BUILTIN_MODULES } from './modules/index' @@ -88,6 +91,7 @@ export class WorldRendererThree extends WorldRendererCommon { ambientLight = new THREE.AmbientLight(0xcc_cc_cc, WorldRendererThree.LEGACY_TO_PHYSICAL_LIGHT) directionalLight = new THREE.DirectionalLight(0xff_ff_ff, 0.5 * WorldRendererThree.LEGACY_TO_PHYSICAL_LIGHT) entities = new Entities(this, (globalThis as any).mcData) + entityLightController?: EntityLightController performanceMonitor!: PerformanceMonitor cameraGroupVr?: THREE.Object3D material = new THREE.MeshBasicMaterial({ vertexColors: true, transparent: true, alphaTest: 0.1 }) @@ -203,6 +207,14 @@ export class WorldRendererThree extends WorldRendererCommon { // Initialize chunk mesh manager this.chunkMeshManager = new ChunkMeshManager(this, this.scene, this.material, this.worldSizeParams.worldHeight, this.viewDistance) + this.entityLightController = new EntityLightController({ + getLight: (x, y, z) => this.rendererLightCache.getLight(x, y, z), + getSkyLevel: () => this.chunkMeshManager.getSkyLevel(), + getLightmapParams: () => this.chunkMeshManager.getLightmapParams(), + getColumnRevision: (cx, cz) => this.rendererLightCache.getColumnRevision(cx, cz), + getGlobalRevision: () => this.rendererLightCache.getGlobalRevision(), + lightingEnabled: () => this.worldRendererConfig.enableLighting !== false + }) this.onRenderDistanceChanged = viewDistance => { this.chunkMeshManager.updateViewDistance(viewDistance) } @@ -562,6 +574,9 @@ export class WorldRendererThree extends WorldRendererCommon { this.cursorBlock.setHighlightCursorBlock(value ? new Vec3(value.x, value.y, value.z) : null, value?.shapes) }) this.onReactivePlayerStateUpdated('diggingBlock', value => { + if (value && (value.stage == null || value.stage === 0) && isClientLightTraceEnabled()) { + recordClientLightTrace({ phase: 'inputClick', sectionKey: `${value.x},${value.y},${value.z}` }) + } this.cursorBlock.updateBreakAnimation(value ? { x: value.x, y: value.y, z: value.z } : undefined, value?.stage ?? null, value?.mergedShape) }) this.onReactivePlayerStateUpdated('perspective', value => { @@ -593,6 +608,13 @@ export class WorldRendererThree extends WorldRendererCommon { this.onReactiveConfigUpdated('futuristicReveal', () => { this.updateModulesFromConfig() }) + this.onReactiveConfigUpdated( + 'enableLighting', + () => { + this.rerenderAllChunks() + }, + false + ) this.onReactiveDebugUpdated('disableEntities', () => { this.entities.syncSceneAttachment() }) @@ -825,6 +847,10 @@ export class WorldRendererThree extends WorldRendererCommon { if (camCollisionBytes > 0) { text += `CAM: ${formatCompact(camCollisionBytes)}b ` } + const entityLightBytes = this.rendererLightCache.getAllocatedBytes() + if (entityLightBytes > 0) { + text += `ELIGHT: ${formatCompact(entityLightBytes)}b ` + } const pf = formatPerformanceFactorsDebug(this.reactiveState.world.instabilityFactors) if (pf) text += `PF: ${pf} ` // entities can be seen in F3 @@ -879,45 +905,37 @@ export class WorldRendererThree extends WorldRendererCommon { if (this.pendingSectionUpdates.size === 0) return const now = performance.now() - const sectionHeight = this.getSectionHeight() - const ready: string[] = [] - - for (const key of this.pendingSectionUpdates.keys()) { - const startedAt = this.pendingSectionBufferStartTimes.get(key) ?? now - const sinceFirst = now - startedAt - - if (sinceFirst < WorldRendererThree.MAX_SECTION_UPDATE_BUFFER_MS) { - // Still within this section's grace window — wait if any neighbor is - // currently being re-meshed so we don't briefly expose a hole between - // the just-updated section and a stale neighbor (sky-flicker bug). - const [sx, sy, sz] = key.split(',').map(Number) - const neighborKeys = [ - `${sx - 16},${sy},${sz}`, - `${sx + 16},${sy},${sz}`, - `${sx},${sy - sectionHeight},${sz}`, - `${sx},${sy + sectionHeight},${sz}`, - `${sx},${sy},${sz - 16}`, - `${sx},${sy},${sz + 16}` - ] - let neighborBusy = false - for (const neighborKey of neighborKeys) { - if (this.sectionsWaiting.has(neighborKey) && !this.pendingSectionUpdates.has(neighborKey) && this.sectionObjects[neighborKey]) { - neighborBusy = true - break + // Face-adjacent buffered sections are installed as one group: a lone + // install would show the neighbour's faces still culled against the old + // block state, i.e. a see-through hole (sky flash) on dig/place. + const ready = selectReadySectionFlushes({ + pendingKeys: this.pendingSectionUpdates.keys(), + startedAt: key => this.pendingSectionBufferStartTimes.get(key), + now, + maxBufferMs: WorldRendererThree.MAX_SECTION_UPDATE_BUFFER_MS, + sectionHeight: this.getSectionHeight(), + isOutstanding: key => this.sectionsWaiting.has(key) && !this.pendingSectionUpdates.has(key), + ...(this.isClientLightOwnerSessionLive() + ? { + isTopologyOutstanding: (key: string) => this.pendingTopologyBySection.has(key) && !this.pendingSectionUpdates.has(key) } - } - if (neighborBusy) continue - } - - ready.push(key) - } + : {}), + hasSectionObject: key => !!this.sectionObjects[key] + }) if (ready.length === 0) return - for (const key of ready) { + for (const { key, reason } of ready) { const update = this.pendingSectionUpdates.get(key)! this.pendingSectionUpdates.delete(key) this.pendingSectionBufferStartTimes.delete(key) + if (isClientLightTraceEnabled()) { + recordClientLightTrace({ + phase: 'flush', + sectionKey: key, + flushReason: reason + }) + } const chunkCoords = update.key.split(',') const chunkKey = `${chunkCoords[0]},${chunkCoords[2]}` @@ -927,6 +945,14 @@ export class WorldRendererThree extends WorldRendererCommon { continue } + const decision = this.evaluateOwnerGeometry(update) + if (!decision.accepted || update.geometry?.hadErrors) { + this.noteRejectedOwnerGeometry(update.key, undefined, { expectLaterFinished: false }) + continue + } + this.acknowledgeAcceptedOwnerTopology(update) + if (decision.keepCovering) this.maybeDispatchCoveringRemesh(update.key) + if (!this.chunkMeshManager.sectionHasRenderableContent(update.geometry)) { this.chunkMeshManager.releaseSection(update.key) continue @@ -961,6 +987,14 @@ export class WorldRendererThree extends WorldRendererCommon { return } + const decision = this.evaluateOwnerGeometry(data) + if (!decision.accepted || data.geometry?.hadErrors) { + this.noteRejectedOwnerGeometry(data.key, undefined, { expectLaterFinished: true }) + return + } + this.acknowledgeAcceptedOwnerTopology(data) + if (decision.keepCovering) this.maybeDispatchCoveringRemesh(data.key) + if (this.sectionObjects[data.key]) { this.pendingSectionUpdates.set(data.key, data) // Per-section deadline: only set if we don't already have one, so @@ -968,6 +1002,13 @@ export class WorldRendererThree extends WorldRendererCommon { if (!this.pendingSectionBufferStartTimes.has(data.key)) { this.pendingSectionBufferStartTimes.set(data.key, performance.now()) } + if (isClientLightTraceEnabled()) { + recordClientLightTrace({ + phase: 'pending', + sectionKey: data.key, + queueDepth: this.pendingSectionUpdates.size + }) + } return } diff --git a/src/wasm-mesher/runtime-build/wasm_mesher.d.ts b/src/wasm-mesher/runtime-build/wasm_mesher.d.ts index 0898e3b..c539410 100644 --- a/src/wasm-mesher/runtime-build/wasm_mesher.d.ts +++ b/src/wasm-mesher/runtime-build/wasm_mesher.d.ts @@ -1,6 +1,20 @@ /* tslint:disable */ /* eslint-disable */ +export class JsLightEngine { + free(): void; + [Symbol.dispose](): void; + constructor(world_min_y: number, world_height: number); + setLightTables(emission: Uint8Array, opacity: Uint8Array): void; + setOcclusionTable(occupancy: Uint8Array): void; + pushEvent(event: any): void; + step(budget_ms: number): boolean; + pollCompletedPublication(): any; + getBlockLight(x: number, y: number, z: number): number; + getSkyLight(x: number, y: number, z: number): number; + setSkyLightEnabled(enabled: boolean): void; +} + /** * Compute wireframe edge positions from a triangle mesh. * @@ -209,11 +223,11 @@ export function parseMapChunkV18Plus(raw_packet: Uint8Array, num_sections: numbe * `raw_packet` includes the leading packet-id varint (we skip it). * `num_sections` should match the column the light is for (16 in 1.17). * - * Returns `{ x, z, skyLight: Uint8Array(num_sections * 4096), - * blockLight: Uint8Array(num_sections * 4096), bytesRead }`. - * Layout matches the existing 1.18+ light arrays - * (`x + z*16 + y_abs*256`); the JS-side worker reorders into per-section - * stack via the same path used for 1.18+ raw map_chunk parsing. + * Returns `{ x, z, trustEdges, skyLight, blockLight, skyLightMask, + * emptySkyLightMask, blockLightMask, emptyBlockLightMask, + * skyBelow?, skyAbove?, blockBelow?, blockAbove?, bytesRead }`. + * World arrays are `num_sections * 4096` (`x + z*16 + y_abs*256`). + * Omitted sections are left 0 and are not authoritative — use the masks. */ export function parseUpdateLightV17(raw_packet: Uint8Array, num_sections: number): any; @@ -226,6 +240,16 @@ export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembl export interface InitOutput { readonly memory: WebAssembly.Memory; + readonly __wbg_jslightengine_free: (a: number, b: number) => void; + readonly jslightengine_js_new: (a: number, b: number) => number; + readonly jslightengine_setLightTables: (a: number, b: number, c: number, d: number, e: number) => void; + readonly jslightengine_setOcclusionTable: (a: number, b: number, c: number) => void; + readonly jslightengine_pushEvent: (a: number, b: any) => void; + readonly jslightengine_step: (a: number, b: number) => number; + readonly jslightengine_pollCompletedPublication: (a: number) => any; + readonly jslightengine_getBlockLight: (a: number, b: number, c: number, d: number) => number; + readonly jslightengine_getSkyLight: (a: number, b: number, c: number, d: number) => number; + readonly jslightengine_setSkyLightEnabled: (a: number, b: number) => void; readonly generate_geometry: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number, m: number, n: number, o: number, p: number, q: number, r: number, s: number, t: number, u: number, v: number, w: number, x: number, y: number, z: number, a1: number, b1: number) => any; readonly generate_geometry_multi: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number, m: number, n: number, o: number, p: number, q: number, r: number, s: number, t: number, u: number, v: number, w: number, x: number, y: number, z: number, a1: number, b1: number, c1: number, d1: number, e1: number, f1: number) => any; readonly parseChunkDump118: (a: number, b: number, c: number, d: number, e: number) => any; diff --git a/src/wasm-mesher/runtime-build/wasm_mesher.js b/src/wasm-mesher/runtime-build/wasm_mesher.js index ab1f344..6e473fd 100644 --- a/src/wasm-mesher/runtime-build/wasm_mesher.js +++ b/src/wasm-mesher/runtime-build/wasm_mesher.js @@ -86,6 +86,11 @@ function getArrayU16FromWasm0(ptr, len) { return getUint16ArrayMemory0().subarray(ptr / 2, ptr / 2 + len); } +function getArrayU32FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getUint32ArrayMemory0().subarray(ptr / 4, ptr / 4 + len); +} + function getArrayU8FromWasm0(ptr, len) { ptr = ptr >>> 0; return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len); @@ -153,6 +158,10 @@ function handleError(f, args) { } } +function isLikeNone(x) { + return x === undefined || x === null; +} + function passArray16ToWasm0(arg, malloc) { const ptr = malloc(arg.length * 2, 2) >>> 0; getUint16ArrayMemory0().set(arg, ptr / 2); @@ -247,6 +256,100 @@ if (!('encodeInto' in cachedTextEncoder)) { let WASM_VECTOR_LEN = 0; +const JsLightEngineFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_jslightengine_free(ptr >>> 0, 1)); + +export class JsLightEngine { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + JsLightEngineFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_jslightengine_free(ptr, 0); + } + /** + * @param {number} world_min_y + * @param {number} world_height + */ + constructor(world_min_y, world_height) { + const ret = wasm.jslightengine_js_new(world_min_y, world_height); + this.__wbg_ptr = ret >>> 0; + JsLightEngineFinalization.register(this, this.__wbg_ptr, this); + return this; + } + /** + * @param {Uint8Array} emission + * @param {Uint8Array} opacity + */ + setLightTables(emission, opacity) { + const ptr0 = passArray8ToWasm0(emission, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passArray8ToWasm0(opacity, wasm.__wbindgen_malloc); + const len1 = WASM_VECTOR_LEN; + wasm.jslightengine_setLightTables(this.__wbg_ptr, ptr0, len0, ptr1, len1); + } + /** + * @param {Uint8Array} occupancy + */ + setOcclusionTable(occupancy) { + const ptr0 = passArray8ToWasm0(occupancy, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + wasm.jslightengine_setOcclusionTable(this.__wbg_ptr, ptr0, len0); + } + /** + * @param {any} event + */ + pushEvent(event) { + wasm.jslightengine_pushEvent(this.__wbg_ptr, event); + } + /** + * @param {number} budget_ms + * @returns {boolean} + */ + step(budget_ms) { + const ret = wasm.jslightengine_step(this.__wbg_ptr, budget_ms); + return ret !== 0; + } + /** + * @returns {any} + */ + pollCompletedPublication() { + const ret = wasm.jslightengine_pollCompletedPublication(this.__wbg_ptr); + return ret; + } + /** + * @param {number} x + * @param {number} y + * @param {number} z + * @returns {number} + */ + getBlockLight(x, y, z) { + const ret = wasm.jslightengine_getBlockLight(this.__wbg_ptr, x, y, z); + return ret; + } + /** + * @param {number} x + * @param {number} y + * @param {number} z + * @returns {number} + */ + getSkyLight(x, y, z) { + const ret = wasm.jslightengine_getSkyLight(this.__wbg_ptr, x, y, z); + return ret; + } + /** + * @param {boolean} enabled + */ + setSkyLightEnabled(enabled) { + wasm.jslightengine_setSkyLightEnabled(this.__wbg_ptr, enabled); + } +} +if (Symbol.dispose) JsLightEngine.prototype[Symbol.dispose] = JsLightEngine.prototype.free; + /** * Compute wireframe edge positions from a triangle mesh. * @@ -879,11 +982,11 @@ export function parseMapChunkV18Plus(raw_packet, num_sections, max_bits_per_bloc * `raw_packet` includes the leading packet-id varint (we skip it). * `num_sections` should match the column the light is for (16 in 1.17). * - * Returns `{ x, z, skyLight: Uint8Array(num_sections * 4096), - * blockLight: Uint8Array(num_sections * 4096), bytesRead }`. - * Layout matches the existing 1.18+ light arrays - * (`x + z*16 + y_abs*256`); the JS-side worker reorders into per-section - * stack via the same path used for 1.18+ raw map_chunk parsing. + * Returns `{ x, z, trustEdges, skyLight, blockLight, skyLightMask, + * emptySkyLightMask, blockLightMask, emptyBlockLightMask, + * skyBelow?, skyAbove?, blockBelow?, blockAbove?, bytesRead }`. + * World arrays are `num_sections * 4096` (`x + z*16 + y_abs*256`). + * Omitted sections are left 0 and are not authoritative — use the masks. * @param {Uint8Array} raw_packet * @param {number} num_sections * @returns {any} @@ -955,6 +1058,28 @@ function __wbg_get_imports() { getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); }; + imports.wbg.__wbg___wbindgen_is_null_dfda7d66506c95b5 = function(arg0) { + const ret = arg0 === null; + return ret; + }; + imports.wbg.__wbg___wbindgen_is_undefined_f6b95eab589e0269 = function(arg0) { + const ret = arg0 === undefined; + return ret; + }; + imports.wbg.__wbg___wbindgen_number_get_9619185a74197f95 = function(arg0, arg1) { + const obj = arg1; + const ret = typeof(obj) === 'number' ? obj : undefined; + getDataViewMemory0().setFloat64(arg0 + 8 * 1, isLikeNone(ret) ? 0 : ret, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true); + }; + imports.wbg.__wbg___wbindgen_string_get_a2a31e16edf96e42 = function(arg0, arg1) { + const obj = arg1; + const ret = typeof(obj) === 'string' ? obj : undefined; + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }; imports.wbg.__wbg___wbindgen_throw_dd24417ed36fc46e = function(arg0, arg1) { throw new Error(getStringFromWasm0(arg0, arg1)); }; @@ -962,6 +1087,10 @@ function __wbg_get_imports() { const ret = arg0[arg1 >>> 0]; return ret; }; + imports.wbg.__wbg_get_af9dab7e9603ea93 = function() { return handleError(function (arg0, arg1) { + const ret = Reflect.get(arg0, arg1); + return ret; + }, arguments) }; imports.wbg.__wbg_instanceof_Int32Array_b6281022039fba32 = function(arg0) { let result; try { @@ -990,6 +1119,10 @@ function __wbg_get_imports() { const ret = arg0.length; return ret; }; + imports.wbg.__wbg_length_89c3414ed7f0594d = function(arg0) { + const ret = arg0.length; + return ret; + }; imports.wbg.__wbg_length_ab53989976907f11 = function(arg0) { const ret = arg0.length; return ret; @@ -1002,10 +1135,22 @@ function __wbg_get_imports() { const ret = new Array(); return ret; }; + imports.wbg.__wbg_new_6421f6084cc5bc5a = function(arg0) { + const ret = new Uint8Array(arg0); + return ret; + }; + imports.wbg.__wbg_new_9f7fd5c3a6ba7298 = function(arg0) { + const ret = new Uint16Array(arg0); + return ret; + }; imports.wbg.__wbg_new_with_length_1e8603a5c71d4e06 = function(arg0) { const ret = new Int32Array(arg0 >>> 0); return ret; }; + imports.wbg.__wbg_new_with_length_202b3db94ba5fc86 = function(arg0) { + const ret = new Uint32Array(arg0 >>> 0); + return ret; + }; imports.wbg.__wbg_new_with_length_aa5eaf41d35235e5 = function(arg0) { const ret = new Uint8Array(arg0 >>> 0); return ret; @@ -1014,12 +1159,23 @@ function __wbg_get_imports() { const ret = new Uint16Array(arg0 >>> 0); return ret; }; + imports.wbg.__wbg_now_69d776cd24f5215b = function() { + const ret = Date.now(); + return ret; + }; + imports.wbg.__wbg_prototypesetcall_b0bea5f39077cfd3 = function(arg0, arg1, arg2) { + Uint16Array.prototype.set.call(getArrayU16FromWasm0(arg0, arg1), arg2); + }; imports.wbg.__wbg_prototypesetcall_dd07c344a74d4bfd = function(arg0, arg1, arg2) { Int32Array.prototype.set.call(getArrayI32FromWasm0(arg0, arg1), arg2); }; imports.wbg.__wbg_prototypesetcall_dfe9b766cdc1f1fd = function(arg0, arg1, arg2) { Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2); }; + imports.wbg.__wbg_push_7d9be8f38fc13975 = function(arg0, arg1) { + const ret = arg0.push(arg1); + return ret; + }; imports.wbg.__wbg_set_169e13b608078b7b = function(arg0, arg1, arg2) { arg0.set(getArrayU8FromWasm0(arg1, arg2)); }; @@ -1036,6 +1192,9 @@ function __wbg_get_imports() { imports.wbg.__wbg_set_bb0c6a7fe60d81b5 = function(arg0, arg1, arg2) { arg0.set(getArrayU16FromWasm0(arg1, arg2)); }; + imports.wbg.__wbg_set_e7cd108182596b7f = function(arg0, arg1, arg2) { + arg0.set(getArrayU32FromWasm0(arg1, arg2)); + }; imports.wbg.__wbindgen_cast_2241b6af4c4b2941 = function(arg0, arg1) { // Cast intrinsic for `Ref(String) -> Externref`. const ret = getStringFromWasm0(arg0, arg1); diff --git a/src/wasm-mesher/runtime-build/wasm_mesher_bg.wasm b/src/wasm-mesher/runtime-build/wasm_mesher_bg.wasm index c2dd4af..45cdee6 100644 Binary files a/src/wasm-mesher/runtime-build/wasm_mesher_bg.wasm and b/src/wasm-mesher/runtime-build/wasm_mesher_bg.wasm differ diff --git a/src/wasm-mesher/runtime-build/wasm_mesher_bg.wasm.d.ts b/src/wasm-mesher/runtime-build/wasm_mesher_bg.wasm.d.ts index 91af6dd..d60eb7d 100644 --- a/src/wasm-mesher/runtime-build/wasm_mesher_bg.wasm.d.ts +++ b/src/wasm-mesher/runtime-build/wasm_mesher_bg.wasm.d.ts @@ -1,6 +1,16 @@ /* tslint:disable */ /* eslint-disable */ export const memory: WebAssembly.Memory; +export const __wbg_jslightengine_free: (a: number, b: number) => void; +export const jslightengine_js_new: (a: number, b: number) => number; +export const jslightengine_setLightTables: (a: number, b: number, c: number, d: number, e: number) => void; +export const jslightengine_setOcclusionTable: (a: number, b: number, c: number) => void; +export const jslightengine_pushEvent: (a: number, b: any) => void; +export const jslightengine_step: (a: number, b: number) => number; +export const jslightengine_pollCompletedPublication: (a: number) => any; +export const jslightengine_getBlockLight: (a: number, b: number, c: number, d: number) => number; +export const jslightengine_getSkyLight: (a: number, b: number, c: number, d: number) => number; +export const jslightengine_setSkyLightEnabled: (a: number, b: number) => void; export const generate_geometry: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number, m: number, n: number, o: number, p: number, q: number, r: number, s: number, t: number, u: number, v: number, w: number, x: number, y: number, z: number, a1: number, b1: number) => any; export const generate_geometry_multi: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number, m: number, n: number, o: number, p: number, q: number, r: number, s: number, t: number, u: number, v: number, w: number, x: number, y: number, z: number, a1: number, b1: number, c1: number, d1: number, e1: number, f1: number) => any; export const parseChunkDump118: (a: number, b: number, c: number, d: number, e: number) => any; diff --git a/src/wasm-mesher/tests/mesherLightDependencies.test.ts b/src/wasm-mesher/tests/mesherLightDependencies.test.ts new file mode 100644 index 0000000..98579fa --- /dev/null +++ b/src/wasm-mesher/tests/mesherLightDependencies.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'vitest' +import { provenMeshLightVersion } from '../worker/mesherLightDependencies' + +function prove(sx: number, sz: number, used: string[], applied: Array<[string, number]>) { + return provenMeshLightVersion({ + sx, + sz, + usedColumns: new Set(used), + appliedVersionByColumn: new Map(applied) + }) +} + +describe('provenMeshLightVersion', () => { + test('a bootstrap neighbor wall takes the published column version when that column was meshed', () => { + expect(prove(16, 0, ['0,0', '16,0'], [['0,0', 10]])).toBe(10) + expect(prove(0, 16, ['0,0', '0,16'], [['0,0', 8]])).toBe(8) + }) + + test('negative chunk coordinates use the same column keys', () => { + expect(prove(-16, -32, ['-32,-32', '-16,-32'], [['-32,-32', 7]])).toBe(7) + }) + + test('an unused published column is not proof, even if a dirty job requested that version', () => { + expect(prove(16, 0, ['16,0'], [['0,0', 10]])).toBe(0) + }) + + test('an older own-column version does not hide a newer neighbor publication that was meshed', () => { + expect( + prove( + 16, + 0, + ['0,0', '16,0'], + [ + ['16,0', 4], + ['0,0', 10] + ] + ) + ).toBe(10) + }) + + test('a later own publication does not cover an earlier neighbor that was left out of the mesh', () => { + expect( + prove( + 16, + 0, + ['16,0'], + [ + ['0,0', 10], + ['16,0', 12] + ] + ) + ).toBe(0) + }) + + test('a used column older than the missing neighbor still counts', () => { + expect( + prove( + 16, + 0, + ['16,0'], + [ + ['0,0', 10], + ['16,0', 4] + ] + ) + ).toBe(4) + }) + + test('a far column publication does not raise the stamp', () => { + expect(prove(0, 0, ['0,0', '100,100'], [['100,100', 100]])).toBe(0) + }) + + test('forgetting a column drops it from later proofs', () => { + const applied = new Map([ + ['0,0', 10], + ['16,0', 10] + ]) + applied.delete('0,0') + expect(prove(16, 0, ['16,0'], [...applied.entries()])).toBe(10) + expect(prove(0, 0, ['0,0'], [...applied.entries()])).toBe(0) + }) +}) diff --git a/src/wasm-mesher/tests/mesherWasmColumnTickSelect.test.ts b/src/wasm-mesher/tests/mesherWasmColumnTickSelect.test.ts new file mode 100644 index 0000000..a0490cc --- /dev/null +++ b/src/wasm-mesher/tests/mesherWasmColumnTickSelect.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'vitest' +import { BULK_COLUMNS_PER_TICK, selectColumnGroupsForTick } from '../worker/mesherWasmColumnTickSelect' + +function dirtyOf(keys: string[]): Map { + const dirty = new Map() + for (const key of keys) dirty.set(key, 1) + return dirty +} + +describe('selectColumnGroupsForTick', () => { + test('urgent columns are selected before bulk columns', () => { + const dirty = dirtyOf(['160,64,0', '0,64,0', '32,64,0']) + const urgent = new Set(['0,64,0']) + const { selected, remaining } = selectColumnGroupsForTick(dirty, urgent, 4) + expect(selected.map(group => `${group.x},${group.z}`)).toEqual(['0,0', '160,0', '32,0']) + expect(remaining.size).toBe(0) + }) + + test('a tick leaves leftover bulk columns for the next tick', () => { + const keys = ['0,64,0', '16,64,0', '32,64,0', '48,64,0', '64,64,0', '80,64,0'] + const dirty = dirtyOf(keys) + const first = selectColumnGroupsForTick(dirty, new Set(), BULK_COLUMNS_PER_TICK) + expect(first.selected).toHaveLength(4) + expect([...first.remaining.keys()]).toEqual(['64,64,0', '80,64,0']) + const second = selectColumnGroupsForTick(first.remaining, new Set(), BULK_COLUMNS_PER_TICK) + expect(second.selected.map(group => `${group.x},${group.z}`)).toEqual(['64,0', '80,0']) + expect(second.remaining.size).toBe(0) + }) + + test('urgent still wins when the same key also has bulk dirties', () => { + const dirty = dirtyOf(['16,64,0', '0,64,0', '32,64,0', '48,64,0', '64,64,0', '80,64,0', '96,64,0']) + dirty.set('0,64,0', 2) + const { selected, remaining } = selectColumnGroupsForTick(dirty, new Set(['0,64,0']), 4) + expect(selected[0]).toMatchObject({ x: 0, z: 0 }) + expect(selected).toHaveLength(5) + expect([...remaining.keys()]).toEqual(['80,64,0', '96,64,0']) + }) +}) diff --git a/src/wasm-mesher/tests/mesherWasmTickParseCache.test.ts b/src/wasm-mesher/tests/mesherWasmTickParseCache.test.ts new file mode 100644 index 0000000..dd59d5a --- /dev/null +++ b/src/wasm-mesher/tests/mesherWasmTickParseCache.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, test } from 'vitest' +import { beginTickWasmParseCache, endTickWasmParseCache, getOrConvertTickWasmParse, getTickWasmParseStats } from '../worker/mesherWasmTickParseCache' + +afterEach(() => { + endTickWasmParseCache() +}) + +describe('mesherWasmTickParseCache', () => { + test('second convert of the same column in one tick is a hit without copying', () => { + beginTickWasmParseCache() + let calls = 0 + const convert = () => { + calls++ + return { tag: calls } + } + const a = getOrConvertTickWasmParse('0,0', convert) + const b = getOrConvertTickWasmParse('0,0', convert) + expect(a.hit).toBe(false) + expect(b.hit).toBe(true) + expect(calls).toBe(1) + expect(b.result).toBe(a.result) + expect(getTickWasmParseStats()).toEqual({ hits: 1, misses: 1 }) + }) + + test('distinct columns miss independently and unique-column count matches the cache', () => { + beginTickWasmParseCache() + let calls = 0 + const convert = () => { + calls++ + return { tag: calls } + } + for (let i = 0; i < 9; i++) getOrConvertTickWasmParse(`${i * 16},0`, convert) + for (let i = 0; i < 9; i++) getOrConvertTickWasmParse(`${i * 16},0`, convert) + expect(calls).toBe(9) + expect(getTickWasmParseStats()).toEqual({ hits: 9, misses: 9 }) + }) + + test('null results are not cached so a later convert can recover', () => { + beginTickWasmParseCache() + let calls = 0 + const a = getOrConvertTickWasmParse('16,16', () => { + calls++ + return null + }) + const b = getOrConvertTickWasmParse('16,16', () => { + calls++ + return { tag: 1 } + }) + expect(a.result).toBeNull() + expect(a.hit).toBe(false) + expect(b.hit).toBe(false) + expect(b.result).toEqual({ tag: 1 }) + expect(calls).toBe(2) + }) + + test('end of tick drops entries so the next tick misses', () => { + beginTickWasmParseCache() + let calls = 0 + getOrConvertTickWasmParse('0,0', () => { + calls++ + return { tag: 1 } + }) + endTickWasmParseCache() + beginTickWasmParseCache() + getOrConvertTickWasmParse('0,0', () => { + calls++ + return { tag: 2 } + }) + expect(calls).toBe(2) + expect(getTickWasmParseStats()).toEqual({ hits: 0, misses: 1 }) + }) +}) diff --git a/src/wasm-mesher/tests/mesherWasmTickSchedule.test.ts b/src/wasm-mesher/tests/mesherWasmTickSchedule.test.ts new file mode 100644 index 0000000..5c88132 --- /dev/null +++ b/src/wasm-mesher/tests/mesherWasmTickSchedule.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test, vi } from 'vitest' +import { BULK_MESH_INTERVAL_MS, createMeshTickScheduler } from '../worker/mesherWasmTickSchedule' + +describe('createMeshTickScheduler', () => { + test('an urgent kick runs on the next turn, before the bulk poll interval', async () => { + vi.useFakeTimers() + try { + let runs = 0 + const scheduler = createMeshTickScheduler(() => { + runs++ + }) + scheduler.kick() + expect(runs).toBe(0) + await vi.advanceTimersByTimeAsync(0) + expect(runs).toBe(1) + expect(BULK_MESH_INTERVAL_MS).toBeGreaterThan(0) + } finally { + vi.clearAllTimers() + vi.useRealTimers() + } + }) + + test('a kick during a running tick runs again after that tick finishes', async () => { + vi.useFakeTimers() + try { + let release: () => void = () => {} + const gate = new Promise(resolve => { + release = resolve + }) + let runs = 0 + const scheduler = createMeshTickScheduler(() => { + runs++ + if (runs === 1) { + scheduler.kick() + return gate + } + }) + scheduler.kick() + await vi.advanceTimersByTimeAsync(0) + expect(runs).toBe(1) + release() + await gate + await Promise.resolve() + expect(runs).toBe(2) + } finally { + vi.clearAllTimers() + vi.useRealTimers() + } + }) +}) diff --git a/src/wasm-mesher/tests/mesherWasmTopologyPostCache.test.ts b/src/wasm-mesher/tests/mesherWasmTopologyPostCache.test.ts new file mode 100644 index 0000000..ea36c54 --- /dev/null +++ b/src/wasm-mesher/tests/mesherWasmTopologyPostCache.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, test } from 'vitest' +import { + TOPOLOGY_POST_CACHE_LIMIT, + clearTopologyPostCache, + clearTopologyPostCacheColumn, + getOrComputeTopologyPost +} from '../worker/mesherWasmTopologyPostCache' + +afterEach(() => { + clearTopologyPostCache() +}) + +describe('mesherWasmTopologyPostCache', () => { + test('second pass with the same revision does not walk', () => { + let walks = 0 + const compute = () => { + walks++ + return { visibilitySet: 1, signs: { a: walks }, heads: {}, banners: {} } + } + const first = getOrComputeTopologyPost('0,64,0', 4, compute) + const second = getOrComputeTopologyPost('0,64,0', 4, compute) + expect(walks).toBe(1) + expect(first.hit).toBe(false) + expect(second.hit).toBe(true) + expect(second.payload).toBe(first.payload) + }) + + test('revision change walks again', () => { + let walks = 0 + const compute = () => { + walks++ + return { visibilitySet: walks, signs: {}, heads: {}, banners: {} } + } + getOrComputeTopologyPost('0,64,0', 4, compute) + const next = getOrComputeTopologyPost('0,64,0', 5, compute) + expect(walks).toBe(2) + expect(next.hit).toBe(false) + expect(next.payload.visibilitySet).toBe(2) + }) + + test('missing revision always walks and does not cache', () => { + let walks = 0 + const compute = () => { + walks++ + return { visibilitySet: walks, signs: {}, heads: {}, banners: {} } + } + getOrComputeTopologyPost('0,64,0', undefined, compute) + getOrComputeTopologyPost('0,64,0', undefined, compute) + expect(walks).toBe(2) + }) + + test('unload of a column clears its cached sections', () => { + let walks = 0 + const compute = () => { + walks++ + return { visibilitySet: walks, signs: {}, heads: {}, banners: {} } + } + getOrComputeTopologyPost('0,64,0', 3, compute) + getOrComputeTopologyPost('16,64,0', 3, compute) + clearTopologyPostCacheColumn(0, 0) + getOrComputeTopologyPost('0,64,0', 3, compute) + getOrComputeTopologyPost('16,64,0', 3, compute) + expect(walks).toBe(3) + }) + + test('evicts oldest entries past the cap', () => { + for (let i = 0; i < TOPOLOGY_POST_CACHE_LIMIT + 3; i++) { + getOrComputeTopologyPost(`${i},64,0`, 1, () => ({ visibilitySet: i, signs: {}, heads: {}, banners: {} })) + } + let walks = 0 + getOrComputeTopologyPost('0,64,0', 1, () => { + walks++ + return { visibilitySet: -1, signs: {}, heads: {}, banners: {} } + }) + expect(walks).toBe(1) + }) +}) diff --git a/src/wasm-mesher/worker/lightOwnerWorker.ts b/src/wasm-mesher/worker/lightOwnerWorker.ts new file mode 100644 index 0000000..1618a07 --- /dev/null +++ b/src/wasm-mesher/worker/lightOwnerWorker.ts @@ -0,0 +1,151 @@ +/** + * Dedicated light-owner worker. Mesh workers stay read-only. + * Protocol: init / setLightTables / setSkyLightEnabled / pushEvent / + * setUpdateLightV17 / setUpdateLightV16 / step / poll. + * Messages arriving before init completes are queued. + */ + +import type { LightOwnerEvent, LightPublication } from '../../three/lightOwnerHost' +import { eventsFromWasmUpdateLight } from './updateLightToOwnerEvents' +import { + CLIENT_LIGHT_TRACE_MESSAGE, + armClientLightTrace, + enableClientLightTrace, + isClientLightTraceArmed, + isClientLightTraceEnabled, + postClientLightTrace +} from '../../lib/clientLightTrace' + +type WasmEngine = { + setLightTables(emission: Uint8Array, opacity: Uint8Array): void + setOcclusionTable?(occupancy: Uint8Array): void + setSkyLightEnabled?(enabled: boolean): void + pushEvent(event: LightOwnerEvent): void + step(budgetMs: number): boolean + pollCompletedPublication(): LightPublication | null +} + +let engine: WasmEngine | null = null +let wasm: any = null +let worldMinY = 0 +const pending: any[] = [] +let localContinue: ReturnType | null = null +let stepGeneration = 0 + +const ctx = self as unknown as DedicatedWorkerGlobalScope +const postTrace = (message: { type: typeof CLIENT_LIGHT_TRACE_MESSAGE; events?: unknown; event?: unknown }) => { + ctx.postMessage(message) +} + +function runOwnerStepSlice(budgetMs: number) { + localContinue = null + const remaining = engine?.step(budgetMs) ?? false + const publication = engine?.pollCompletedPublication() ?? null + ctx.postMessage({ type: 'stepped', remaining, publication, stepGeneration }) + if (publication && isClientLightTraceEnabled() && isClientLightTraceArmed()) { + postClientLightTrace(postTrace, { + phase: 'ownerComplete', + lightVersion: publication.publicationVersion, + worldGeneration: publication.worldGeneration, + queueDepth: remaining ? 1 : 0 + }) + } + if (remaining) { + localContinue = setTimeout(() => { + runOwnerStepSlice(budgetMs) + }, 0) + } +} + +async function handle(data: any) { + if (!data || typeof data !== 'object') return + switch (data.type) { + case 'init': { + wasm = await import('../runtime-build/wasm_mesher.js') + await wasm.default('/wasm_mesher_bg.wasm') + const Engine = (wasm as unknown as { JsLightEngine: new (minY: number, height: number) => WasmEngine }).JsLightEngine + worldMinY = data.worldMinY ?? 0 + engine = new Engine(worldMinY, data.worldHeight ?? 256) + ctx.postMessage({ type: 'ready' }) + const queued = pending.splice(0, pending.length) + for (const message of queued) await handle(message) + break + } + case 'setLightTables': { + engine?.setLightTables(data.emission, data.opacity) + if (data.occupancy) engine?.setOcclusionTable?.(data.occupancy) + ctx.postMessage({ type: 'tablesSet' }) + break + } + case 'setSkyLightEnabled': { + engine?.setSkyLightEnabled?.(Boolean(data.enabled)) + break + } + case 'pushEvent': { + engine?.pushEvent(data.event) + const event = data.event as LightOwnerEvent | undefined + const currentColumn = + event && 'x' in event && typeof event.x === 'number' + ? `${Math.floor(event.x / 16)},${Math.floor((event as { z: number }).z / 16)}` + : event && 'sx' in event && typeof event.sx === 'number' + ? `${event.sx},${(event as { sz: number }).sz}` + : undefined + if (isClientLightTraceEnabled() && isClientLightTraceArmed()) { + postClientLightTrace(postTrace, { + phase: 'ownerAdmit', + currentColumn, + queueDepth: pending.length + }) + } + break + } + case 'clientLightTraceConfig': { + enableClientLightTrace(Boolean(data.enabled)) + armClientLightTrace(Boolean(data.armed)) + break + } + case 'setUpdateLightV17': { + if (!wasm || !engine) break + const parsed = wasm.parseUpdateLightV17(data.rawPacket as Uint8Array, data.numSections as number) + for (const event of eventsFromWasmUpdateLight(parsed, data.numSections as number, worldMinY)) { + engine.pushEvent(event) + } + break + } + case 'setUpdateLightV16': { + if (!wasm || !engine) break + const parsed = wasm.parseUpdateLightV17(data.rawPacket as Uint8Array, 16) + for (const event of eventsFromWasmUpdateLight(parsed, 16, worldMinY)) { + engine.pushEvent(event) + } + break + } + case 'step': { + if (typeof data.stepGeneration === 'number') stepGeneration = data.stepGeneration + if (localContinue) break + runOwnerStepSlice(data.budgetMs ?? 5) + break + } + case 'poll': { + const publication = engine?.pollCompletedPublication() ?? null + ctx.postMessage({ type: 'publication', publication }) + break + } + default: + break + } +} + +ctx.onmessage = async (message: MessageEvent) => { + const data = message.data + if (!data || typeof data !== 'object') return + try { + if (!engine && data.type !== 'init') { + pending.push(data) + return + } + await handle(data) + } catch (err) { + ctx.postMessage({ type: 'error', error: err instanceof Error ? err.message : String(err) }) + } +} diff --git a/src/wasm-mesher/worker/mesherLightDependencies.ts b/src/wasm-mesher/worker/mesherLightDependencies.ts new file mode 100644 index 0000000..3948334 --- /dev/null +++ b/src/wasm-mesher/worker/mesherLightDependencies.ts @@ -0,0 +1,42 @@ +/** + * Light-version stamp for one section mesh. + * + * Each column keeps only the latest applied owner version. The stamp is the + * newest of those versions whose sampled columns were actually meshed, and it + * stops before the first sampled column that was written but left out. The + * version requested on the dirty job is not an input. + */ + +export function sectionLightSampleColumns(sx: number, sz: number): string[] { + const columns: string[] = [] + for (let dx = -16; dx <= 16; dx += 16) { + for (let dz = -16; dz <= 16; dz += 16) { + columns.push(`${sx + dx},${sz + dz}`) + } + } + return columns +} + +export function provenMeshLightVersion(opts: { + sx: number + sz: number + usedColumns: ReadonlySet + appliedVersionByColumn: ReadonlyMap +}): number { + let gap = Number.POSITIVE_INFINITY + let covered = 0 + for (const column of sectionLightSampleColumns(opts.sx, opts.sz)) { + const applied = opts.appliedVersionByColumn.get(column) + if (applied == null) continue + if (!opts.usedColumns.has(column)) gap = Math.min(gap, applied) + else covered = Math.max(covered, applied) + } + if (!Number.isFinite(gap)) return covered + let belowGap = 0 + for (const column of sectionLightSampleColumns(opts.sx, opts.sz)) { + const applied = opts.appliedVersionByColumn.get(column) + if (applied == null || !opts.usedColumns.has(column) || applied >= gap) continue + belowGap = Math.max(belowGap, applied) + } + return belowGap +} diff --git a/src/wasm-mesher/worker/mesherWasm.ts b/src/wasm-mesher/worker/mesherWasm.ts index fb66c7d..26ab441 100644 --- a/src/wasm-mesher/worker/mesherWasm.ts +++ b/src/wasm-mesher/worker/mesherWasm.ts @@ -10,8 +10,16 @@ import { worldColumnKey, World } from '../../mesher-shared/world' import { handleGetHeightmap, EMPTY_COLUMN_HEIGHTMAP_SENTINEL } from '../../mesher-shared/computeHeightmap' import { collectBlockEntityMetadata, type SignMeta, type HeadMeta, type BannerMeta } from '../../mesher-shared/blockEntityMetadata' import { SectionRequestTracker } from './mesherWasmRequestTracker' -import { sectionYsForLightColumnDirty } from './mesherWasmLightDirty' +import { dropRawMapChunkOnLightOnlyReload, sectionYsForLightColumnDirty } from './mesherWasmLightDirty' +import { applyOwnerPublicationToColumnCaches, applyRawLightPacketToCaches, ownerDeltaSectionWorldYs, revertDisplayToIncoming } from './mesherWasmOwnerLight' +import { INITIAL_TOPOLOGY_REVISION, snapshotMeshVersions, validateOwnerPublicationMessage } from '../../lib/clientLightVersions' +import { provenMeshLightVersion } from './mesherLightDependencies' +import { BULK_MESH_INTERVAL_MS, createMeshTickScheduler } from './mesherWasmTickSchedule' +import { displayLightColumn, isLightSectionPresent, parsedUpdateLightFromWasm, worldSectionMaskBit, type UpdateLightColumnCache } from './mesherWasmLightMerge' import { CONVERSION_CACHE_LIMIT, clearConversionCache, getOrConvertColumn, invalidateConversion, setConversionCacheLimit } from './mesherWasmConversionCache' +import { beginTickWasmParseCache, endTickWasmParseCache, getOrConvertTickWasmParse } from './mesherWasmTickParseCache' +import { clearTopologyPostCache, clearTopologyPostCacheColumn, getOrComputeTopologyPost } from './mesherWasmTopologyPostCache' +import { selectColumnGroupsForTick } from './mesherWasmColumnTickSelect' import { PendingChunkBuffer } from './mesherWasmChunkBuffer' import { PendingNeighborHealTracker, @@ -22,6 +30,14 @@ import { countWorldColumns3x3, type PacketCaches } from './mesherWasmNeighborhood' +import { + CLIENT_LIGHT_TRACE_MESSAGE, + armClientLightTrace, + enableClientLightTrace, + isClientLightTraceArmed, + isClientLightTraceEnabled, + postClientLightTrace +} from '../../lib/clientLightTrace' let wasm: typeof import('../runtime-build/wasm_mesher.js') | null = null let wasmInitialized = false @@ -49,11 +65,16 @@ function processUpdateLightV17(rawPacket: Uint8Array, numSections: number): void const parsed: any = (wasm as any).parseUpdateLightV17(rawPacket, numSections) const x = (parsed.x as number) * 16 const z = (parsed.z as number) * 16 - const skyLight = parsed.skyLight as Uint8Array - updateLightV17Cache.set(rawCacheKey(x, z), { - skyLight, - blockLight: parsed.blockLight as Uint8Array + const key = rawCacheKey(x, z) + const result = applyRawLightPacketToCaches({ + ownerOwnsColumn: ownerOwnedLightColumns.has(key), + incoming: incomingUpdateLightV17Cache.get(key), + display: updateLightV17Cache.get(key), + parsed: parsedUpdateLightFromWasm(parsed, numSections) }) + incomingUpdateLightV17Cache.set(key, result.incoming) + if (!result.dirtyDisplay || !result.display) return + updateLightV17Cache.set(key, result.display) invalidateConversion(x, z) dirtyColumnSectionsForLightUpdate(x, z) const hadColumn = !!world?.getColumn(x, z) @@ -78,10 +99,16 @@ function processUpdateLightV16(rawPacket: Uint8Array): void { const parsed: any = (wasm as any).parseUpdateLightV17(rawPacket, 16) const x = (parsed.x as number) * 16 const z = (parsed.z as number) * 16 - updateLightV16Cache.set(rawCacheKey(x, z), { - skyLight: parsed.skyLight as Uint8Array, - blockLight: parsed.blockLight as Uint8Array + const key = rawCacheKey(x, z) + const result = applyRawLightPacketToCaches({ + ownerOwnsColumn: ownerOwnedLightColumns.has(key), + incoming: incomingUpdateLightV16Cache.get(key), + display: updateLightV16Cache.get(key), + parsed: parsedUpdateLightFromWasm(parsed, 16) }) + incomingUpdateLightV16Cache.set(key, result.incoming) + if (!result.dirtyDisplay || !result.display) return + updateLightV16Cache.set(key, result.display) invalidateConversion(x, z) dirtyColumnSectionsForLightUpdate(x, z) } catch (err) { @@ -133,6 +160,23 @@ let config = defaultMesherConfig let version = '1.16.5' let world: World // chunkKey -> chunk data let dirtySections = new Map() +const urgentSections = new Set() +const dirtyLightMeta = new Map() +const dirtyTraceMeta = new Map() +const dirtyVersionMeta = new Map< + string, + { + sessionEpoch?: number + columnIncarnation?: number + requestId?: number + topologyRevision?: number + neighborTopologyRevisions?: Array<{ key: string; topologyRevision: number }> + } +>() +const appliedOwnerLightVersion = new Map() +const columnIncarnation = new Map() +let ownerSessionEpoch = 0 +let rejectOwnerPublications = false // Kept in sync with `dirtySections` so column mode can filter outgoing // geometry/sectionFinished events to only the section keys requested by the // main thread, even though a full-column WASM call may generate more data. @@ -160,8 +204,19 @@ const postMessage = (data: any, transferList: any[] = []) => { }) } +const postTrace = (message: { type: typeof CLIENT_LIGHT_TRACE_MESSAGE; events?: unknown; event?: unknown }) => { + global.postMessage(message) +} + function drainQueue(from: number, to: number) { const messages = queuedMessages.slice(from, to) + const remaining = queuedMessages.length - to + if (isClientLightTraceEnabled() && isClientLightTraceArmed()) { + postClientLightTrace(postTrace, { + phase: 'mesherFlush', + queueDepth: remaining + }) + } global.postMessage( messages.map(m => m.data), messages.flatMap(m => m.transferList) as unknown as string @@ -258,25 +313,71 @@ const parsedV17Cache = new Map() // WASM (`parseUpdateLightV17`) and cache per-block arrays keyed by the // chunk origin — the next mesh tick of that column merges them in instead // of the sky=15/block=0 fallback. May arrive before or after `map_chunk`. -interface UpdateLightV17Entry { - skyLight: Uint8Array - blockLight: Uint8Array +const updateLightV17Cache = new Map() +const incomingUpdateLightV17Cache = new Map() +const ownerOwnedLightColumns = new Set() + +function takeGeometryVersionMeta(key: string, sx: number, sz: number, usedColumns: ReadonlySet) { + const lightMeta = dirtyLightMeta.get(key) + dirtyLightMeta.delete(key) + const traceMeta = dirtyTraceMeta.get(key) + dirtyTraceMeta.delete(key) + const job = dirtyVersionMeta.get(key) + dirtyVersionMeta.delete(key) + const columnKey = rawCacheKey(sx, sz) + const owned = ownerOwnedLightColumns.has(columnKey) + const lightPublicationVersion = provenMeshLightVersion({ + sx, + sz, + usedColumns, + appliedVersionByColumn: appliedOwnerLightVersion + }) + if (!job && !owned && !lightMeta && lightPublicationVersion === 0) return { ...traceMeta } + const appliedLightMeta = { ...(lightMeta ?? {}) } + delete appliedLightMeta.lightPublicationVersion + return { + ...appliedLightMeta, + ...traceMeta, + ...snapshotMeshVersions({ + sessionEpoch: job?.sessionEpoch ?? ownerSessionEpoch, + columnIncarnation: columnIncarnation.get(columnKey) ?? job?.columnIncarnation ?? 1, + requestId: job?.requestId ?? 0, + topologyRevision: job?.topologyRevision ?? INITIAL_TOPOLOGY_REVISION, + lightPublicationVersion, + worldGeneration: lightMeta?.worldGeneration ?? 0, + neighborTopologyRevisions: job?.neighborTopologyRevisions ?? [], + meshMode: owned ? 'owner' : 'legacyBootstrap' + }) + } +} + +function displayCachedLight(entry: UpdateLightColumnCache | undefined): { skyLight: Uint8Array; blockLight: Uint8Array } | undefined { + if (!entry) return undefined + return { + skyLight: displayLightColumn(entry, 'sky', config?.skyLight ?? 15), + blockLight: displayLightColumn(entry, 'block', 0) + } } -const updateLightV17Cache = new Map() /** Columns that received `update_light` before the worker column existed. */ const pendingLightDirtyColumns = new Set() const _syncLightPos = new Vec3(0, 0, 0) -function resolveUpdateLightV17Entry(x: number, z: number, chunk: any | undefined, worldMinY: number, worldMaxY: number): UpdateLightV17Entry | undefined { - const cached = updateLightV17Cache.get(rawCacheKey(x, z)) - if (cached?.blockLight?.length) return cached - if (!chunk) return cached +function resolveUpdateLightV17Entry( + x: number, + z: number, + chunk: any | undefined, + worldMinY: number, + worldMaxY: number +): { skyLight: Uint8Array; blockLight: Uint8Array } | undefined { + const displayed = displayCachedLight(updateLightV17Cache.get(rawCacheKey(x, z))) + if (displayed) return displayed + if (!chunk) return undefined const { result } = getOrConvertColumn(x, z, chunk, version, worldMinY, worldMaxY, () => convertChunkToWasm(chunk, version, x, z, worldMinY, worldMaxY), chunk) return { blockLight: result.blockLight, skyLight: result.skyLight } } /** Write cached 1.17 `update_light` arrays into the worker prismarine column. */ -function syncV17LightToColumn(x: number, z: number): boolean { +function syncV17LightToColumn(x: number, z: number, onlySectionWorldYs?: number[]): boolean { if (!world) return false const col = world.getColumn(x, z) const entry = updateLightV17Cache.get(rawCacheKey(x, z)) @@ -285,16 +386,23 @@ function syncV17LightToColumn(x: number, z: number): boolean { const CHUNK_SIZE = 16 const minY = config?.worldMinY ?? 0 const maxY = config?.worldMaxY ?? 256 - const { blockLight, skyLight } = entry + const { blockLight, skyLight, blockPresent, skyPresent } = entry + const onlyYs = onlySectionWorldYs && onlySectionWorldYs.length ? new Set(onlySectionWorldYs) : null for (let y = minY; y < maxY; y++) { + const sectionOriginY = Math.floor((y - minY) / CHUNK_SIZE) * CHUNK_SIZE + minY + if (onlyYs && !onlyYs.has(sectionOriginY)) continue + const sectionIndex = Math.floor((y - minY) / CHUNK_SIZE) + const skyAuthoritative = isLightSectionPresent(skyPresent, worldSectionMaskBit(sectionIndex)) + const blockAuthoritative = isLightSectionPresent(blockPresent, worldSectionMaskBit(sectionIndex)) + if (!skyAuthoritative && !blockAuthoritative) continue for (let lz = 0; lz < CHUNK_SIZE; lz++) { for (let lx = 0; lx < CHUNK_SIZE; lx++) { const idx = lx + lz * CHUNK_SIZE + (y - minY) * CHUNK_SIZE * CHUNK_SIZE - if (idx >= blockLight.length) continue + if (idx >= blockLight.length && idx >= skyLight.length) continue _syncLightPos.set(lx, y, lz) - col.setBlockLight(_syncLightPos, blockLight[idx]!) - col.setSkyLight(_syncLightPos, skyLight[idx]!) + if (blockAuthoritative && idx < blockLight.length) col.setBlockLight(_syncLightPos, blockLight[idx]!) + if (skyAuthoritative && idx < skyLight.length) col.setSkyLight(_syncLightPos, skyLight[idx]!) } } } @@ -382,6 +490,7 @@ function processChunkMessage(data: { x: number; z: number; chunk: any; customBlo function drainPendingChunks() { if (!world) return pendingChunks.drain(msg => { + dropRawMapChunkOnLightOnlyReload(msg.isLightUpdate, rawMapChunkCache, rawCacheKey(msg.x, msg.z)) processChunkMessage(msg) onColumnDataArrived(msg.x, msg.z) }) @@ -391,7 +500,8 @@ function drainPendingChunks() { // `processUpdateLightV16` (which calls the shared `parseUpdateLightV17` // WASM export). Separate map for the same isolation reasons as // `parsedV16Cache` above. -const updateLightV16Cache = new Map() +const updateLightV16Cache = new Map() +const incomingUpdateLightV16Cache = new Map() // Mirrors `convertChunkToWasm`'s output (same layout: x + z*16 + y*256, // y outer) so it can be dropped straight into `generate_geometry`. @@ -435,7 +545,7 @@ const convertRawMapChunkToWasm = (raw: RawMapChunkEntry, version: string): Chunk // (expanded from the 4×4×4 cell layout). Light comes from the paired // `update_light` cache when available; otherwise we fall back to full // daylight (sky=15) and no block light so geometry stays visible. -const convertParsedV17ToWasm = (entry: ParsedV17Entry, lightEntry: UpdateLightV17Entry | undefined, version: string): ChunkConversionResult | null => { +const convertParsedV17ToWasm = (entry: ParsedV17Entry, lightEntry: UpdateLightColumnCache | undefined, version: string): ChunkConversionResult | null => { if (!wasm || !(wasm as any).parseChunkSectionsV16V17) return null // Empty `Int32Array` signals "no biomes captured" — WASM falls back to // `default_biome` for every block. Plains (id 1) matches the JS path. @@ -452,9 +562,9 @@ const convertParsedV17ToWasm = (entry: ParsedV17Entry, lightEntry: UpdateLightV1 const totalBlocks = blockStates.length let blockLight: Uint8Array let skyLight: Uint8Array - if (lightEntry && lightEntry.skyLight.length === totalBlocks) { - skyLight = lightEntry.skyLight - blockLight = lightEntry.blockLight + if (lightEntry) { + skyLight = displayLightColumn(lightEntry, 'sky', config?.skyLight ?? 15) + blockLight = displayLightColumn(lightEntry, 'block', 0) } else { blockLight = new Uint8Array(totalBlocks) skyLight = new Uint8Array(totalBlocks) @@ -486,7 +596,7 @@ const convertParsedV17ToWasm = (entry: ParsedV17Entry, lightEntry: UpdateLightV1 // defaults — anything else means a non-vanilla server we don't support // on the fast path, in which case we return null and fall back to the // JS column-walk via `convertChunkToWasm`. -const convertParsedV16ToWasm = (entry: ParsedV16Entry, lightEntry: UpdateLightV17Entry | undefined, version: string): ChunkConversionResult | null => { +const convertParsedV16ToWasm = (entry: ParsedV16Entry, lightEntry: UpdateLightColumnCache | undefined, version: string): ChunkConversionResult | null => { if (!wasm || !(wasm as any).parseChunkSectionsV16V17) return null const NUM_SECTIONS = 16 const MAX_BITS_PER_BLOCK = 15 @@ -507,9 +617,9 @@ const convertParsedV16ToWasm = (entry: ParsedV16Entry, lightEntry: UpdateLightV1 const totalBlocks = blockStates.length let blockLight: Uint8Array let skyLight: Uint8Array - if (lightEntry && lightEntry.skyLight.length === totalBlocks) { - skyLight = lightEntry.skyLight - blockLight = lightEntry.blockLight + if (lightEntry) { + skyLight = displayLightColumn(lightEntry, 'sky', config?.skyLight ?? 15) + blockLight = displayLightColumn(lightEntry, 'block', 0) } else { blockLight = new Uint8Array(totalBlocks) skyLight = new Uint8Array(totalBlocks) @@ -760,7 +870,7 @@ const meshMultiColumnsFromParsedV16V17 = ( const bm = entry.bitMap >>> 0 bitMapLoHi[i * 2] = bm bitMapLoHi[i * 2 + 1] = 0 - const light = updateLightV16Cache.get(key) + const light = displayCachedLight(updateLightV16Cache.get(key)) skyLightList.push(light?.skyLight ?? new Uint8Array(0)) blockLightList.push(light?.blockLight ?? new Uint8Array(0)) } @@ -837,18 +947,78 @@ const handleMessage = async (data: any) => { case 'dirty': { const loc = new Vec3(data.x, data.y, data.z) setSectionDirty(loc, data.value) + { + const sectionHeight = getSectionHeight() + const key = sectionKey(Math.floor(loc.x / 16) * 16, Math.floor(loc.y / sectionHeight) * sectionHeight, Math.floor(loc.z / 16) * 16) + if (data.urgent) { + urgentSections.add(key) + meshTickScheduler.kick() + } else if (!data.value) urgentSections.delete(key) + } + if (typeof data.lightPublicationVersion === 'number') { + const sectionHeight = getSectionHeight() + const key = sectionKey(Math.floor(loc.x / 16) * 16, Math.floor(loc.y / sectionHeight) * sectionHeight, Math.floor(loc.z / 16) * 16) + dirtyLightMeta.set(key, { + lightPublicationVersion: data.lightPublicationVersion, + worldGeneration: data.worldGeneration + }) + } + { + const sectionHeight = getSectionHeight() + const key = sectionKey(Math.floor(loc.x / 16) * 16, Math.floor(loc.y / sectionHeight) * sectionHeight, Math.floor(loc.z / 16) * 16) + if ( + typeof data.sessionEpoch === 'number' || + typeof data.columnIncarnation === 'number' || + typeof data.requestId === 'number' || + typeof data.topologyRevision === 'number' + ) { + dirtyVersionMeta.set(key, { + sessionEpoch: data.sessionEpoch, + columnIncarnation: data.columnIncarnation, + requestId: data.requestId, + topologyRevision: data.topologyRevision, + neighborTopologyRevisions: data.neighborTopologyRevisions + }) + } + if (typeof data.clientLightRequestId === 'number' || typeof data.clientLightEditSeq === 'number') { + dirtyTraceMeta.set(key, { + clientLightRequestId: data.clientLightRequestId, + clientLightEditSeq: data.clientLightEditSeq, + clientLightSessionEpoch: data.clientLightSessionEpoch + }) + if (isClientLightTraceEnabled() && isClientLightTraceArmed()) { + postClientLightTrace(postTrace, { + phase: 'mesherEnqueue', + sectionKey: key, + requestId: data.clientLightRequestId, + editSeq: data.clientLightEditSeq, + sessionEpoch: data.clientLightSessionEpoch, + lightVersion: data.lightPublicationVersion, + worldGeneration: data.worldGeneration, + workerIndex + }) + } + } + } + break + } + case 'clientLightTraceConfig': { + enableClientLightTrace(Boolean(data.enabled)) + armClientLightTrace(Boolean(data.armed)) break } case 'chunk': { // Invalidate BEFORE replacing the column reference so a stale entry // can never outlive the old chunk object. invalidateConversion(data.x, data.z) + dropRawMapChunkOnLightOnlyReload(data.isLightUpdate, rawMapChunkCache, rawCacheKey(data.x, data.z)) if (!world) { pendingChunks.enqueue({ x: data.x, z: data.z, chunk: data.chunk, - customBlockModels: data.customBlockModels + customBlockModels: data.customBlockModels, + isLightUpdate: data.isLightUpdate }) break } @@ -858,21 +1028,32 @@ const handleMessage = async (data: any) => { } case 'unloadChunk': { invalidateConversion(data.x, data.z) - rawMapChunkCache.delete(rawCacheKey(data.x, data.z)) - parsedV17Cache.delete(rawCacheKey(data.x, data.z)) - updateLightV17Cache.delete(rawCacheKey(data.x, data.z)) - parsedV16Cache.delete(rawCacheKey(data.x, data.z)) - updateLightV16Cache.delete(rawCacheKey(data.x, data.z)) - pendingLightDirtyColumns.delete(rawCacheKey(data.x, data.z)) + const unloadKey = rawCacheKey(data.x, data.z) + rawMapChunkCache.delete(unloadKey) + parsedV17Cache.delete(unloadKey) + updateLightV17Cache.delete(unloadKey) + incomingUpdateLightV17Cache.delete(unloadKey) + parsedV16Cache.delete(unloadKey) + updateLightV16Cache.delete(unloadKey) + incomingUpdateLightV16Cache.delete(unloadKey) + ownerOwnedLightColumns.delete(unloadKey) + appliedOwnerLightVersion.delete(unloadKey) + columnIncarnation.set(unloadKey, (columnIncarnation.get(unloadKey) ?? 1) + 1) + pendingLightDirtyColumns.delete(unloadKey) if (!world) break world.removeColumn(data.x, data.z) world.customBlockModels.delete(`${data.x},${data.z}`) pendingNeighborHeal.clearColumn(data.x, data.z) requestTracker.clearColumn(data.x, data.z) + clearTopologyPostCacheColumn(data.x, data.z) for (const key of [...dirtySections.keys()]) { const [sx, , sz] = key.split(',').map(Number) if (sx === data.x && sz === data.z) { dirtySections.delete(key) + dirtyLightMeta.delete(key) + dirtyTraceMeta.delete(key) + dirtyVersionMeta.delete(key) + urgentSections.delete(key) } } if (Object.keys(world.columns).length === 0) softCleanup() @@ -963,17 +1144,109 @@ const handleMessage = async (data: any) => { processUpdateLightV16(data.rawPacket as Uint8Array) break } + case 'setOwnerAcceptFence': { + if (typeof data.sessionEpoch === 'number') ownerSessionEpoch = data.sessionEpoch + rejectOwnerPublications = data.rejectOwnerPublications === true + break + } + case 'revertOwnerLightToIncoming': { + if (typeof data.sessionEpoch === 'number') ownerSessionEpoch = data.sessionEpoch + rejectOwnerPublications = true + for (const key of [...ownerOwnedLightColumns]) { + const [cx, cz] = key.split(',').map(Number) + const v16In = incomingUpdateLightV16Cache.get(key) + const v17In = incomingUpdateLightV17Cache.get(key) + const v16 = updateLightV16Cache.get(key) + const v17 = updateLightV17Cache.get(key) + if (v16 && !v17) { + const reverted = revertDisplayToIncoming({ incoming: v16In, display: v16 }) + if (reverted.display) updateLightV16Cache.set(key, reverted.display) + } else { + const reverted = revertDisplayToIncoming({ incoming: v17In, display: v17 }) + if (reverted.display) { + updateLightV17Cache.set(key, reverted.display) + syncV17LightToColumn(cx, cz) + } + } + ownerOwnedLightColumns.delete(key) + appliedOwnerLightVersion.delete(key) + invalidateConversion(cx, cz) + } + break + } + case 'applyOwnerLightPublication': { + if (rejectOwnerPublications) break + if (typeof data.sessionEpoch === 'number' && ownerSessionEpoch !== 0 && data.sessionEpoch !== ownerSessionEpoch) break + const checked = validateOwnerPublicationMessage(data) + if (!checked.ok) break + const sections = (data.sections ?? []) as Array<{ sx: number; sy: number; sz: number; blockLight: Uint8Array; skyLight?: Uint8Array }> + const worldMinY = config?.worldMinY ?? 0 + const worldMaxY = config?.worldMaxY ?? 256 + const numSections = Math.max(1, Math.floor((worldMaxY - worldMinY) / 16)) + const columns = new Set() + for (const section of sections) columns.add(`${section.sx},${section.sz}`) + const publicationVersion = typeof data.publicationVersion === 'number' ? data.publicationVersion : 0 + for (const col of columns) { + const [cx, cz] = col.split(',').map(Number) + const key = rawCacheKey(cx, cz) + const v16 = updateLightV16Cache.get(key) + const v17 = updateLightV17Cache.get(key) + if (v16 && !v17) { + const applied = applyOwnerPublicationToColumnCaches({ + incoming: incomingUpdateLightV16Cache.get(key), + display: v16, + sections, + worldMinY, + columnWorldX: cx, + columnWorldZ: cz, + numSections: v16.numSections + }) + if (applied.incoming) incomingUpdateLightV16Cache.set(key, applied.incoming) + updateLightV16Cache.set(key, applied.display) + } else { + const applied = applyOwnerPublicationToColumnCaches({ + incoming: incomingUpdateLightV17Cache.get(key), + display: v17, + sections, + worldMinY, + columnWorldX: cx, + columnWorldZ: cz, + numSections: v17?.numSections ?? numSections + }) + if (applied.incoming) incomingUpdateLightV17Cache.set(key, applied.incoming) + updateLightV17Cache.set(key, applied.display) + syncV17LightToColumn(cx, cz, ownerDeltaSectionWorldYs(sections, cx, cz)) + } + ownerOwnedLightColumns.add(key) + appliedOwnerLightVersion.set(key, publicationVersion) + if (typeof data.sessionEpoch === 'number') ownerSessionEpoch = data.sessionEpoch + invalidateConversion(cx, cz) + } + break + } case 'reset': { world = undefined as any dirtySections.clear() + dirtyLightMeta.clear() + dirtyTraceMeta.clear() + dirtyVersionMeta.clear() + urgentSections.clear() + appliedOwnerLightVersion.clear() + columnIncarnation.clear() + ownerSessionEpoch = 0 + rejectOwnerPublications = false requestTracker.clear() clearConversionCache() + clearTopologyPostCache() rawMapChunkCache.clear() parsedV17Cache.clear() updateLightV17Cache.clear() + incomingUpdateLightV17Cache.clear() + ownerOwnedLightColumns.clear() pendingLightDirtyColumns.clear() parsedV16Cache.clear() updateLightV16Cache.clear() + incomingUpdateLightV16Cache.clear() pendingChunks.clear() pendingNeighborHeal.clear() blindMeshWarnCount = 0 @@ -1088,7 +1361,38 @@ function makeEmptyColumnGeometry(sx: number, sy: number, sz: number, sectionHeig // It groups dirty section keys by chunk column, runs one WASM call per column // over the full Y range, then splits the column output back into per-section // geometries. Only requested section keys are emitted back to the main thread. -function processColumnTick() { +async function processColumnTick() { + beginTickWasmParseCache() + try { + await processColumnTickInner() + } finally { + endTickWasmParseCache() + } +} + +function dirtyHasUrgent() { + for (const key of dirtySections.keys()) { + if (urgentSections.has(key)) return true + } + return false +} + +function requeueColumnGroups(pending: Array<{ sections: Array<{ key: string; count: number }> }>, urgentKeys: Set) { + for (const group of pending) { + for (const section of group.sections) { + dirtySections.set(section.key, (dirtySections.get(section.key) ?? 0) + section.count) + if (urgentKeys.has(section.key)) urgentSections.add(section.key) + } + } +} + +function yieldToIncomingMessages() { + return new Promise(resolve => { + setTimeout(resolve, 0) + }) +} + +async function processColumnTickInner() { const worldMinY = config?.worldMinY ?? 0 const worldMaxY = config?.worldMaxY ?? 256 const columnHeight = worldMaxY - worldMinY @@ -1098,20 +1402,25 @@ function processColumnTick() { // coords — the same units used by section keys). This guarantees a // single WASM call per column per tick even when multiple section keys // of the same column are dirty. - const groups = new Map }>() - for (const [key, count] of dirtySections) { - const [sx, sy, sz] = key.split(',').map(v => parseInt(v, 10)) - const colKey = `${sx},${sz}` - let g = groups.get(colKey) - if (!g) { - g = { x: sx, z: sz, sections: [] } - groups.set(colKey, g) + const { selected: groups, remaining } = selectColumnGroupsForTick(dirtySections, urgentSections) + dirtySections.clear() + for (const [key, count] of remaining) dirtySections.set(key, count) + const selectedUrgent = new Set() + for (const group of groups) { + for (const section of group.sections) { + if (urgentSections.delete(section.key)) selectedUrgent.add(section.key) } - g.sections.push({ key, x: sx, y: sy, z: sz, count }) } - dirtySections.clear() + if (isClientLightTraceEnabled() && isClientLightTraceArmed()) { + postClientLightTrace(postTrace, { + phase: 'mesherStart', + queueDepth: groups.length, + workerIndex + }) + } - for (const group of groups.values()) { + for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) { + const group = groups[groupIndex]! const { x, z, sections } = group const targetChunk = world.getColumn(x, z) @@ -1127,6 +1436,8 @@ function processColumnTick() { let preOther = 0 let preCacheHits = 0 let preCacheMisses = 0 + let preWasmParseHits = 0 + let preWasmParseMisses = 0 let hadError = false let columnMeshPath = 'none' let chunkCount = 0 @@ -1139,6 +1450,7 @@ function processColumnTick() { // attributed to the column). let columnStart = 0 let postStart = 0 + let usedLightColumns = new Set() if (targetChunk && wasm) { columnStart = performance.now() @@ -1146,6 +1458,7 @@ function processColumnTick() { const t0 = start try { const chunksToUse = collectChunksForColumn(x, z) + usedLightColumns = new Set(chunksToUse.map(entry => rawCacheKey(entry.x, entry.z))) chunkCount = chunksToUse.length const caches = packetCaches() worldColumns3x3 = countWorldColumns3x3(x, z, (nx, nz) => world.getColumn(nx, nz)) @@ -1201,7 +1514,7 @@ function processColumnTick() { ) if (wasmResult) columnMeshPath = 'v17_fused' } else if (v16Entry) { - const v16Light = updateLightV16Cache.get(rawCacheKey(x, z)) + const v16Light = displayCachedLight(updateLightV16Cache.get(rawCacheKey(x, z))) const bitMapLoHi = new Uint32Array([v16Entry.bitMap >>> 0, 0]) wasmResult = meshColumnFromParsedV16V17( v16Entry.chunkData, @@ -1256,25 +1569,36 @@ function processColumnTick() { const cs = performance.now() const rawEntry = rawMapChunkCache.get(rawCacheKey(cx, cz)) const v17Entry = parsedV17Cache.get(rawCacheKey(cx, cz)) - const v17Light = resolveUpdateLightV17Entry(cx, cz, chunk, worldMinY, worldMaxY) + const v17Light = updateLightV17Cache.get(rawCacheKey(cx, cz)) const v16Entry = parsedV16Cache.get(rawCacheKey(cx, cz)) const v16Light = updateLightV16Cache.get(rawCacheKey(cx, cz)) let conv: ChunkConversionResult | null = null let hit = false - // WASM fast paths — parse is already fast (2.19× over JS), no - // cache needed. Bypass getOrConvertColumn so the conversion - // cache only holds JS-fallback results. When a WASM helper - // returns null (unsupported protocol, parser error, …) we MUST - // fall through to the JS column walk — otherwise the column - // would render as empty geometry. + // WASM fast paths — parse is already fast (2.19× over JS). Cache + // for the rest of this tick so 9 dirty columns share neighbour + // parses. Bypass getOrConvertColumn so the conversion cache only + // holds JS-fallback results. When a WASM helper returns null + // (unsupported protocol, parser error, …) we MUST fall through + // to the JS column walk — otherwise the column would render as + // empty geometry. + const tickKey = rawCacheKey(cx, cz) if (rawEntry) { - conv = convertRawMapChunkToWasm(rawEntry, version) + const cached = getOrConvertTickWasmParse(tickKey, () => convertRawMapChunkToWasm(rawEntry, version)) + conv = cached.result + if (cached.hit) preWasmParseHits++ + else preWasmParseMisses++ } else if (v17Entry) { - conv = convertParsedV17ToWasm(v17Entry, v17Light, version) + const cached = getOrConvertTickWasmParse(tickKey, () => convertParsedV17ToWasm(v17Entry, v17Light, version)) + conv = cached.result + if (cached.hit) preWasmParseHits++ + else preWasmParseMisses++ } else if (v16Entry) { - conv = convertParsedV16ToWasm(v16Entry, v16Light, version) + const cached = getOrConvertTickWasmParse(tickKey, () => convertParsedV16ToWasm(v16Entry, v16Light, version)) + conv = cached.result + if (cached.hit) preWasmParseHits++ + else preWasmParseMisses++ } if (!conv) { @@ -1309,6 +1633,7 @@ function processColumnTick() { conversions.push(conv) meshChunks.push({ x: cx, z: cz, chunk }) } + usedLightColumns = new Set(meshChunks.map(entry => rawCacheKey(entry.x, entry.z))) const twoStepChunkCount = conversions.length if (twoStepChunkCount === 0) { @@ -1463,27 +1788,39 @@ function processColumnTick() { const sectionBlocksCount = entry?.blocksCount ?? 0 // Block entity metadata still needs a per-section world walk // (signs/heads/banners), matching the legacy per-section path. - const signs: Record = {} - const heads: Record = {} - const banners: Record = {} - const beTarget = { signs, heads, banners } - const beOpts = { disableBlockEntityTextures: world.config.disableBlockEntityTextures } - const { occludingLookup } = getBlockMeta(version) - const visGraph = new VisGraph() - const cursor = new Vec3(0, 0, 0) - for (cursor.y = sy; cursor.y < sy + sectionHeight; cursor.y++) { - for (cursor.z = sz; cursor.z < sz + 16; cursor.z++) { - for (cursor.x = sx; cursor.x < sx + 16; cursor.x++) { - const b = world.getBlock(cursor) - if (!b) continue - if (occludingLookup[b.stateId]) { - visGraph.setOpaque(cursor.x - sx, cursor.y - sy, cursor.z - sz) + const topologyRevision = dirtyVersionMeta.get(key)?.topologyRevision + const post = getOrComputeTopologyPost(key, topologyRevision, () => { + const signs: Record = {} + const heads: Record = {} + const banners: Record = {} + const beTarget = { signs, heads, banners } + const beOpts = { disableBlockEntityTextures: world.config.disableBlockEntityTextures } + const { occludingLookup } = getBlockMeta(version) + const visGraph = new VisGraph() + const cursor = new Vec3(0, 0, 0) + for (cursor.y = sy; cursor.y < sy + sectionHeight; cursor.y++) { + for (cursor.z = sz; cursor.z < sz + 16; cursor.z++) { + for (cursor.x = sx; cursor.x < sx + 16; cursor.x++) { + const b = world.getBlock(cursor) + if (!b) continue + if (occludingLookup[b.stateId]) { + visGraph.setOpaque(cursor.x - sx, cursor.y - sy, cursor.z - sz) + } + collectBlockEntityMetadata(b, cursor.x, cursor.y, cursor.z, beTarget, beOpts, world) } - collectBlockEntityMetadata(b, cursor.x, cursor.y, cursor.z, beTarget, beOpts, world) } } - } - const sectionVisibilitySet = packVisibilitySet(visGraph.resolve()) + return { + visibilitySet: packVisibilitySet(visGraph.resolve()), + signs, + heads, + banners + } + }) + const signs = post.payload.signs as Record + const heads = post.payload.heads as Record + const banners = post.payload.banners as Record + const sectionVisibilitySet = post.payload.visibilitySet as ReturnType let geometry: MesherGeometryOutput let transferable: any[] = [] @@ -1590,10 +1927,12 @@ function processColumnTick() { geometry.heads = heads geometry.banners = banners } - postMessage({ type: 'geometry', key, geometry, workerIndex }, transferable) + const versionMeta = takeGeometryVersionMeta(key, sx, sz, usedLightColumns) + postMessage({ type: 'geometry', key, geometry, workerIndex, ...versionMeta }, transferable) } else if (hadError) { const errorGeometry = makeEmptyColumnGeometry(sx, sy, sz, sectionHeight, true) - postMessage({ type: 'geometry', key, geometry: errorGeometry, workerIndex }) + const versionMeta = takeGeometryVersionMeta(key, sx, sz, usedLightColumns) + postMessage({ type: 'geometry', key, geometry: errorGeometry, workerIndex, ...versionMeta }) } // No targetChunk and no error: skip geometry message (mirrors // legacy behavior for sections whose chunk has been unloaded @@ -1632,6 +1971,8 @@ function processColumnTick() { preOther: !attributed ? preOther : 0, preCacheHits: !attributed ? preCacheHits : 0, preCacheMisses: !attributed ? preCacheMisses : 0, + preWasmParseHits: !attributed ? preWasmParseHits : 0, + preWasmParseMisses: !attributed ? preWasmParseMisses : 0, chunkCount: !attributed ? chunkCount : 0, worldColumns3x3: !attributed ? worldColumns3x3 : 0, parsedCache3x3: !attributed ? parsedCache3x3 : 0, @@ -1640,25 +1981,37 @@ function processColumnTick() { attributed = true } } + if (groupIndex < groups.length - 1) { + await yieldToIncomingMessages() + if (dirtyHasUrgent()) { + requeueColumnGroups(groups.slice(groupIndex + 1), selectedUrgent) + break + } + } + } + if (isClientLightTraceEnabled() && isClientLightTraceArmed()) { + postClientLightTrace(postTrace, { + phase: 'mesherEnd', + queueDepth: 0, + workerIndex + }) } } -setInterval(async () => { +const meshTickScheduler = createMeshTickScheduler(async () => { if (!allDataReady) return - - // Ensure WASM is initialized if (!wasmInitialized) { await initWasm() - if (!wasmInitialized) return // Still not initialized, skip this cycle + if (!wasmInitialized) return } - if (dirtySections.size === 0) return - try { - processColumnTick() + await processColumnTick() } catch (err) { console.error('[WASM Mesher] processColumnTick failed:', err) - // Swallow to avoid breaking the setInterval; individual columns - // already have their own try/catch. } -}, 50) +}) + +setInterval(() => { + meshTickScheduler.kick() +}, BULK_MESH_INTERVAL_MS) diff --git a/src/wasm-mesher/worker/mesherWasmChunkBuffer.ts b/src/wasm-mesher/worker/mesherWasmChunkBuffer.ts index 5c96035..e85e37f 100644 --- a/src/wasm-mesher/worker/mesherWasmChunkBuffer.ts +++ b/src/wasm-mesher/worker/mesherWasmChunkBuffer.ts @@ -3,6 +3,7 @@ export type PendingChunkMessage = { z: number chunk: any customBlockModels?: any + isLightUpdate?: boolean } export class PendingChunkBuffer { diff --git a/src/wasm-mesher/worker/mesherWasmColumnTickSelect.ts b/src/wasm-mesher/worker/mesherWasmColumnTickSelect.ts new file mode 100644 index 0000000..14a9fb5 --- /dev/null +++ b/src/wasm-mesher/worker/mesherWasmColumnTickSelect.ts @@ -0,0 +1,46 @@ +/** + * Choose which dirty columns a mesher tick processes. + * + * Urgent (player-edit) columns go first with no cap. Bulk columns are limited + * so an incoming edit is not stuck behind a long FIFO of load-wave remeshes. + */ + +export const BULK_COLUMNS_PER_TICK = 4 + +export type ColumnTickSection = { key: string; x: number; y: number; z: number; count: number } + +export type ColumnTickGroup = { x: number; z: number; sections: ColumnTickSection[] } + +export function groupDirtySectionsByColumn(dirtySections: Iterable<[string, number]>): ColumnTickGroup[] { + const groups = new Map() + for (const [key, count] of dirtySections) { + const [sx, sy, sz] = key.split(',').map(v => parseInt(v, 10)) + const colKey = `${sx},${sz}` + let group = groups.get(colKey) + if (!group) { + group = { x: sx, z: sz, sections: [] } + groups.set(colKey, group) + } + group.sections.push({ key, x: sx, y: sy, z: sz, count }) + } + return [...groups.values()] +} + +export function selectColumnGroupsForTick( + dirtySections: Map, + urgentKeys: Set, + bulkLimit = BULK_COLUMNS_PER_TICK +): { selected: ColumnTickGroup[]; remaining: Map } { + const urgent: ColumnTickGroup[] = [] + const bulk: ColumnTickGroup[] = [] + for (const group of groupDirtySectionsByColumn(dirtySections)) { + if (group.sections.some(section => urgentKeys.has(section.key))) urgent.push(group) + else bulk.push(group) + } + const selected = [...urgent, ...bulk.slice(0, bulkLimit)] + const remaining = new Map() + for (const group of bulk.slice(bulkLimit)) { + for (const section of group.sections) remaining.set(section.key, section.count) + } + return { selected, remaining } +} diff --git a/src/wasm-mesher/worker/mesherWasmLightDirty.test.ts b/src/wasm-mesher/worker/mesherWasmLightDirty.test.ts index 52450db..a55b708 100644 --- a/src/wasm-mesher/worker/mesherWasmLightDirty.test.ts +++ b/src/wasm-mesher/worker/mesherWasmLightDirty.test.ts @@ -1,8 +1,24 @@ import { describe, expect, it } from 'vitest' -import { sectionYsForLightColumnDirty } from './mesherWasmLightDirty' +import { dropRawMapChunkOnLightOnlyReload, sectionYsForLightColumnDirty } from './mesherWasmLightDirty' describe('sectionYsForLightColumnDirty', () => { it('covers every section in a 256-high overworld column', () => { expect(sectionYsForLightColumnDirty(0, 256)).toEqual(Array.from({ length: 16 }, (_, i) => i * 16)) }) }) + +describe('dropRawMapChunkOnLightOnlyReload', () => { + it('deletes only the fused raw entry when the chunk reload is light-only', () => { + const raw = new Map([ + ['0,0', 1], + ['16,0', 2] + ]) + expect(dropRawMapChunkOnLightOnlyReload(false, raw, '0,0')).toBe(false) + expect(raw.has('0,0')).toBe(true) + expect(dropRawMapChunkOnLightOnlyReload(undefined, raw, '0,0')).toBe(false) + expect(raw.has('0,0')).toBe(true) + expect(dropRawMapChunkOnLightOnlyReload(true, raw, '0,0')).toBe(true) + expect(raw.has('0,0')).toBe(false) + expect(raw.has('16,0')).toBe(true) + }) +}) diff --git a/src/wasm-mesher/worker/mesherWasmLightDirty.ts b/src/wasm-mesher/worker/mesherWasmLightDirty.ts index 79145b3..26a9bd5 100644 --- a/src/wasm-mesher/worker/mesherWasmLightDirty.ts +++ b/src/wasm-mesher/worker/mesherWasmLightDirty.ts @@ -8,3 +8,13 @@ export function sectionYsForLightColumnDirty(worldMinY: number, worldMaxY: numbe } return ys } + +/** + * 1.18+ fused meshing prefers the original raw map_chunk. A later light-only + * column reload must drop that entry so the JSON column walk is used. + * Normal chunk loads must keep it (`setRawMapChunk` arrives first). + */ +export function dropRawMapChunkOnLightOnlyReload(isLightUpdate: boolean | undefined, rawCache: { delete: (key: string) => boolean }, key: string): boolean { + if (!isLightUpdate) return false + return rawCache.delete(key) +} diff --git a/src/wasm-mesher/worker/mesherWasmLightMerge.test.ts b/src/wasm-mesher/worker/mesherWasmLightMerge.test.ts new file mode 100644 index 0000000..533d1c7 --- /dev/null +++ b/src/wasm-mesher/worker/mesherWasmLightMerge.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest' +import { + LIGHT_BLOCKS_PER_SECTION, + createEmptyLightCache, + displayLightColumn, + isLightSectionPresent, + mergeUpdateLight, + worldSectionMaskBit, + type ParsedUpdateLight, + type UpdateLightColumnCache +} from './mesherWasmLightMerge' + +const NUM_SECTIONS = 16 +const WORLD_BYTES = NUM_SECTIONS * LIGHT_BLOCKS_PER_SECTION + +function maskWithBits(...bits: number[]): Uint32Array { + const out = new Uint32Array(2) + for (const bit of bits) { + const bitInLong = bit & 63 + if (bitInLong < 32) out[0] |= 1 << bitInLong + else out[1] |= 1 << (bitInLong - 32) + } + return out +} + +function emptyParsed(overrides: Partial = {}): ParsedUpdateLight { + return { + x: 0, + z: 0, + trustEdges: false, + numSections: NUM_SECTIONS, + skyLight: new Uint8Array(WORLD_BYTES), + blockLight: new Uint8Array(WORLD_BYTES), + skyLightMask: new Uint32Array(2), + emptySkyLightMask: new Uint32Array(2), + blockLightMask: new Uint32Array(2), + emptyBlockLightMask: new Uint32Array(2), + ...overrides + } +} + +function writeWorldSection(column: Uint8Array, sectionIndex: number, value: number) { + const start = sectionIndex * LIGHT_BLOCKS_PER_SECTION + column.fill(value, start, start + LIGHT_BLOCKS_PER_SECTION) +} + +function sectionSlice(column: Uint8Array, sectionIndex: number): Uint8Array { + const start = sectionIndex * LIGHT_BLOCKS_PER_SECTION + return column.subarray(start, start + LIGHT_BLOCKS_PER_SECTION) +} + +function sectionAll(column: Uint8Array, sectionIndex: number, value: number): boolean { + return sectionSlice(column, sectionIndex).every(v => v === value) +} + +describe('mergeUpdateLight', () => { + it('does not bake omitted sky as authoritative 15 on the first packet', () => { + const skyLight = new Uint8Array(WORLD_BYTES) + writeWorldSection(skyLight, 1, 3) + const cache = mergeUpdateLight( + undefined, + emptyParsed({ + skyLight, + skyLightMask: maskWithBits(worldSectionMaskBit(1)) + }) + ) + + expect(isLightSectionPresent(cache.skyPresent, worldSectionMaskBit(1))).toBe(true) + expect(isLightSectionPresent(cache.skyPresent, worldSectionMaskBit(0))).toBe(false) + expect(sectionAll(cache.skyLight, 1, 3)).toBe(true) + expect(sectionAll(cache.skyLight, 0, 15)).toBe(false) + expect(displayLightColumn(cache, 'sky', 15)[0]).toBe(15) + expect(displayLightColumn(cache, 'sky')[LIGHT_BLOCKS_PER_SECTION]).toBe(3) + }) + + it('preserves an omitted neighbor section across a repeated partial delta', () => { + const firstSky = new Uint8Array(WORLD_BYTES) + writeWorldSection(firstSky, 0, 7) + let cache = mergeUpdateLight( + undefined, + emptyParsed({ + skyLight: firstSky, + skyLightMask: maskWithBits(worldSectionMaskBit(0)) + }) + ) + + const secondSky = new Uint8Array(WORLD_BYTES) + writeWorldSection(secondSky, 1, 4) + cache = mergeUpdateLight( + cache, + emptyParsed({ + skyLight: secondSky, + skyLightMask: maskWithBits(worldSectionMaskBit(1)) + }) + ) + + expect(sectionAll(cache.skyLight, 0, 7)).toBe(true) + expect(sectionAll(cache.skyLight, 1, 4)).toBe(true) + expect(isLightSectionPresent(cache.skyPresent, worldSectionMaskBit(0))).toBe(true) + expect(isLightSectionPresent(cache.skyPresent, worldSectionMaskBit(1))).toBe(true) + expect(isLightSectionPresent(cache.skyPresent, worldSectionMaskBit(2))).toBe(false) + }) + + it('zeros a section when the empty-bit is set', () => { + const firstSky = new Uint8Array(WORLD_BYTES) + writeWorldSection(firstSky, 0, 7) + let cache = mergeUpdateLight( + undefined, + emptyParsed({ + skyLight: firstSky, + skyLightMask: maskWithBits(worldSectionMaskBit(0)) + }) + ) + + cache = mergeUpdateLight( + cache, + emptyParsed({ + emptySkyLightMask: maskWithBits(worldSectionMaskBit(0)) + }) + ) + + expect(isLightSectionPresent(cache.skyPresent, worldSectionMaskBit(0))).toBe(true) + expect(sectionAll(cache.skyLight, 0, 0)).toBe(true) + expect(displayLightColumn(cache, 'sky', 15)[0]).toBe(0) + }) + + it('updates sky and block independently', () => { + const skyLight = new Uint8Array(WORLD_BYTES) + const blockLight = new Uint8Array(WORLD_BYTES) + writeWorldSection(skyLight, 0, 12) + writeWorldSection(blockLight, 0, 5) + let cache = mergeUpdateLight( + undefined, + emptyParsed({ + skyLight, + blockLight, + skyLightMask: maskWithBits(worldSectionMaskBit(0)), + blockLightMask: maskWithBits(worldSectionMaskBit(0)) + }) + ) + + cache = mergeUpdateLight( + cache, + emptyParsed({ + emptyBlockLightMask: maskWithBits(worldSectionMaskBit(0)) + }) + ) + + expect(sectionAll(cache.skyLight, 0, 12)).toBe(true) + expect(sectionAll(cache.blockLight, 0, 0)).toBe(true) + expect(isLightSectionPresent(cache.skyPresent, worldSectionMaskBit(0))).toBe(true) + expect(isLightSectionPresent(cache.blockPresent, worldSectionMaskBit(0))).toBe(true) + }) + + it('records padding bits without writing them into the world column', () => { + const skyBelow = new Uint8Array(LIGHT_BLOCKS_PER_SECTION).fill(9) + const cache = mergeUpdateLight( + undefined, + emptyParsed({ + skyBelow, + skyLightMask: maskWithBits(0) + }) + ) + + expect(isLightSectionPresent(cache.skyPresent, 0)).toBe(true) + expect(cache.skyBelow).toBeDefined() + expect(cache.skyBelow!.every(v => v === 9)).toBe(true) + expect(sectionAll(cache.skyLight, 0, 0)).toBe(true) + expect(isLightSectionPresent(cache.skyPresent, worldSectionMaskBit(0))).toBe(false) + }) + + it('applies captured 1.17.1 empty-block bits without seeding omitted sky as 15', () => { + const cache = mergeUpdateLight( + undefined, + emptyParsed({ + trustEdges: true, + emptyBlockLightMask: maskWithBits(1, 2, 3, 4, 5) + }) + ) + + expect(cache.trustEdges).toBe(true) + for (let s = 0; s <= 4; s++) { + expect(isLightSectionPresent(cache.blockPresent, worldSectionMaskBit(s))).toBe(true) + expect(sectionAll(cache.blockLight, s, 0)).toBe(true) + } + expect(isLightSectionPresent(cache.skyPresent, worldSectionMaskBit(0))).toBe(false) + expect(sectionAll(cache.skyLight, 0, 15)).toBe(false) + expect(displayLightColumn(cache, 'sky', 15)[0]).toBe(15) + }) + + it('keeps trustEdges from the latest packet without changing merge rules', () => { + const cache = mergeUpdateLight(createEmptyLightCache(NUM_SECTIONS), emptyParsed({ trustEdges: true })) + expect(cache.trustEdges).toBe(true) + }) +}) + +describe('displayLightColumn', () => { + it('fills only absent world sections and leaves explicit zeros', () => { + const cache: UpdateLightColumnCache = createEmptyLightCache(NUM_SECTIONS) + writeWorldSection(cache.blockLight, 2, 0) + cache.blockPresent = maskWithBits(worldSectionMaskBit(2)) + + const displayed = displayLightColumn(cache, 'block', 0) + expect(sectionAll(displayed, 2, 0)).toBe(true) + expect(sectionAll(displayed, 1, 0)).toBe(true) + }) +}) diff --git a/src/wasm-mesher/worker/mesherWasmLightMerge.ts b/src/wasm-mesher/worker/mesherWasmLightMerge.ts new file mode 100644 index 0000000..2c5a1a5 --- /dev/null +++ b/src/wasm-mesher/worker/mesherWasmLightMerge.ts @@ -0,0 +1,222 @@ +/** 16³ unpacked light values per section. */ +export const LIGHT_BLOCKS_PER_SECTION = 4096 + +export type ParsedUpdateLight = { + x: number + z: number + trustEdges: boolean + numSections: number + skyLight: Uint8Array + blockLight: Uint8Array + skyLightMask: Uint32Array + emptySkyLightMask: Uint32Array + blockLightMask: Uint32Array + emptyBlockLightMask: Uint32Array + skyBelow?: Uint8Array + skyAbove?: Uint8Array + blockBelow?: Uint8Array + blockAbove?: Uint8Array +} + +export type UpdateLightColumnCache = { + numSections: number + trustEdges: boolean + skyLight: Uint8Array + blockLight: Uint8Array + skyPresent: Uint32Array + blockPresent: Uint32Array + skyBelow?: Uint8Array + skyAbove?: Uint8Array + blockBelow?: Uint8Array + blockAbove?: Uint8Array +} + +export function worldSectionMaskBit(sectionIndex: number): number { + return sectionIndex + 1 +} + +export function lightMaskWordCount(numSections: number): number { + const bits = numSections + 2 + return Math.max(2, Math.ceil(bits / 64) * 2) +} + +export function maskBitGet(mask: Uint32Array, i: number): boolean { + const longIdx = i >> 6 + const bitInLong = i & 63 + const pair = longIdx * 2 + if (pair + 1 >= mask.length) return false + const val = bitInLong < 32 ? mask[pair]! : mask[pair + 1]! + const bit = bitInLong & 31 + return ((val >>> bit) & 1) === 1 +} + +export function maskBitSet(mask: Uint32Array, i: number): void { + const longIdx = i >> 6 + const bitInLong = i & 63 + const pair = longIdx * 2 + if (pair + 1 >= mask.length) return + if (bitInLong < 32) mask[pair] = (mask[pair]! | (1 << bitInLong)) >>> 0 + else mask[pair + 1] = (mask[pair + 1]! | (1 << (bitInLong - 32))) >>> 0 +} + +export function isLightSectionPresent(present: Uint32Array, maskBit: number): boolean { + return maskBitGet(present, maskBit) +} + +export function createEmptyLightCache(numSections: number): UpdateLightColumnCache { + const worldBytes = numSections * LIGHT_BLOCKS_PER_SECTION + const words = lightMaskWordCount(numSections) + return { + numSections, + trustEdges: false, + skyLight: new Uint8Array(worldBytes), + blockLight: new Uint8Array(worldBytes), + skyPresent: new Uint32Array(words), + blockPresent: new Uint32Array(words) + } +} + +function copySection(dest: Uint8Array, src: Uint8Array, sectionIndex: number, value?: number) { + const start = sectionIndex * LIGHT_BLOCKS_PER_SECTION + const end = start + LIGHT_BLOCKS_PER_SECTION + if (value !== undefined) { + dest.fill(value, start, end) + return + } + if (src.length < end) return + dest.set(src.subarray(start, end), start) +} + +function mergePadding(hasData: boolean, incoming?: Uint8Array): Uint8Array { + if (hasData && incoming && incoming.length === LIGHT_BLOCKS_PER_SECTION) { + return new Uint8Array(incoming) + } + return new Uint8Array(LIGHT_BLOCKS_PER_SECTION) +} + +function mergeChannel( + destValues: Uint8Array, + destPresent: Uint32Array, + srcValues: Uint8Array, + dataMask: Uint32Array, + emptyMask: Uint32Array, + numSections: number, + destBelow: Uint8Array | undefined, + destAbove: Uint8Array | undefined, + incomingBelow: Uint8Array | undefined, + incomingAbove: Uint8Array | undefined +): { below?: Uint8Array; above?: Uint8Array } { + let below = destBelow + let above = destAbove + const totalBits = numSections + 2 + for (let bit = 0; bit < totalBits; bit++) { + const hasData = maskBitGet(dataMask, bit) + const hasEmpty = maskBitGet(emptyMask, bit) + if (!hasData && !hasEmpty) continue + + if (bit === 0) { + below = mergePadding(hasData, incomingBelow) + maskBitSet(destPresent, 0) + continue + } + if (bit === numSections + 1) { + above = mergePadding(hasData, incomingAbove) + maskBitSet(destPresent, bit) + continue + } + + const sectionIndex = bit - 1 + if (hasData) copySection(destValues, srcValues, sectionIndex) + else copySection(destValues, srcValues, sectionIndex, 0) + maskBitSet(destPresent, bit) + } + return { below, above } +} + +export function mergeUpdateLight(cache: UpdateLightColumnCache | undefined, parsed: ParsedUpdateLight): UpdateLightColumnCache { + const next = cache ? cloneLightCache(cache) : createEmptyLightCache(parsed.numSections) + next.trustEdges = parsed.trustEdges + next.numSections = parsed.numSections + + const skyPad = mergeChannel( + next.skyLight, + next.skyPresent, + parsed.skyLight, + parsed.skyLightMask, + parsed.emptySkyLightMask, + parsed.numSections, + next.skyBelow, + next.skyAbove, + parsed.skyBelow, + parsed.skyAbove + ) + next.skyBelow = skyPad.below + next.skyAbove = skyPad.above + + const blockPad = mergeChannel( + next.blockLight, + next.blockPresent, + parsed.blockLight, + parsed.blockLightMask, + parsed.emptyBlockLightMask, + parsed.numSections, + next.blockBelow, + next.blockAbove, + parsed.blockBelow, + parsed.blockAbove + ) + next.blockBelow = blockPad.below + next.blockAbove = blockPad.above + + return next +} + +export function cloneLightCache(cache: UpdateLightColumnCache): UpdateLightColumnCache { + return { + numSections: cache.numSections, + trustEdges: cache.trustEdges, + skyLight: new Uint8Array(cache.skyLight), + blockLight: new Uint8Array(cache.blockLight), + skyPresent: new Uint32Array(cache.skyPresent), + blockPresent: new Uint32Array(cache.blockPresent), + skyBelow: cache.skyBelow ? new Uint8Array(cache.skyBelow) : undefined, + skyAbove: cache.skyAbove ? new Uint8Array(cache.skyAbove) : undefined, + blockBelow: cache.blockBelow ? new Uint8Array(cache.blockBelow) : undefined, + blockAbove: cache.blockAbove ? new Uint8Array(cache.blockAbove) : undefined + } +} + +export function displayLightColumn(cache: UpdateLightColumnCache, channel: 'sky' | 'block', fill = channel === 'sky' ? 15 : 0): Uint8Array { + const src = channel === 'sky' ? cache.skyLight : cache.blockLight + const present = channel === 'sky' ? cache.skyPresent : cache.blockPresent + const out = new Uint8Array(cache.numSections * LIGHT_BLOCKS_PER_SECTION) + for (let s = 0; s < cache.numSections; s++) { + const start = s * LIGHT_BLOCKS_PER_SECTION + const end = start + LIGHT_BLOCKS_PER_SECTION + if (maskBitGet(present, worldSectionMaskBit(s))) { + out.set(src.subarray(start, end), start) + } else { + out.fill(fill, start, end) + } + } + return out +} + +export function parsedUpdateLightFromWasm(parsed: any, numSections: number): ParsedUpdateLight { + return { + x: parsed.x as number, + z: parsed.z as number, + trustEdges: Boolean(parsed.trustEdges), + numSections, + skyLight: parsed.skyLight as Uint8Array, + blockLight: parsed.blockLight as Uint8Array, + skyLightMask: (parsed.skyLightMask as Uint32Array) ?? new Uint32Array(2), + emptySkyLightMask: (parsed.emptySkyLightMask as Uint32Array) ?? new Uint32Array(2), + blockLightMask: (parsed.blockLightMask as Uint32Array) ?? new Uint32Array(2), + emptyBlockLightMask: (parsed.emptyBlockLightMask as Uint32Array) ?? new Uint32Array(2), + skyBelow: parsed.skyBelow as Uint8Array | undefined, + skyAbove: parsed.skyAbove as Uint8Array | undefined, + blockBelow: parsed.blockBelow as Uint8Array | undefined, + blockAbove: parsed.blockAbove as Uint8Array | undefined + } +} diff --git a/src/wasm-mesher/worker/mesherWasmOwnerLight.test.ts b/src/wasm-mesher/worker/mesherWasmOwnerLight.test.ts new file mode 100644 index 0000000..c20e35f --- /dev/null +++ b/src/wasm-mesher/worker/mesherWasmOwnerLight.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest' +import { createEmptyLightCache, isLightSectionPresent, worldSectionMaskBit, type ParsedUpdateLight } from './mesherWasmLightMerge' +import { + applyOwnerPublicationToColumnCaches, + applyPackedOwnerSectionsToLightCache, + applyRawLightPacketToCaches, + detachDisplayOnOwnerTakeover, + ownerDeltaSectionWorldYs, + revertDisplayToIncoming +} from './mesherWasmOwnerLight' +import { packUnpackedLightSection } from '../../mesher-shared/lightNibblePack' + +describe('applyPackedOwnerSectionsToLightCache', () => { + it('unpacks owner packed channels into the worker column cache without touching other sections', () => { + const cache = createEmptyLightCache(16) + const unpacked = new Uint8Array(4096).fill(0) + unpacked[0] = 9 + const packed = packUnpackedLightSection(unpacked) + const next = applyPackedOwnerSectionsToLightCache(cache, [{ sx: 0, sy: 64, sz: 0, blockLight: packed, skyLight: packed }], 0, 0, 0, 16) + expect(next.blockLight[4 * 4096]).toBe(9) + expect(next.skyLight[4 * 4096]).toBe(9) + expect(isLightSectionPresent(next.blockPresent, worldSectionMaskBit(4))).toBe(true) + expect(next.blockLight[0]).toBe(0) + expect(isLightSectionPresent(next.blockPresent, worldSectionMaskBit(0))).toBe(false) + }) + + it('writes a section delta in place without cloning the full column arrays', () => { + const cache = createEmptyLightCache(16) + cache.blockLight[0] = 4 + cache.skyLight[0] = 7 + const blockRef = cache.blockLight + const skyRef = cache.skyLight + const unpacked = new Uint8Array(4096).fill(0) + unpacked[0] = 9 + const packed = packUnpackedLightSection(unpacked) + const next = applyPackedOwnerSectionsToLightCache(cache, [{ sx: 0, sy: 64, sz: 0, blockLight: packed, skyLight: packed }], 0, 0, 0, 16) + expect(next.blockLight).toBe(blockRef) + expect(next.skyLight).toBe(skyRef) + expect(next.blockLight[0]).toBe(4) + expect(next.skyLight[0]).toBe(7) + expect(next.blockLight[4 * 4096]).toBe(9) + }) + + it('lists only the published section Y origins for a column', () => { + expect( + ownerDeltaSectionWorldYs( + [ + { sx: 0, sy: 64, sz: 0 }, + { sx: 16, sy: 64, sz: 0 }, + { sx: 0, sy: 80, sz: 0 } + ], + 0, + 0 + ) + ).toEqual([64, 80]) + }) +}) + +describe('applyRawLightPacketToCaches', () => { + function parsedBlock(sectionIndex: number, value: number): ParsedUpdateLight { + const blockLight = new Uint8Array(16 * 4096) + blockLight.fill(value, sectionIndex * 4096, (sectionIndex + 1) * 4096) + const blockLightMask = new Uint32Array(2) + blockLightMask[0] = 1 << (sectionIndex + 1) + return { + x: 0, + z: 0, + trustEdges: true, + numSections: 16, + skyLight: new Uint8Array(16 * 4096), + blockLight, + skyLightMask: new Uint32Array(2), + emptySkyLightMask: new Uint32Array(2), + blockLightMask, + emptyBlockLightMask: new Uint32Array(2) + } + } + + it('writes incoming server light into the display cache when the owner does not own the column', () => { + const result = applyRawLightPacketToCaches({ + ownerOwnsColumn: false, + incoming: undefined, + display: undefined, + parsed: parsedBlock(4, 9) + }) + expect(result.dirtyDisplay).toBe(true) + expect(result.display?.blockLight[4 * 4096]).toBe(9) + expect(result.incoming.blockLight[4 * 4096]).toBe(9) + }) + + it('keeps incoming server light separate and does not overwrite owner display cache', () => { + const display = createEmptyLightCache(16) + display.blockLight[4 * 4096] = 3 + const result = applyRawLightPacketToCaches({ + ownerOwnsColumn: true, + incoming: undefined, + display, + parsed: parsedBlock(4, 9) + }) + expect(result.dirtyDisplay).toBe(false) + expect(result.display?.blockLight[4 * 4096]).toBe(3) + expect(result.incoming.blockLight[4 * 4096]).toBe(9) + }) +}) + +describe('owner takeover incoming/display split', () => { + it('clones display once so an in-place owner write does not change incoming server light', () => { + const incoming = createEmptyLightCache(16) + incoming.blockLight[4 * 4096] = 9 + const aliased = detachDisplayOnOwnerTakeover({ incoming, display: incoming }) + expect(aliased.display).not.toBe(aliased.incoming) + + const unpacked = new Uint8Array(4096).fill(0) + unpacked[0] = 3 + const packed = packUnpackedLightSection(unpacked) + const applied = applyOwnerPublicationToColumnCaches({ + incoming: aliased.incoming, + display: aliased.display, + sections: [{ sx: 0, sy: 64, sz: 0, blockLight: packed }], + worldMinY: 0, + columnWorldX: 0, + columnWorldZ: 0, + numSections: 16 + }) + expect(applied.incoming?.blockLight[4 * 4096]).toBe(9) + expect(applied.display.blockLight[4 * 4096]).toBe(3) + expect(applied.display.blockLight).not.toBe(applied.incoming?.blockLight) + }) + + it('does not clone the full column on a second owner delta', () => { + const incoming = createEmptyLightCache(16) + incoming.blockLight[4 * 4096] = 9 + const first = detachDisplayOnOwnerTakeover({ incoming, display: incoming }) + const displayRef = first.display!.blockLight + const unpacked = new Uint8Array(4096).fill(0) + unpacked[0] = 3 + const packed = packUnpackedLightSection(unpacked) + const second = applyOwnerPublicationToColumnCaches({ + incoming: first.incoming, + display: first.display, + sections: [{ sx: 0, sy: 64, sz: 0, blockLight: packed }], + worldMinY: 0, + columnWorldX: 0, + columnWorldZ: 0, + numSections: 16 + }) + expect(second.display.blockLight).toBe(displayRef) + }) + + it('reverts display to last server incoming without zeroing omitted sections', () => { + const incoming = createEmptyLightCache(16) + incoming.blockLight[4 * 4096] = 9 + const display = createEmptyLightCache(16) + display.blockLight[4 * 4096] = 3 + const reverted = revertDisplayToIncoming({ incoming, display }) + expect(reverted.display?.blockLight[4 * 4096]).toBe(9) + expect(reverted.incoming?.blockLight[4 * 4096]).toBe(9) + expect(isLightSectionPresent(reverted.incoming!.blockPresent, worldSectionMaskBit(0))).toBe(false) + }) +}) diff --git a/src/wasm-mesher/worker/mesherWasmOwnerLight.ts b/src/wasm-mesher/worker/mesherWasmOwnerLight.ts new file mode 100644 index 0000000..44b2f4f --- /dev/null +++ b/src/wasm-mesher/worker/mesherWasmOwnerLight.ts @@ -0,0 +1,111 @@ +import { + cloneLightCache, + createEmptyLightCache, + maskBitSet, + mergeUpdateLight, + worldSectionMaskBit, + type ParsedUpdateLight, + type UpdateLightColumnCache +} from './mesherWasmLightMerge' +import { unpackPackedLightSection } from '../../mesher-shared/lightNibblePack' + +export type OwnerPackedSection = { + sx: number + sy: number + sz: number + blockLight: Uint8Array + skyLight?: Uint8Array +} + +/** World-origin Y of published sections that belong to this column. */ +export function ownerDeltaSectionWorldYs(sections: Array<{ sx: number; sy: number; sz: number }>, columnWorldX: number, columnWorldZ: number): number[] { + const colSx = Math.floor(columnWorldX / 16) * 16 + const colSz = Math.floor(columnWorldZ / 16) * 16 + const ys: number[] = [] + const seen = new Set() + for (const section of sections) { + if (section.sx !== colSx || section.sz !== colSz) continue + if (seen.has(section.sy)) continue + seen.add(section.sy) + ys.push(section.sy) + } + return ys +} + +/** Write owner packed 2048-byte channels into the worker's unpacked column cache. */ +export function applyPackedOwnerSectionsToLightCache( + cache: UpdateLightColumnCache | undefined, + sections: OwnerPackedSection[], + worldMinY: number, + columnWorldX: number, + columnWorldZ: number, + numSections: number +): UpdateLightColumnCache { + const next = cache ?? createEmptyLightCache(numSections) + const colSx = Math.floor(columnWorldX / 16) * 16 + const colSz = Math.floor(columnWorldZ / 16) * 16 + for (const section of sections) { + if (section.sx !== colSx || section.sz !== colSz) continue + const sectionIndex = Math.floor((section.sy - worldMinY) / 16) + if (sectionIndex < 0 || sectionIndex >= next.numSections) continue + const start = sectionIndex * 4096 + const block = unpackPackedLightSection(section.blockLight) + next.blockLight.set(block, start) + maskBitSet(next.blockPresent, worldSectionMaskBit(sectionIndex)) + if (section.skyLight) { + const sky = unpackPackedLightSection(section.skyLight) + next.skyLight.set(sky, start) + maskBitSet(next.skyPresent, worldSectionMaskBit(sectionIndex)) + } + } + return next +} + +export function applyRawLightPacketToCaches(opts: { + ownerOwnsColumn: boolean + incoming: UpdateLightColumnCache | undefined + display: UpdateLightColumnCache | undefined + parsed: ParsedUpdateLight +}): { incoming: UpdateLightColumnCache; display: UpdateLightColumnCache | undefined; dirtyDisplay: boolean } { + const incoming = mergeUpdateLight(opts.incoming, opts.parsed) + if (opts.ownerOwnsColumn) { + return { incoming, display: opts.display, dirtyDisplay: false } + } + return { incoming, display: incoming, dirtyDisplay: true } +} + +/** First owner takeover: clone incoming onto display once. Later deltas write display in place. */ +export function detachDisplayOnOwnerTakeover(opts: { incoming: UpdateLightColumnCache | undefined; display: UpdateLightColumnCache | undefined }): { + incoming: UpdateLightColumnCache | undefined + display: UpdateLightColumnCache | undefined +} { + if (!opts.incoming) return { incoming: undefined, display: opts.display } + if (opts.display == null || opts.display === opts.incoming) { + return { incoming: opts.incoming, display: cloneLightCache(opts.incoming) } + } + return { incoming: opts.incoming, display: opts.display } +} + +export function applyOwnerPublicationToColumnCaches(opts: { + incoming: UpdateLightColumnCache | undefined + display: UpdateLightColumnCache | undefined + sections: OwnerPackedSection[] + worldMinY: number + columnWorldX: number + columnWorldZ: number + numSections: number +}): { incoming: UpdateLightColumnCache | undefined; display: UpdateLightColumnCache } { + const detached = detachDisplayOnOwnerTakeover({ incoming: opts.incoming, display: opts.display }) + return { + incoming: detached.incoming, + display: applyPackedOwnerSectionsToLightCache(detached.display, opts.sections, opts.worldMinY, opts.columnWorldX, opts.columnWorldZ, opts.numSections) + } +} + +export function revertDisplayToIncoming(opts: { incoming: UpdateLightColumnCache | undefined; display: UpdateLightColumnCache | undefined }): { + incoming: UpdateLightColumnCache | undefined + display: UpdateLightColumnCache | undefined +} { + if (!opts.incoming) return { incoming: undefined, display: opts.display } + return { incoming: opts.incoming, display: cloneLightCache(opts.incoming) } +} diff --git a/src/wasm-mesher/worker/mesherWasmTickParseCache.ts b/src/wasm-mesher/worker/mesherWasmTickParseCache.ts new file mode 100644 index 0000000..47d5acc --- /dev/null +++ b/src/wasm-mesher/worker/mesherWasmTickParseCache.ts @@ -0,0 +1,44 @@ +/** + * Tick-scoped cache for WASM column parses in the two-step mesh path. + * + * `getOrConvertColumn` only covers the JS walk. The three WASM converters + * (`convertRawMapChunkToWasm` / `convertParsedV17ToWasm` / `convertParsedV16ToWasm`) + * used to re-parse the same neighbour for every dirty target column in a tick. + * This map lives for one `processColumnTick` and is dropped in `finally`. + * + * Same contract as `getOrConvertColumn`: cached values are `set()` sources / + * WASM inputs only. Consumers must not mutate them, so we do not copy. + */ + +let cache: Map | null = null +let hits = 0 +let misses = 0 + +export function beginTickWasmParseCache(): void { + cache = new Map() + hits = 0 + misses = 0 +} + +export function endTickWasmParseCache(): void { + cache = null +} + +export function getTickWasmParseStats(): { hits: number; misses: number } { + return { hits, misses } +} + +export function getOrConvertTickWasmParse(key: string, convert: () => T | null): { result: T | null; hit: boolean } { + if (!cache) { + misses++ + return { result: convert(), hit: false } + } + if (cache.has(key)) { + hits++ + return { result: cache.get(key) as T, hit: true } + } + const result = convert() + misses++ + if (result != null) cache.set(key, result) + return { result, hit: false } +} diff --git a/src/wasm-mesher/worker/mesherWasmTickSchedule.ts b/src/wasm-mesher/worker/mesherWasmTickSchedule.ts new file mode 100644 index 0000000..37379f4 --- /dev/null +++ b/src/wasm-mesher/worker/mesherWasmTickSchedule.ts @@ -0,0 +1,43 @@ +/** + * Mesh ticks. Bulk work is polled on an interval. An urgent dirty kicks the + * same runner on the next turn, and a kick during an in-progress tick reruns + * after that tick yields instead of waiting for the next poll. + */ + +export const BULK_MESH_INTERVAL_MS = 50 + +export function createMeshTickScheduler(run: () => Promise | void) { + let running = false + let rerun = false + let timer: ReturnType | null = null + + const pump = async () => { + timer = null + if (running) { + rerun = true + return + } + running = true + try { + do { + rerun = false + await run() + } while (rerun) + } finally { + running = false + } + } + + return { + kick() { + if (running) { + rerun = true + return + } + if (timer != null) return + timer = setTimeout(() => { + void pump() + }, 0) + } + } +} diff --git a/src/wasm-mesher/worker/mesherWasmTopologyPostCache.ts b/src/wasm-mesher/worker/mesherWasmTopologyPostCache.ts new file mode 100644 index 0000000..5c64721 --- /dev/null +++ b/src/wasm-mesher/worker/mesherWasmTopologyPostCache.ts @@ -0,0 +1,62 @@ +/** + * Topology-keyed cache for the per-section visGraph + block-entity post-pass. + * + * Wave-2 covering remeshes carry the same `topologyRevision` as wave-1, so the + * 4096-cell walk can be reused. A new revision always recomputes. Legacy / + * unversioned jobs (no revision) never cache. + */ + +export const TOPOLOGY_POST_CACHE_LIMIT = 1024 + +export type TopologyPostPayload = { + visibilitySet: unknown + signs: Record + heads: Record + banners: Record +} + +type CacheEntry = { + topologyRevision: number + payload: TopologyPostPayload +} + +const cache = new Map() + +export function getOrComputeTopologyPost( + key: string, + topologyRevision: number | undefined, + compute: () => T +): { payload: T; hit: boolean } { + if (topologyRevision == null) { + return { payload: compute(), hit: false } + } + const existing = cache.get(key) + if (existing && existing.topologyRevision === topologyRevision) { + cache.delete(key) + cache.set(key, existing) + return { payload: existing.payload as T, hit: true } + } + const payload = compute() + cache.set(key, { topologyRevision, payload }) + evictIfNeeded() + return { payload, hit: false } +} + +export function clearTopologyPostCacheColumn(x: number, z: number): void { + for (const key of [...cache.keys()]) { + const [sx, , sz] = key.split(',').map(Number) + if (sx === x && sz === z) cache.delete(key) + } +} + +export function clearTopologyPostCache(): void { + cache.clear() +} + +function evictIfNeeded(): void { + while (cache.size > TOPOLOGY_POST_CACHE_LIMIT) { + const oldest = cache.keys().next().value + if (oldest == null) break + cache.delete(oldest) + } +} diff --git a/src/wasm-mesher/worker/updateLight1171OwnerPath.test.ts b/src/wasm-mesher/worker/updateLight1171OwnerPath.test.ts new file mode 100644 index 0000000..ed6f60d --- /dev/null +++ b/src/wasm-mesher/worker/updateLight1171OwnerPath.test.ts @@ -0,0 +1,67 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { RendererLightCache } from '../../three/rendererLightCache' +import { applyLightPublication } from '../../three/lightOwnerHost' +import { isLightSectionPresent, mergeUpdateLight, parsedUpdateLightFromWasm, worldSectionMaskBit } from './mesherWasmLightMerge' + +const NUM_SECTIONS = 16 + +function packedNibble(value: number): Uint8Array { + const n = value & 0x0f + return new Uint8Array(2048).fill(n | (n << 4)) +} + +describe('1.17.1 fixture owner path (scenario б)', () => { + it('keeps an omitted neighbor after the captured empty-block packet', async () => { + const wasmDir = dirname(fileURLToPath(import.meta.url)) + const wasmModule = await import('../runtime-build/wasm_mesher.js') + const wasmBytes = readFileSync(join(wasmDir, '../runtime-build/wasm_mesher_bg.wasm')) + try { + wasmModule.initSync(wasmBytes) + } catch { + // already initialized + } + + const fixture = readFileSync(join(wasmDir, '../../../chunk-packet-fixtures/fixtures/map_chunk/1.17.1/5_0.update_light.bin')) + const parsed = parsedUpdateLightFromWasm((wasmModule as any).parseUpdateLightV17(fixture, NUM_SECTIONS), NUM_SECTIONS) + expect(parsed.x).toBe(7) + expect(parsed.z).toBe(0) + + const seed = mergeUpdateLight(undefined, { + ...parsed, + blockLightMask: new Uint32Array([1 << 7, 0]), + emptyBlockLightMask: new Uint32Array(2), + blockLight: (() => { + const column = new Uint8Array(NUM_SECTIONS * 4096) + column.fill(7, 6 * 4096, 7 * 4096) + return column + })() + }) + expect(isLightSectionPresent(seed.blockPresent, worldSectionMaskBit(6))).toBe(true) + + const merged = mergeUpdateLight(seed, parsed) + expect(isLightSectionPresent(merged.blockPresent, worldSectionMaskBit(6))).toBe(true) + expect(merged.blockLight[6 * 4096]).toBe(7) + expect(isLightSectionPresent(merged.blockPresent, worldSectionMaskBit(0))).toBe(true) + expect(merged.blockLight[0]).toBe(0) + + const cache = new RendererLightCache('1.17.1') + cache.setWorldBounds(0, 256) + applyLightPublication( + cache, + { + worldGeneration: 1, + publicationVersion: 1, + sections: [ + { sx: 112, sy: 96, sz: 0, blockLight: packedNibble(7) }, + { sx: 112, sy: 0, sz: 0, blockLight: packedNibble(0) } + ] + }, + { acceptedGeneration: 1, lastVersion: 0 } + ) + expect(cache.getLight(112, 96, 0).block).toBeCloseTo(9 / 15, 5) + expect(cache.getLight(112, 0, 0).block).toBeCloseTo(2 / 15, 5) + }) +}) diff --git a/src/wasm-mesher/worker/updateLightToOwnerEvents.ts b/src/wasm-mesher/worker/updateLightToOwnerEvents.ts new file mode 100644 index 0000000..db0edf3 --- /dev/null +++ b/src/wasm-mesher/worker/updateLightToOwnerEvents.ts @@ -0,0 +1,47 @@ +import { maskBitGet, parsedUpdateLightFromWasm, type ParsedUpdateLight } from './mesherWasmLightMerge' +import { packUnpackedLightSection } from '../../mesher-shared/lightNibblePack' +import type { LightOwnerEvent } from '../../three/lightOwnerHost' + +export function eventsFromParsedUpdateLight(parsed: ParsedUpdateLight, worldMinY: number): LightOwnerEvent[] { + const sx = parsed.x + const sz = parsed.z + const baseSy = Math.floor(worldMinY / 16) + const events: LightOwnerEvent[] = [] + const pushChannel = (channel: 'block' | 'sky', dataMask: Uint32Array, emptyMask: Uint32Array, column: Uint8Array, below?: Uint8Array, above?: Uint8Array) => { + const totalBits = parsed.numSections + 2 + for (let bit = 0; bit < totalBits; bit++) { + const hasData = maskBitGet(dataMask, bit) + const hasEmpty = maskBitGet(emptyMask, bit) + if (!hasData && !hasEmpty) continue + const sy = baseSy + bit - 1 + if (hasEmpty) { + events.push({ type: 'serverLight', sx, sy, sz, channel, kind: 'empty' }) + continue + } + let unpacked: Uint8Array | undefined + if (bit === 0) unpacked = below + else if (bit === parsed.numSections + 1) unpacked = above + else unpacked = column.subarray((bit - 1) * 4096, bit * 4096) + if (!unpacked || unpacked.length < 4096) { + events.push({ type: 'serverLight', sx, sy, sz, channel, kind: 'empty' }) + continue + } + events.push({ + type: 'serverLight', + sx, + sy, + sz, + channel, + kind: 'data', + data: packUnpackedLightSection(unpacked) + }) + } + } + pushChannel('block', parsed.blockLightMask, parsed.emptyBlockLightMask, parsed.blockLight, parsed.blockBelow, parsed.blockAbove) + pushChannel('sky', parsed.skyLightMask, parsed.emptySkyLightMask, parsed.skyLight, parsed.skyBelow, parsed.skyAbove) + return events +} + +export function eventsFromWasmUpdateLight(parsedJs: unknown, numSections: number, worldMinY: number): LightOwnerEvent[] { + return eventsFromParsedUpdateLight(parsedUpdateLightFromWasm(parsedJs, numSections), worldMinY) +} diff --git a/wasm-mesher/src/chunk_parser_common.rs b/wasm-mesher/src/chunk_parser_common.rs index ba23d03..1ccebb6 100644 --- a/wasm-mesher/src/chunk_parser_common.rs +++ b/wasm-mesher/src/chunk_parser_common.rs @@ -337,37 +337,94 @@ pub fn assemble_light_full_column( if !in_chunk { continue; } let s = section_idx_in_chunk as usize; let base = s * 16 * 256; - // BitArrayNoSpan stores 16 nibbles per i64. For 1.18+ map_chunk and - // for 1.16 the wire writes those longs as 8 big-endian bytes — so - // within each 8-byte run, byte offset 0 holds nibble positions 14-15 - // and byte offset 7 holds positions 0-1 (long_encoded = true). For - // 1.17 update_light the wire payload is the BitArrayNoSpan internal - // byte buffer in *natural* order: byte i holds blocks 2i (low nibble) - // and 2i+1 (high nibble) directly (long_encoded = false). Mismatching - // this produces X-mirrored light bands inside every Z-row, which the - // user sees as very dark shadows under trees / on slopes. - for byte_idx in 0..LIGHT_SECTION_BUFFER_BYTES { - let byte = section[byte_idx]; - let v0 = byte & 0x0F; - let v1 = (byte >> 4) & 0x0F; - let block0 = if long_encoded { - let long_idx = byte_idx >> 3; - let off_in_long = byte_idx & 0x7; - let pair_pos = 7 - off_in_long; // BE byte → nibble-pair position inside long - long_idx * 16 + pair_pos * 2 - } else { - byte_idx * 2 - }; - let block1 = block0 + 1; - for (block_local, value) in [(block0, v0), (block1, v1)] { - let y_in = block_local >> 8; - let z = (block_local >> 4) & 0xF; - let x = block_local & 0xF; - out[base + y_in * 256 + z * 16 + x] = value; + unpack_light_section_into(section, &mut out[base..base + BLOCK_SECTION_VOLUME], long_encoded); + } + Ok(out) +} + +/// One unpacked 16³ light section plus optional ±1 padding sections. +/// Omitted (no data bit, no empty bit) world bytes stay 0 and are not authoritative — +/// callers must keep the masks to distinguish absence from a real zero. +#[derive(Debug)] +pub struct AssembledLightWithPadding { + pub world: Vec, + pub below: Option>, + pub above: Option>, +} + +/// Assemble `update_light` arrays without baking a display default into omitted sections. +pub fn assemble_light_with_padding( + sections_concat: &[u8], + mask: &[u32], + empty_mask: &[u32], + num_sections: usize, + long_encoded: bool, +) -> io::Result { + let mut world = vec![0u8; num_sections * BLOCK_SECTION_VOLUME]; + let mut below = None; + let mut above = None; + let mut data_cursor = 0usize; + let total_bits = num_sections + 2; + + for mask_bit in 0..total_bits { + let is_present = mask_bit_get(mask, mask_bit); + let is_empty = mask_bit_get(empty_mask, mask_bit); + if !is_present && !is_empty { + continue; + } + + let unpacked = if is_empty { + vec![0u8; BLOCK_SECTION_VOLUME] + } else { + if data_cursor + LIGHT_SECTION_BUFFER_BYTES > sections_concat.len() { + return Err(io::Error::new(io::ErrorKind::InvalidData, + format!("light data underflow at mask_bit={} cursor={} len={}", + mask_bit, data_cursor, sections_concat.len()))); } + let section = §ions_concat[data_cursor..data_cursor + LIGHT_SECTION_BUFFER_BYTES]; + data_cursor += LIGHT_SECTION_BUFFER_BYTES; + let mut dest = vec![0u8; BLOCK_SECTION_VOLUME]; + unpack_light_section_into(section, &mut dest, long_encoded); + dest + }; + + if mask_bit == 0 { + below = Some(unpacked); + continue; + } + if mask_bit == num_sections + 1 { + above = Some(unpacked); + continue; + } + let s = mask_bit - 1; + let base = s * BLOCK_SECTION_VOLUME; + world[base..base + BLOCK_SECTION_VOLUME].copy_from_slice(&unpacked); + } + + Ok(AssembledLightWithPadding { world, below, above }) +} + +fn unpack_light_section_into(section: &[u8], dest: &mut [u8], long_encoded: bool) { + for byte_idx in 0..LIGHT_SECTION_BUFFER_BYTES { + let byte = section[byte_idx]; + let v0 = byte & 0x0F; + let v1 = (byte >> 4) & 0x0F; + let block0 = if long_encoded { + let long_idx = byte_idx >> 3; + let off_in_long = byte_idx & 0x7; + let pair_pos = 7 - off_in_long; + long_idx * 16 + pair_pos * 2 + } else { + byte_idx * 2 + }; + let block1 = block0 + 1; + for (block_local, value) in [(block0, v0), (block1, v1)] { + let y_in = block_local >> 8; + let z = (block_local >> 4) & 0xF; + let x = block_local & 0xF; + dest[y_in * 256 + z * 16 + x] = value; } } - Ok(out) } /// Unpack a single light section (2048 bytes = BitArrayNoSpan bpv=4 capacity=4096). @@ -606,6 +663,29 @@ pub fn build_full_column_light( assemble_light_full_column(&concat, &mask_pairs, &empty_pairs, num_sections, default_value, long_encoded) } +/// Like `build_full_column_light`, but omitted sections stay 0 and padding is kept. +pub fn build_light_with_padding( + section_buffers: &[Vec], + mask: &[i64], + empty_mask: &[i64], + num_sections: usize, + long_encoded: bool, +) -> io::Result { + let mask_pairs = i64_mask_to_u32_pairs(mask); + let empty_pairs = i64_mask_to_u32_pairs(empty_mask); + + let mut concat = Vec::with_capacity(section_buffers.len() * LIGHT_SECTION_BUFFER_BYTES); + for buf in section_buffers { + if buf.len() != LIGHT_SECTION_BUFFER_BYTES { + return Err(io::Error::new(io::ErrorKind::InvalidData, + format!("light buffer size {} != {}", buf.len(), LIGHT_SECTION_BUFFER_BYTES))); + } + concat.extend_from_slice(buf); + } + + assemble_light_with_padding(&concat, &mask_pairs, &empty_pairs, num_sections, long_encoded) +} + #[cfg(test)] mod tests { use super::*; diff --git a/wasm-mesher/src/lib.rs b/wasm-mesher/src/lib.rs index febcc55..fd0519e 100644 --- a/wasm-mesher/src/lib.rs +++ b/wasm-mesher/src/lib.rs @@ -5,6 +5,7 @@ mod chunk; mod chunk_parser_common; mod dump_parser; mod geometry; +mod light_engine; mod lighting; mod mesher; mod parser_v18plus; @@ -1135,11 +1136,11 @@ fn add_wireframe_edge( /// `raw_packet` includes the leading packet-id varint (we skip it). /// `num_sections` should match the column the light is for (16 in 1.17). /// -/// Returns `{ x, z, skyLight: Uint8Array(num_sections * 4096), -/// blockLight: Uint8Array(num_sections * 4096), bytesRead }`. -/// Layout matches the existing 1.18+ light arrays -/// (`x + z*16 + y_abs*256`); the JS-side worker reorders into per-section -/// stack via the same path used for 1.18+ raw map_chunk parsing. +/// Returns `{ x, z, trustEdges, skyLight, blockLight, skyLightMask, +/// emptySkyLightMask, blockLightMask, emptyBlockLightMask, +/// skyBelow?, skyAbove?, blockBelow?, blockAbove?, bytesRead }`. +/// World arrays are `num_sections * 4096` (`x + z*16 + y_abs*256`). +/// Omitted sections are left 0 and are not authoritative — use the masks. #[wasm_bindgen(js_name = parseUpdateLightV17)] pub fn parse_update_light_v17_js( raw_packet: &[u8], @@ -1157,13 +1158,36 @@ pub fn parse_update_light_v17_js( block_view.copy_from(&result.block_light); js_sys::Reflect::set(&obj, &JsValue::from_str("x"), &JsValue::from_f64(result.x as f64)).unwrap(); js_sys::Reflect::set(&obj, &JsValue::from_str("z"), &JsValue::from_f64(result.z as f64)).unwrap(); + js_sys::Reflect::set(&obj, &JsValue::from_str("trustEdges"), &JsValue::from_bool(result.trust_edges)).unwrap(); js_sys::Reflect::set(&obj, &JsValue::from_str("skyLight"), &sky_view).unwrap(); js_sys::Reflect::set(&obj, &JsValue::from_str("blockLight"), &block_view).unwrap(); + set_u32_array(&obj, "skyLightMask", &result.sky_light_mask); + set_u32_array(&obj, "emptySkyLightMask", &result.empty_sky_light_mask); + set_u32_array(&obj, "blockLightMask", &result.block_light_mask); + set_u32_array(&obj, "emptyBlockLightMask", &result.empty_block_light_mask); + set_optional_u8_array(&obj, "skyBelow", result.sky_below.as_deref()); + set_optional_u8_array(&obj, "skyAbove", result.sky_above.as_deref()); + set_optional_u8_array(&obj, "blockBelow", result.block_below.as_deref()); + set_optional_u8_array(&obj, "blockAbove", result.block_above.as_deref()); js_sys::Reflect::set(&obj, &JsValue::from_str("bytesRead"), &JsValue::from_f64(result.bytes_read as f64)).unwrap(); obj.into() } +fn set_u32_array(obj: &js_sys::Object, key: &str, values: &[u32]) { + let view = js_sys::Uint32Array::new_with_length(values.len() as u32); + view.copy_from(values); + js_sys::Reflect::set(obj, &JsValue::from_str(key), &view).unwrap(); +} + +fn set_optional_u8_array(obj: &js_sys::Object, key: &str, values: Option<&[u8]>) { + if let Some(values) = values { + let view = js_sys::Uint8Array::new_with_length(values.len() as u32); + view.copy_from(values); + js_sys::Reflect::set(obj, &JsValue::from_str(key), &view).unwrap(); + } +} + // --------------------------------------------------------------------------- // Round-trip tests: fused path vs split path (parse → mesh in two steps). // Verifies that the parser→mesher integration inside each fused function diff --git a/wasm-mesher/src/light_engine.rs b/wasm-mesher/src/light_engine.rs new file mode 100644 index 0000000..a8e06e0 --- /dev/null +++ b/wasm-mesher/src/light_engine.rs @@ -0,0 +1,3430 @@ +//! Owned block+sky light engine for the dedicated light-worker. +//! +//! ABI: `push_event` → `step(budget_ms)` → `poll_completed_publication`. +//! Publications are versioned and copy-out packed 2048-byte channels. +//! `sky=15` is never written as an authoritative seed. +//! Sky and block are independent channels. Sky sources skip UP. + +use std::collections::{HashMap, HashSet, VecDeque}; + +use wasm_bindgen::prelude::*; + +use crate::chunk_parser_common::{BLOCK_SECTION_VOLUME, LIGHT_SECTION_BUFFER_BYTES}; + +pub const AIR: u16 = 0; +#[cfg(test)] +pub const STONE: u16 = 1; +#[cfg(test)] +pub const TORCH: u16 = 2; +#[cfg(test)] +pub const WATER: u16 = 3; +#[cfg(test)] +pub const LEAVES: u16 = 4; +#[cfg(test)] +pub const SLAB_BOTTOM: u16 = 5; +#[cfg(test)] +pub const SLAB_TOP: u16 = 6; +#[cfg(test)] +pub const SLAB_DOUBLE: u16 = 7; +#[cfg(test)] +pub const STAIR: u16 = 8; +#[cfg(test)] +pub const GLASS: u16 = 9; + +const DIRS: [(i32, i32, i32); 6] = [ + (1, 0, 0), + (-1, 0, 0), + (0, 1, 0), + (0, -1, 0), + (0, 0, 1), + (0, 0, -1), +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SectionAvailability { + Unloaded, + Loaded, + LightOnly, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LightChannel { + Block, + Sky, +} + +#[derive(Clone, Debug)] +pub enum ServerLightKind { + Omitted, + Empty, + Data(Vec), +} + +#[derive(Clone, Debug)] +pub enum LightEvent { + IngestBlockSection { + sx: i32, + sy: i32, + sz: i32, + states: Vec, + }, + SetAvailability { + sx: i32, + sy: i32, + sz: i32, + availability: SectionAvailability, + }, + ServerLight { + sx: i32, + sy: i32, + sz: i32, + channel: LightChannel, + kind: ServerLightKind, + }, + BlockChange { + x: i32, + y: i32, + z: i32, + state_id: u16, + }, + UnloadColumn { + sx: i32, + sz: i32, + }, +} + +#[derive(Clone, Debug)] +pub struct PublishedSection { + pub sx: i32, + pub sy: i32, + pub sz: i32, + pub block_light: Vec, + pub sky_light: Option>, +} + +#[derive(Clone, Debug)] +pub struct LightPublication { + pub world_generation: u64, + pub publication_version: u64, + pub sections: Vec, +} + +#[derive(Clone)] +struct Section { + availability: SectionAvailability, + states: Option>, + /// Solver working block light (packed 2048). + block_light: Vec, + sky_light: Vec, + /// Last accepted server block-light payload (omitted leaves this as-is). + accepted_block: Option>, + accepted_sky: Option>, +} + +impl Section { + fn new(availability: SectionAvailability) -> Self { + Self { + availability, + states: None, + block_light: vec![0; LIGHT_SECTION_BUFFER_BYTES], + sky_light: vec![0; LIGHT_SECTION_BUFFER_BYTES], + accepted_block: None, + accepted_sky: None, + } + } +} + +type SectionKey = (i32, i32, i32); + +pub struct LightEngine { + world_min_y: i32, + world_height: i32, + world_generation: u64, + publication_version: u64, + emission: Vec, + /// Raw vanilla getLightBlock per stateId. Decay uses max(1, lightBlock). + /// Sky *source* stops when lightBlock != 0 (water/leaves), not when decay opacity is 1. + light_block: Vec, + /// 2×2×2 occupancy (bit = x + 2*z + 4*y). Empty unless canOcclude && useShapeForLightOcclusion. + occupancy: Vec, + /// Overworld default: compute sky. Nether/end turn this off and leave sky uncomputed. + sky_light_enabled: bool, + sections: HashMap, + pending: VecDeque, + decrease_q: VecDeque<(i32, i32, i32, u8)>, + increase_q: VecDeque<(i32, i32, i32, u8)>, + /// Sky queues are independent of block light. `bool` is skip-up (vanilla sky source). + sky_decrease_q: VecDeque<(i32, i32, i32, u8, bool)>, + sky_increase_q: VecDeque<(i32, i32, i32, u8, bool)>, + /// Publication set only — never a solver guard and never "skip seed". + changed_for_publication: HashSet, + changed_sky: HashSet, + check_dedup: HashSet<(i32, i32, i32)>, + sky_check_dedup: HashSet<(i32, i32, i32)>, + transaction_open: bool, + ready_publication: Option, + /// Events remaining in the current transaction's snapshot. New pushes stay for the next batch. + waiting_this_batch: usize, + pending_sky_xz: VecDeque<(i32, i32)>, + pending_sky_xz_set: HashSet<(i32, i32)>, + /// Derived sky-source lowest Y per world (x,z). Not a cell map and not a mirror world. + sky_source_lowest: HashMap<(i32, i32), i32>, + #[cfg(test)] + sky_column_updates: u32, + #[cfg(test)] + sky_source_scan_rows: std::cell::Cell, +} + +impl LightEngine { + pub fn new(world_min_y: i32, world_height: i32) -> Self { + Self { + world_min_y, + world_height, + world_generation: 1, + publication_version: 0, + emission: Vec::new(), + light_block: Vec::new(), + occupancy: Vec::new(), + sky_light_enabled: true, + sections: HashMap::new(), + pending: VecDeque::new(), + decrease_q: VecDeque::new(), + increase_q: VecDeque::new(), + sky_decrease_q: VecDeque::new(), + sky_increase_q: VecDeque::new(), + changed_for_publication: HashSet::new(), + changed_sky: HashSet::new(), + check_dedup: HashSet::new(), + sky_check_dedup: HashSet::new(), + transaction_open: false, + ready_publication: None, + waiting_this_batch: 0, + pending_sky_xz: VecDeque::new(), + pending_sky_xz_set: HashSet::new(), + sky_source_lowest: HashMap::new(), + #[cfg(test)] + sky_column_updates: 0, + #[cfg(test)] + sky_source_scan_rows: std::cell::Cell::new(0), + } + } + + pub fn set_light_tables(&mut self, emission: &[u8], light_block: &[u8]) { + self.emission = emission.to_vec(); + self.light_block = light_block.to_vec(); + } + + pub fn set_occlusion_table(&mut self, occupancy: &[u8]) { + self.occupancy = occupancy.to_vec(); + } + + /// Dimensions without sky light (nether/end) leave the sky channel uncomputed. + pub fn set_sky_light_enabled(&mut self, enabled: bool) { + if self.sky_light_enabled != enabled { + self.sky_source_lowest.clear(); + } + self.sky_light_enabled = enabled; + } + + pub fn push_event(&mut self, event: LightEvent) { + self.pending.push_back(event); + } + + /// Process pending events and decrease-first queues until `budget_ms` elapses. + /// Returns true if work remains. Does not publish a half-finished transaction. + pub fn step(&mut self, budget_ms: f64) -> bool { + let start = now_ms(); + if self.drain_waiting_events(start, Some(budget_ms)) { + return true; + } + + let has_queue = self.has_queue_work(); + if budget_ms <= 0.0 && has_queue { + return true; + } + + while let Some((x, y, z, level)) = self.decrease_q.pop_front() { + self.propagate_decrease(x, y, z, level); + if now_ms() - start >= budget_ms { + return true; + } + } + while let Some((x, y, z, level, skip_up)) = self.sky_decrease_q.pop_front() { + self.propagate_sky_decrease(x, y, z, level, skip_up); + if now_ms() - start >= budget_ms { + return true; + } + } + while let Some((x, y, z, level)) = self.increase_q.pop_front() { + self.propagate_increase(x, y, z, level); + if now_ms() - start >= budget_ms { + return true; + } + } + while let Some((x, y, z, level, skip_up)) = self.sky_increase_q.pop_front() { + self.propagate_sky_increase(x, y, z, level, skip_up); + if now_ms() - start >= budget_ms { + return true; + } + } + + self.finish_transaction_if_idle(); + self.has_remaining_work() + } + + /// Deterministic slice: apply events, then at most `max_nodes` queue pops. + /// `max_nodes == 0` applies events only. Does not use wall-clock time. + pub fn step_nodes(&mut self, max_nodes: usize) -> bool { + let start = now_ms(); + let _ = self.drain_waiting_events(start, None); + let mut left = max_nodes; + while left > 0 { + if let Some((x, y, z, level)) = self.decrease_q.pop_front() { + self.propagate_decrease(x, y, z, level); + left -= 1; + continue; + } + if let Some((x, y, z, level, skip_up)) = self.sky_decrease_q.pop_front() { + self.propagate_sky_decrease(x, y, z, level, skip_up); + left -= 1; + continue; + } + if let Some((x, y, z, level)) = self.increase_q.pop_front() { + self.propagate_increase(x, y, z, level); + left -= 1; + continue; + } + if let Some((x, y, z, level, skip_up)) = self.sky_increase_q.pop_front() { + self.propagate_sky_increase(x, y, z, level, skip_up); + left -= 1; + continue; + } + break; + } + self.finish_transaction_if_idle(); + self.has_remaining_work() + } + + fn has_queue_work(&self) -> bool { + !self.decrease_q.is_empty() + || !self.increase_q.is_empty() + || !self.sky_decrease_q.is_empty() + || !self.sky_increase_q.is_empty() + } + + fn has_remaining_work(&self) -> bool { + !self.pending.is_empty() || self.prep_in_progress() || self.has_queue_work() + } + + fn finish_transaction_if_idle(&mut self) { + if self.transaction_open && !self.has_queue_work() && !self.prep_in_progress() { + self.publish_completed(); + self.transaction_open = false; + self.check_dedup.clear(); + self.sky_check_dedup.clear(); + } + } + + fn prep_in_progress(&self) -> bool { + self.waiting_this_batch > 0 || !self.pending_sky_xz.is_empty() + } + + fn batch_in_progress(&self) -> bool { + self.prep_in_progress() || self.has_queue_work() + } + + pub fn poll_completed_publication(&mut self) -> Option { + self.ready_publication.take() + } + + pub fn get_block_light(&self, x: i32, y: i32, z: i32) -> u8 { + self.get_channel(x, y, z, LightChannel::Block) + } + + pub fn get_sky_light(&self, x: i32, y: i32, z: i32) -> u8 { + self.get_channel(x, y, z, LightChannel::Sky) + } + + pub fn section_availability(&self, sx: i32, sy: i32, sz: i32) -> SectionAvailability { + self.sections + .get(&(sx, sy, sz)) + .map(|s| s.availability) + .unwrap_or(SectionAvailability::Unloaded) + } + + pub fn packed_block_light(&self, sx: i32, sy: i32, sz: i32) -> Option<&[u8]> { + self.sections.get(&(sx, sy, sz)).map(|s| s.block_light.as_slice()) + } + + fn drain_waiting_events(&mut self, start: f64, budget_ms: Option) -> bool { + self.begin_batch_if_needed(); + while self.waiting_this_batch > 0 { + if let Some(event) = self.pending.pop_front() { + self.apply_event(event); + } + self.waiting_this_batch = self.waiting_this_batch.saturating_sub(1); + if let Some(budget) = budget_ms { + if now_ms() - start >= budget { + return true; + } + } + } + self.flush_pending_sky_columns(start, budget_ms) + } + + fn begin_batch_if_needed(&mut self) { + if self.batch_in_progress() { + return; + } + self.check_dedup.clear(); + self.sky_check_dedup.clear(); + let known_columns: HashSet<(i32, i32)> = + self.sections.keys().map(|&(sx, _, sz)| (sx, sz)).collect(); + promote_edit_column(&mut self.pending, |sx, sz| known_columns.contains(&(sx, sz))); + self.waiting_this_batch = column_admission_len(&self.pending); + } + + fn invalidate_sky_source_xz(&mut self, x: i32, z: i32) { + self.sky_source_lowest.remove(&(x, z)); + } + + fn invalidate_sky_source_section(&mut self, sx: i32, sz: i32) { + for lz in 0..16 { + for lx in 0..16 { + self.invalidate_sky_source_xz(sx * 16 + lx as i32, sz * 16 + lz as i32); + } + } + } + + fn sky_source_lowest_y(&mut self, x: i32, z: i32) -> i32 { + if let Some(&cached) = self.sky_source_lowest.get(&(x, z)) { + return cached; + } + let computed = self.lowest_source_y(x, z); + self.sky_source_lowest.insert((x, z), computed); + computed + } + + fn mark_sky_column_dirty(&mut self, x: i32, z: i32) { + if !self.sky_light_enabled { + return; + } + if self.pending_sky_xz_set.insert((x, z)) { + self.pending_sky_xz.push_back((x, z)); + } + } + + fn mark_sky_columns_in_section(&mut self, sx: i32, sz: i32) { + for lz in 0..16 { + for lx in 0..16 { + self.mark_sky_column_dirty(sx * 16 + lx as i32, sz * 16 + lz as i32); + } + } + } + + fn flush_pending_sky_columns(&mut self, start: f64, budget_ms: Option) -> bool { + while let Some((x, z)) = self.pending_sky_xz.pop_front() { + self.pending_sky_xz_set.remove(&(x, z)); + self.update_sky_column(x, z); + if let Some(budget) = budget_ms { + if now_ms() - start >= budget { + return true; + } + } + } + false + } + + fn apply_event(&mut self, event: LightEvent) { + match event { + LightEvent::IngestBlockSection { sx, sy, sz, states } => { + { + let section = self.ensure_section(sx, sy, sz, SectionAvailability::Loaded); + section.states = Some(pad_states(states)); + section.availability = SectionAvailability::Loaded; + } + for ly in 0..16 { + for lz in 0..16 { + for lx in 0..16 { + let x = sx * 16 + lx as i32; + let y = sy * 16 + ly as i32; + let z = sz * 16 + lz as i32; + if self.emission_at(x, y, z) > 0 || self.get_block_light(x, y, z) > 0 { + self.check_loaded_cell(x, y, z); + } + } + } + } + self.invalidate_sky_source_section(sx, sz); + self.wake_section_face_contacts(sx, sy, sz); + if self.sky_light_enabled { + self.mark_sky_columns_in_section(sx, sz); + } + self.mark_changed(sx, sy, sz); + } + LightEvent::SetAvailability { + sx, + sy, + sz, + availability, + } => { + if availability == SectionAvailability::Unloaded { + self.unload_section(sx, sy, sz); + return; + } + self.invalidate_sky_source_section(sx, sz); + self.ensure_section(sx, sy, sz, availability).availability = availability; + self.mark_changed(sx, sy, sz); + } + LightEvent::ServerLight { + sx, + sy, + sz, + channel, + kind, + } => self.apply_server_light(sx, sy, sz, channel, kind), + LightEvent::BlockChange { x, y, z, state_id } => { + self.apply_block_change(x, y, z, state_id); + } + LightEvent::UnloadColumn { sx, sz } => { + self.invalidate_sky_source_section(sx, sz); + let keys: Vec = self + .sections + .keys() + .copied() + .filter(|key| key.0 == sx && key.2 == sz) + .collect(); + for (usx, usy, usz) in &keys { + self.reconcile_unloading_section(*usx, *usy, *usz); + } + for (usx, usy, usz) in keys { + self.remove_section_storage(usx, usy, usz); + } + self.world_generation = self.world_generation.saturating_add(1); + self.mark_transaction(); + } + } + } + + fn apply_block_change(&mut self, x: i32, y: i32, z: i32, state_id: u16) { + if !self.in_world(x, y, z) { + return; + } + let (sx, sy, sz) = section_key(x, y, z); + let old_light = self.get_block_light(x, y, z); + { + let Some(section) = self.sections.get_mut(&(sx, sy, sz)) else { + return; + }; + let Some(states) = section.states.as_mut() else { + return; + }; + states[block_index(x, y, z)] = state_id; + } + let emission = self.emission_of(state_id); + self.invalidate_sky_source_xz(x, z); + self.mark_changed(sx, sy, sz); + + if emission < old_light { + self.set_block_light(x, y, z, 0); + self.decrease_q.push_back((x, y, z, old_light)); + if emission > 0 { + self.increase_q.push_back((x, y, z, emission)); + } + } else if emission > old_light { + self.set_block_light(x, y, z, emission); + self.increase_q.push_back((x, y, z, emission)); + } else if emission > 0 { + self.set_block_light(x, y, z, emission); + self.increase_q.push_back((x, y, z, emission)); + } else if old_light == 0 && self.block_neighbor_level(x, y, z) > 1 { + // Same pull as sky: opening a path (old=emission=0) re-spreads from a lit neighbor. + self.decrease_q.push_back((x, y, z, 1)); + } + self.mark_sky_column_dirty(x, z); + } + + fn apply_server_light(&mut self, sx: i32, sy: i32, sz: i32, channel: LightChannel, kind: ServerLightKind) { + match kind { + ServerLightKind::Omitted => {} + ServerLightKind::Empty => { + let zeros = vec![0u8; LIGHT_SECTION_BUFFER_BYTES]; + self.accept_and_reconcile_server(sx, sy, sz, channel, &zeros); + } + ServerLightKind::Data(data) => { + if data.len() != LIGHT_SECTION_BUFFER_BYTES { + return; + } + self.accept_and_reconcile_server(sx, sy, sz, channel, &data); + } + } + } + + fn accept_and_reconcile_server(&mut self, sx: i32, sy: i32, sz: i32, channel: LightChannel, packed: &[u8]) { + let availability = self.section_availability(sx, sy, sz); + { + let section = self.ensure_section( + sx, + sy, + sz, + if availability == SectionAvailability::Unloaded { + SectionAvailability::LightOnly + } else { + availability + }, + ); + match channel { + LightChannel::Block => section.accepted_block = Some(packed.to_vec()), + LightChannel::Sky => section.accepted_sky = Some(packed.to_vec()), + } + } + if channel == LightChannel::Sky { + self.invalidate_sky_source_section(sx, sz); + } + + let availability = self.section_availability(sx, sy, sz); + let mut changed = Vec::new(); + for ly in 0..16 { + for lz in 0..16 { + for lx in 0..16 { + let x = sx * 16 + lx as i32; + let y = sy * 16 + ly as i32; + let z = sz * 16 + lz as i32; + if !self.in_world(x, y, z) { + continue; + } + let old = self.get_channel(x, y, z, channel); + let new = nibble_at(packed, lx, ly, lz); + self.write_working(x, y, z, channel, new); + if old != new { + changed.push((x, y, z, old, new)); + } + } + } + } + + if channel == LightChannel::Sky && !self.sky_light_enabled { + self.mark_sky_changed(sx, sy, sz); + return; + } + + match availability { + SectionAvailability::LightOnly | SectionAvailability::Unloaded => { + for (x, y, z, old, new) in changed { + self.enqueue_boundary_influence(x, y, z, channel, old, new); + } + if channel == LightChannel::Sky { + self.update_sky_columns_in_section(sx, sz); + } + } + SectionAvailability::Loaded => { + if channel == LightChannel::Block { + for (x, y, z, _old, _new) in changed { + self.check_loaded_cell(x, y, z); + } + } else { + self.update_sky_columns_in_section(sx, sz); + } + } + } + if channel == LightChannel::Sky { + self.mark_sky_changed(sx, sy, sz); + } else { + self.mark_changed(sx, sy, sz); + } + } + + fn unload_section(&mut self, sx: i32, sy: i32, sz: i32) { + if !self.sections.contains_key(&(sx, sy, sz)) { + return; + } + self.invalidate_sky_source_section(sx, sz); + self.reconcile_unloading_section(sx, sy, sz); + self.remove_section_storage(sx, sy, sz); + } + + fn remove_section_storage(&mut self, sx: i32, sy: i32, sz: i32) { + self.sections.remove(&(sx, sy, sz)); + self.changed_for_publication.remove(&(sx, sy, sz)); + self.changed_sky.remove(&(sx, sy, sz)); + self.mark_transaction(); + } + + /// Lost support from a removed section: decrease remaining Loaded contacts. + /// Unknown after unload is not written as a known-zero wall. + fn reconcile_unloading_section(&mut self, sx: i32, sy: i32, sz: i32) { + for (dx, dy, dz) in DIRS { + let nsx = sx + dx; + let nsy = sy + dy; + let nsz = sz + dz; + if self.section_availability(nsx, nsy, nsz) != SectionAvailability::Loaded { + continue; + } + for (x, y, z) in section_face_cells(sx, sy, sz, dx, dy, dz) { + if !self.in_world(x, y, z) { + continue; + } + let old_block = self + .known_boundary_light(x, y, z, LightChannel::Block) + .unwrap_or_else(|| self.get_block_light(x, y, z)); + if old_block > 0 { + self.enqueue_boundary_influence(x, y, z, LightChannel::Block, old_block, 0); + } + if self.sky_light_enabled { + let old_sky = self + .known_boundary_light(x, y, z, LightChannel::Sky) + .unwrap_or_else(|| self.get_sky_light(x, y, z)); + if old_sky > 0 { + self.enqueue_boundary_influence(x, y, z, LightChannel::Sky, old_sky, 0); + } + } + } + } + } + + /// Ingest/load contact: dark face cells pull from a lit Loaded neighbor or LIGHT_ONLY boundary. + fn wake_section_face_contacts(&mut self, sx: i32, sy: i32, sz: i32) { + for (dx, dy, dz) in DIRS { + match self.section_availability(sx + dx, sy + dy, sz + dz) { + SectionAvailability::Unloaded => continue, + SectionAvailability::Loaded | SectionAvailability::LightOnly => { + for (x, y, z) in section_face_cells(sx, sy, sz, dx, dy, dz) { + if self.in_world(x, y, z) { + self.pull_block_from_neighbors(x, y, z); + } + } + } + } + } + } + + fn pull_block_from_neighbors(&mut self, x: i32, y: i32, z: i32) { + if !self.can_write(x, y, z) { + return; + } + if self.emission_at(x, y, z) > 0 || self.get_block_light(x, y, z) > 0 { + return; + } + if self.block_neighbor_level(x, y, z) > 1 { + self.decrease_q.push_back((x, y, z, 1)); + } + } + + fn block_neighbor_level(&self, x: i32, y: i32, z: i32) -> u8 { + let mut max_level = 0u8; + for (dx, dy, dz) in DIRS { + let nx = x + dx; + let ny = y + dy; + let nz = z + dz; + let level = self + .known_boundary_light(nx, ny, nz, LightChannel::Block) + .unwrap_or_else(|| self.get_block_light(nx, ny, nz)); + max_level = max_level.max(level); + } + max_level + } + + fn check_loaded_cell(&mut self, x: i32, y: i32, z: i32) { + if !self.check_dedup.insert((x, y, z)) { + return; + } + if self.section_availability_at(x, y, z) != SectionAvailability::Loaded { + return; + } + let emission = self.emission_at(x, y, z); + let stored = self.get_block_light(x, y, z); + if emission < stored { + self.set_block_light(x, y, z, 0); + self.decrease_q.push_back((x, y, z, stored)); + } + if emission > 0 { + if self.get_block_light(x, y, z) < emission { + self.set_block_light(x, y, z, emission); + } + self.increase_q.push_back((x, y, z, emission)); + } + } + + fn enqueue_boundary_influence(&mut self, x: i32, y: i32, z: i32, channel: LightChannel, old: u8, new: u8) { + match channel { + LightChannel::Block => { + if new > 0 { + self.increase_q.push_back((x, y, z, new)); + } + if old > new { + for (dx, dy, dz) in DIRS { + let nx = x + dx; + let ny = y + dy; + let nz = z + dz; + if self.section_availability_at(nx, ny, nz) != SectionAvailability::Loaded { + continue; + } + let stored = self.get_block_light(nx, ny, nz); + if stored == 0 { + continue; + } + if stored <= old.saturating_sub(1) { + self.set_block_light(nx, ny, nz, 0); + self.decrease_q.push_back((nx, ny, nz, stored)); + let emission = self.emission_at(nx, ny, nz); + if emission > 0 { + self.increase_q.push_back((nx, ny, nz, emission)); + } + } else { + self.increase_q.push_back((nx, ny, nz, stored)); + } + } + } + } + LightChannel::Sky => { + if new > 0 { + self.sky_increase_q.push_back((x, y, z, new, new == 15)); + } + if old > new { + for (dx, dy, dz) in DIRS { + let nx = x + dx; + let ny = y + dy; + let nz = z + dz; + if self.section_availability_at(nx, ny, nz) != SectionAvailability::Loaded { + continue; + } + let stored = self.get_sky_light(nx, ny, nz); + if stored == 0 { + continue; + } + if stored <= old.saturating_sub(1) { + self.set_sky_light(nx, ny, nz, 0); + self.sky_decrease_q.push_back((nx, ny, nz, stored, stored == 15)); + let emission = self.sky_emission_at(nx, ny, nz); + if emission > 0 { + self.sky_increase_q.push_back((nx, ny, nz, emission, true)); + } + } else { + self.sky_increase_q.push_back((nx, ny, nz, stored, false)); + } + } + } + } + } + } + + fn propagate_decrease(&mut self, x: i32, y: i32, z: i32, from_level: u8) { + for (dx, dy, dz) in DIRS { + let nx = x + dx; + let ny = y + dy; + let nz = z + dz; + if let Some(boundary) = self.known_boundary_light(nx, ny, nz, LightChannel::Block) { + if boundary > 0 { + self.increase_q.push_back((nx, ny, nz, boundary)); + } + continue; + } + if !self.can_write(nx, ny, nz) { + continue; + } + let stored = self.get_block_light(nx, ny, nz); + if stored == 0 { + continue; + } + if stored <= from_level.saturating_sub(1) { + self.set_block_light(nx, ny, nz, 0); + self.decrease_q.push_back((nx, ny, nz, stored)); + let emission = self.emission_at(nx, ny, nz); + if emission > 0 { + self.increase_q.push_back((nx, ny, nz, emission)); + } + } else { + self.increase_q.push_back((nx, ny, nz, stored)); + } + } + } + + fn propagate_increase(&mut self, x: i32, y: i32, z: i32, level: u8) { + if let Some(boundary) = self.known_boundary_light(x, y, z, LightChannel::Block) { + if boundary == 0 { + return; + } + self.spread_increase(x, y, z, boundary.min(level)); + return; + } + let stored = self.get_block_light(x, y, z); + let emission = self.emission_at(x, y, z); + let supported = stored.max(emission); + if level > supported { + if stored <= 1 { + return; + } + self.spread_increase(x, y, z, stored); + return; + } + if stored < emission { + self.set_block_light(x, y, z, emission); + } + let from = self.get_block_light(x, y, z); + self.spread_increase(x, y, z, from); + } + + fn spread_increase(&mut self, x: i32, y: i32, z: i32, level: u8) { + for (dx, dy, dz) in DIRS { + let nx = x + dx; + let ny = y + dy; + let nz = z + dz; + if !self.can_write(nx, ny, nz) { + continue; + } + if self.shape_occludes(x, y, z, nx, ny, nz, dx, dy, dz) { + continue; + } + let next = level.saturating_sub(self.opacity_at(nx, ny, nz)); + let stored = self.get_block_light(nx, ny, nz); + if next > stored { + self.set_block_light(nx, ny, nz, next); + if next > 1 { + self.increase_q.push_back((nx, ny, nz, next)); + } + } + } + } + + fn update_sky_columns_in_section(&mut self, sx: i32, sz: i32) { + self.mark_sky_columns_in_section(sx, sz); + } + + fn update_sky_column(&mut self, x: i32, z: i32) { + if !self.sky_light_enabled { + return; + } + #[cfg(test)] + { + self.sky_column_updates = self.sky_column_updates.saturating_add(1); + } + let lowest = self.sky_source_lowest_y(x, z); + let max_y = self.world_min_y + self.world_height; + for y in (self.world_min_y..max_y).rev() { + if self.section_availability_at(x, y, z) == SectionAvailability::Loaded { + self.sky_check_dedup.remove(&(x, y, z)); + self.check_sky_cell_with_lowest(x, y, z, lowest); + } + } + } + + fn check_sky_cell_with_lowest(&mut self, x: i32, y: i32, z: i32, lowest: i32) { + if !self.sky_check_dedup.insert((x, y, z)) { + return; + } + if self.section_availability_at(x, y, z) != SectionAvailability::Loaded { + return; + } + let emission = if !self.sky_occludes(x, y, z) && y >= lowest { 15 } else { 0 }; + let stored = self.get_sky_light(x, y, z); + if emission < stored { + self.set_sky_light(x, y, z, 0); + self.sky_decrease_q.push_back((x, y, z, stored, stored == 15)); + } + if emission > 0 { + if self.get_sky_light(x, y, z) < emission { + self.set_sky_light(x, y, z, emission); + } + self.sky_increase_q.push_back((x, y, z, emission, true)); + } else if stored == 0 && self.sky_neighbor_level(x, y, z) > 1 { + self.sky_decrease_q.push_back((x, y, z, 1, false)); + } + } + + fn sky_neighbor_level(&self, x: i32, y: i32, z: i32) -> u8 { + let mut max_level = 0u8; + for (dx, dy, dz) in DIRS { + let nx = x + dx; + let ny = y + dy; + let nz = z + dz; + let level = self + .known_boundary_light(nx, ny, nz, LightChannel::Sky) + .unwrap_or_else(|| self.get_sky_light(nx, ny, nz)); + max_level = max_level.max(level); + } + max_level + } + + fn sky_emission_at(&mut self, x: i32, y: i32, z: i32) -> u8 { + if self.is_sky_source(x, y, z) { + 15 + } else { + 0 + } + } + + fn is_sky_source(&mut self, x: i32, y: i32, z: i32) -> bool { + if !self.sky_light_enabled || !self.in_world(x, y, z) { + return false; + } + if self.section_availability_at(x, y, z) != SectionAvailability::Loaded { + return false; + } + if self.sky_occludes(x, y, z) { + return false; + } + y >= self.sky_source_lowest_y(x, z) + } + + /// Vanilla sky *source* stops on true light-blocking: getLightBlock() != 0. + /// Air/glass/slabs are 0 (column stays 15). Water/leaves are 1 (below is not 15). + fn sky_occludes(&self, x: i32, y: i32, z: i32) -> bool { + self.light_block_at(x, y, z) != 0 + } + + /// Lowest Y in this column that is a sky source, or i32::MAX if the column has no sky. + /// Unknown (unloaded / LIGHT_ONLY without data) is not treated as 15. + fn lowest_source_y(&self, x: i32, z: i32) -> i32 { + if !self.sky_light_enabled { + return i32::MAX; + } + let max_y = self.world_min_y + self.world_height - 1; + let mut connected = false; + let mut lowest = i32::MAX; + for y in (self.world_min_y..=max_y).rev() { + #[cfg(test)] + { + self.sky_source_scan_rows + .set(self.sky_source_scan_rows.get().saturating_add(1)); + } + match self.section_availability_at(x, y, z) { + SectionAvailability::Unloaded => { + if connected { + break; + } + } + SectionAvailability::LightOnly => match self.known_boundary_light(x, y, z, LightChannel::Sky) { + Some(15) => { + connected = true; + } + Some(_) | None => { + if connected { + break; + } + } + }, + SectionAvailability::Loaded => { + if y == max_y { + if self.sky_occludes(x, y, z) { + break; + } + connected = true; + lowest = y; + continue; + } + if !connected { + continue; + } + if self.sky_occludes(x, y, z) { + break; + } + lowest = y; + } + } + } + lowest + } + + fn propagate_sky_decrease(&mut self, x: i32, y: i32, z: i32, from_level: u8, skip_up: bool) { + for (dx, dy, dz) in DIRS { + if skip_up && dy == 1 { + continue; + } + let nx = x + dx; + let ny = y + dy; + let nz = z + dz; + if let Some(boundary) = self.known_boundary_light(nx, ny, nz, LightChannel::Sky) { + if boundary > 0 { + self.sky_increase_q.push_back((nx, ny, nz, boundary, boundary == 15)); + } + continue; + } + if !self.can_write(nx, ny, nz) { + continue; + } + let stored = self.get_sky_light(nx, ny, nz); + if stored == 0 { + continue; + } + if stored <= from_level.saturating_sub(1) { + self.set_sky_light(nx, ny, nz, 0); + self.sky_decrease_q.push_back((nx, ny, nz, stored, stored == 15)); + let emission = self.sky_emission_at(nx, ny, nz); + if emission > 0 { + self.sky_increase_q.push_back((nx, ny, nz, emission, true)); + } + } else { + self.sky_increase_q.push_back((nx, ny, nz, stored, false)); + } + } + } + + fn propagate_sky_increase(&mut self, x: i32, y: i32, z: i32, level: u8, skip_up: bool) { + if let Some(boundary) = self.known_boundary_light(x, y, z, LightChannel::Sky) { + if boundary == 0 { + return; + } + self.spread_sky_increase(x, y, z, boundary.min(level), skip_up || boundary == 15); + return; + } + let stored = self.get_sky_light(x, y, z); + let emission = self.sky_emission_at(x, y, z); + let supported = stored.max(emission); + if level > supported { + if stored <= 1 { + return; + } + self.spread_sky_increase(x, y, z, stored, false); + return; + } + if stored < emission { + self.set_sky_light(x, y, z, emission); + } + let from = self.get_sky_light(x, y, z); + self.spread_sky_increase(x, y, z, from, skip_up || emission == 15); + } + + fn spread_sky_increase(&mut self, x: i32, y: i32, z: i32, level: u8, skip_up: bool) { + for (dx, dy, dz) in DIRS { + if skip_up && dy == 1 { + continue; + } + let nx = x + dx; + let ny = y + dy; + let nz = z + dz; + if !self.can_write(nx, ny, nz) { + continue; + } + if self.shape_occludes(x, y, z, nx, ny, nz, dx, dy, dz) { + continue; + } + let next = level.saturating_sub(self.opacity_at(nx, ny, nz)); + let stored = self.get_sky_light(nx, ny, nz); + if next > stored { + self.set_sky_light(nx, ny, nz, next); + if next > 1 { + self.sky_increase_q.push_back((nx, ny, nz, next, false)); + } + } + } + } + + fn publish_completed(&mut self) { + if self.changed_for_publication.is_empty() { + return; + } + self.publication_version = self.publication_version.saturating_add(1); + let keys: Vec = self.changed_for_publication.drain().collect(); + let sky_keys = std::mem::take(&mut self.changed_sky); + let mut sections = Vec::with_capacity(keys.len()); + for (sx, sy, sz) in keys { + if let Some(section) = self.sections.get(&(sx, sy, sz)) { + let sky = if sky_keys.contains(&(sx, sy, sz)) { + Some(section.sky_light.clone()) + } else { + None + }; + sections.push(PublishedSection { + sx, + sy, + sz, + block_light: section.block_light.clone(), + sky_light: sky, + }); + } + } + if sections.is_empty() { + return; + } + let incoming = LightPublication { + world_generation: self.world_generation, + publication_version: self.publication_version, + sections, + }; + match self.ready_publication.as_mut() { + Some(existing) => { + existing.publication_version = incoming.publication_version; + existing.world_generation = incoming.world_generation; + existing.sections.extend(incoming.sections); + } + None => self.ready_publication = Some(incoming), + } + } + + fn mark_changed(&mut self, sx: i32, sy: i32, sz: i32) { + self.changed_for_publication.insert((sx, sy, sz)); + self.mark_transaction(); + } + + fn mark_sky_changed(&mut self, sx: i32, sy: i32, sz: i32) { + self.changed_sky.insert((sx, sy, sz)); + self.mark_changed(sx, sy, sz); + } + + fn mark_transaction(&mut self) { + self.transaction_open = true; + } + + fn ensure_section( + &mut self, + sx: i32, + sy: i32, + sz: i32, + availability: SectionAvailability, + ) -> &mut Section { + self.sections + .entry((sx, sy, sz)) + .or_insert_with(|| Section::new(availability)) + } + + fn get_channel(&self, x: i32, y: i32, z: i32, channel: LightChannel) -> u8 { + let (sx, sy, sz) = section_key(x, y, z); + let Some(section) = self.sections.get(&(sx, sy, sz)) else { + return 0; + }; + nibble_at(channel_buf(section, channel), local(x), local(y), local(z)) + } + + fn set_block_light(&mut self, x: i32, y: i32, z: i32, value: u8) { + if self.section_availability_at(x, y, z) != SectionAvailability::Loaded { + return; + } + self.write_working(x, y, z, LightChannel::Block, value); + } + + fn set_sky_light(&mut self, x: i32, y: i32, z: i32, value: u8) { + if self.section_availability_at(x, y, z) != SectionAvailability::Loaded { + return; + } + self.write_working(x, y, z, LightChannel::Sky, value); + } + + fn write_working(&mut self, x: i32, y: i32, z: i32, channel: LightChannel, value: u8) { + let (sx, sy, sz) = section_key(x, y, z); + let Some(section) = self.sections.get_mut(&(sx, sy, sz)) else { + return; + }; + set_nibble(channel_buf_mut(section, channel), local(x), local(y), local(z), value); + self.changed_for_publication.insert((sx, sy, sz)); + if channel == LightChannel::Sky { + self.changed_sky.insert((sx, sy, sz)); + } + } + + fn known_boundary_light(&self, x: i32, y: i32, z: i32, channel: LightChannel) -> Option { + let (sx, sy, sz) = section_key(x, y, z); + let section = self.sections.get(&(sx, sy, sz))?; + if section.availability != SectionAvailability::LightOnly { + return None; + } + let accepted = match channel { + LightChannel::Block => section.accepted_block.is_some(), + LightChannel::Sky => section.accepted_sky.is_some(), + }; + if !accepted { + return None; + } + Some(nibble_at(channel_buf(section, channel), local(x), local(y), local(z))) + } + + fn can_write(&self, x: i32, y: i32, z: i32) -> bool { + self.in_world(x, y, z) && self.section_availability_at(x, y, z) == SectionAvailability::Loaded + } + + fn section_availability_at(&self, x: i32, y: i32, z: i32) -> SectionAvailability { + let (sx, sy, sz) = section_key(x, y, z); + self.section_availability(sx, sy, sz) + } + + fn in_world(&self, _x: i32, y: i32, _z: i32) -> bool { + y >= self.world_min_y && y < self.world_min_y + self.world_height + } + + fn emission_at(&self, x: i32, y: i32, z: i32) -> u8 { + self.emission_of(self.state_at(x, y, z)) + } + + fn opacity_at(&self, x: i32, y: i32, z: i32) -> u8 { + self.opacity_of(self.state_at(x, y, z)) + } + + fn light_block_at(&self, x: i32, y: i32, z: i32) -> u8 { + self.light_block_of(self.state_at(x, y, z)) + } + + fn state_at(&self, x: i32, y: i32, z: i32) -> u16 { + let (sx, sy, sz) = section_key(x, y, z); + self.sections + .get(&(sx, sy, sz)) + .and_then(|section| section.states.as_ref()) + .map(|states| states[block_index(x, y, z)]) + .unwrap_or(AIR) + } + + fn emission_of(&self, state_id: u16) -> u8 { + self.emission.get(state_id as usize).copied().unwrap_or(0).min(15) + } + + fn light_block_of(&self, state_id: u16) -> u8 { + self.light_block.get(state_id as usize).copied().unwrap_or(0).min(15) + } + + /// Neighbor spread: vanilla getOpacity = max(1, getLightBlock()). Air still costs 1. + fn opacity_of(&self, state_id: u16) -> u8 { + self.light_block_of(state_id).max(1) + } + + fn occupancy_of(&self, state_id: u16) -> u8 { + self.occupancy.get(state_id as usize).copied().unwrap_or(0) + } + + /// 1.17.1 LayerLightEngine: faceShapeOccludes(from.dir, to.opposite). + /// Full cube (0xFF) matches Shapes.block() identity. Otherwise OR of the 3D + /// occupancy "slices" must fill the cube. Isolated slabs/stairs vs air do not occlude. + fn shape_occludes(&self, x: i32, y: i32, z: i32, nx: i32, ny: i32, nz: i32, dx: i32, dy: i32, dz: i32) -> bool { + let from = self.face_shape(self.occupancy_of(self.state_at(x, y, z)), dx, dy, dz); + let to = self.face_shape(self.occupancy_of(self.state_at(nx, ny, nz)), -dx, -dy, -dz); + if from == 0xFF || to == 0xFF { + return true; + } + if from == 0 && to == 0 { + return false; + } + from | to == 0xFF + } + + fn face_shape(&self, occ: u8, dx: i32, dy: i32, dz: i32) -> u8 { + if occ == 0 || occ == 0xFF { + return occ; + } + let mask = if dx > 0 { + 0b1010_1010 + } else if dx < 0 { + 0b0101_0101 + } else if dy > 0 { + 0b1111_0000 + } else if dy < 0 { + 0b0000_1111 + } else if dz > 0 { + 0b1100_1100 + } else if dz < 0 { + 0b0011_0011 + } else { + 0 + }; + if occ & mask == 0 { + 0 + } else { + occ + } + } +} + +#[cfg(test)] +mod test_clock { + use std::cell::Cell; + + thread_local! { + static NOW: Cell> = const { Cell::new(None) }; + static STEP: Cell = const { Cell::new(0.0) }; + } + + pub struct Guard; + + impl Drop for Guard { + fn drop(&mut self) { + NOW.with(|c| c.set(None)); + STEP.with(|c| c.set(0.0)); + } + } + + pub fn start(now: f64, step: f64) -> Guard { + NOW.with(|c| c.set(Some(now))); + STEP.with(|c| c.set(step)); + Guard + } + + pub fn advance_and_read() -> Option { + let t = NOW.with(|c| c.get())?; + let step = STEP.with(|c| c.get()); + NOW.with(|c| c.set(Some(t + step))); + Some(t) + } +} + +fn now_ms() -> f64 { + #[cfg(test)] + { + if let Some(t) = test_clock::advance_and_read() { + return t; + } + } + #[cfg(target_arch = "wasm32")] + { + js_sys::Date::now() + } + #[cfg(not(target_arch = "wasm32"))] + { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64() * 1000.0) + .unwrap_or(0.0) + } +} + +fn event_column(event: &LightEvent) -> (i32, i32) { + match event { + LightEvent::IngestBlockSection { sx, sz, .. } + | LightEvent::SetAvailability { sx, sz, .. } + | LightEvent::ServerLight { sx, sz, .. } + | LightEvent::UnloadColumn { sx, sz } => (*sx, *sz), + LightEvent::BlockChange { x, z, .. } => (x.div_euclid(16), z.div_euclid(16)), + } +} + +/// FIFO prefix of one column's connected load/availability/seed/edit events. +fn column_admission_len(pending: &VecDeque) -> usize { + let Some(first) = pending.front() else { + return 0; + }; + let column = event_column(first); + pending + .iter() + .take_while(|event| event_column(event) == column) + .count() +} + +/// Promote the earliest edit column, then its queued 3x3 neighborhood. +/// Block light (level <= 15, decay >= 1) cannot reach beyond these columns. +/// Preserve each column's event order and unrelated bulk order. Admission +/// remains one column at a time; missing neighbors are never invented. +fn promote_edit_column(pending: &mut VecDeque, column_known: impl Fn(i32, i32) -> bool) { + if pending.is_empty() { + return; + } + let head_column = event_column(pending.front().unwrap()); + // A bounded prefix scan turns startup bulk depth into interactive latency: + // edits beyond that prefix cannot be promoted until the bulk drains. + let Some(edit_idx) = pending + .iter() + .position(|event| matches!(event, LightEvent::BlockChange { .. })) + else { + return; + }; + let edit_column = event_column(&pending[edit_idx]); + if edit_column == head_column && edit_idx >= column_admission_len(pending) { + // Keep the initial head prefix as its own transaction. The trailing + // edit and its dependencies can jump bulk at the next boundary. + return; + } + let original: Vec = pending.drain(..).collect(); + let has_ingest = original.iter().any(|event| { + event_column(event) == edit_column && matches!(event, LightEvent::IngestBlockSection { .. }) + }); + if !has_ingest && !column_known(edit_column.0, edit_column.1) { + pending.extend(original); + return; + } + let (extracted, rest): (Vec, Vec) = original + .into_iter() + .partition(|event| event_column(event) == edit_column); + let (neighbors, bulk): (Vec, Vec) = rest + .into_iter() + .partition(|event| { + let (sx, sz) = event_column(event); + sx.abs_diff(edit_column.0) <= 1 && sz.abs_diff(edit_column.1) <= 1 + }); + pending.extend(extracted); + // Retain dependencies after the edit leaves the queue. Ingest's face + // contact wake-up propagates the light as each neighbor becomes known. + pending.extend(neighbors); + pending.extend(bulk); +} + +fn section_key(x: i32, y: i32, z: i32) -> SectionKey { + (x.div_euclid(16), y.div_euclid(16), z.div_euclid(16)) +} + +fn local(v: i32) -> usize { + v.rem_euclid(16) as usize +} + +fn block_index(x: i32, y: i32, z: i32) -> usize { + local(x) + local(z) * 16 + local(y) * 256 +} + +/// Cells of section (sx,sy,sz) on the face toward (dx,dy,dz). +fn section_face_cells( + sx: i32, + sy: i32, + sz: i32, + dx: i32, + dy: i32, + dz: i32, +) -> impl Iterator { + let (lx0, lx1, ly0, ly1, lz0, lz1) = match (dx, dy, dz) { + (1, 0, 0) => (15, 16, 0, 16, 0, 16), + (-1, 0, 0) => (0, 1, 0, 16, 0, 16), + (0, 1, 0) => (0, 16, 15, 16, 0, 16), + (0, -1, 0) => (0, 16, 0, 1, 0, 16), + (0, 0, 1) => (0, 16, 0, 16, 15, 16), + (0, 0, -1) => (0, 16, 0, 16, 0, 1), + _ => (0, 0, 0, 0, 0, 0), + }; + (ly0..ly1).flat_map(move |ly| { + (lz0..lz1).flat_map(move |lz| { + (lx0..lx1).map(move |lx| (sx * 16 + lx, sy * 16 + ly, sz * 16 + lz)) + }) + }) +} + +fn pad_states(mut states: Vec) -> Vec { + if states.len() < BLOCK_SECTION_VOLUME { + states.resize(BLOCK_SECTION_VOLUME, AIR); + } + states.truncate(BLOCK_SECTION_VOLUME); + states +} + +fn channel_buf(section: &Section, channel: LightChannel) -> &[u8] { + match channel { + LightChannel::Block => §ion.block_light, + LightChannel::Sky => §ion.sky_light, + } +} + +fn channel_buf_mut(section: &mut Section, channel: LightChannel) -> &mut [u8] { + match channel { + LightChannel::Block => &mut section.block_light, + LightChannel::Sky => &mut section.sky_light, + } +} + +fn set_nibble(packed: &mut [u8], lx: usize, ly: usize, lz: usize, value: u8) { + let local = (ly << 8) | (lz << 4) | lx; + let byte = local >> 1; + let n = value & 0x0f; + if local & 1 == 0 { + packed[byte] = (packed[byte] & 0xf0) | n; + } else { + packed[byte] = (packed[byte] & 0x0f) | (n << 4); + } +} + +pub fn pack_uniform_section(value: u8) -> Vec { + let n = value & 0x0f; + vec![n | (n << 4); LIGHT_SECTION_BUFFER_BYTES] +} + +pub fn nibble_at(packed: &[u8], lx: usize, ly: usize, lz: usize) -> u8 { + let local = (ly << 8) | (lz << 4) | lx; + let byte = packed[local >> 1]; + if local & 1 == 0 { + byte & 0x0f + } else { + byte >> 4 + } +} + +#[wasm_bindgen] +pub struct JsLightEngine { + inner: LightEngine, +} + +#[wasm_bindgen] +impl JsLightEngine { + #[wasm_bindgen(constructor)] + pub fn js_new(world_min_y: i32, world_height: i32) -> JsLightEngine { + JsLightEngine { + inner: LightEngine::new(world_min_y, world_height), + } + } + + #[wasm_bindgen(js_name = setLightTables)] + pub fn js_set_light_tables(&mut self, emission: &[u8], opacity: &[u8]) { + self.inner.set_light_tables(emission, opacity); + } + + #[wasm_bindgen(js_name = setOcclusionTable)] + pub fn js_set_occlusion_table(&mut self, occupancy: &[u8]) { + self.inner.set_occlusion_table(occupancy); + } + + #[wasm_bindgen(js_name = pushEvent)] + pub fn js_push_event(&mut self, event: JsValue) { + match event_from_js(&event) { + Ok(ev) => self.inner.push_event(ev), + Err(msg) => wasm_bindgen::throw_str(&msg), + } + } + + pub fn step(&mut self, budget_ms: f64) -> bool { + self.inner.step(budget_ms) + } + + #[wasm_bindgen(js_name = pollCompletedPublication)] + pub fn js_poll_completed_publication(&mut self) -> JsValue { + match self.inner.poll_completed_publication() { + Some(publication) => publication_to_js(&publication), + None => JsValue::NULL, + } + } + + #[wasm_bindgen(js_name = getBlockLight)] + pub fn js_get_block_light(&self, x: i32, y: i32, z: i32) -> u8 { + self.inner.get_block_light(x, y, z) + } + + #[wasm_bindgen(js_name = getSkyLight)] + pub fn js_get_sky_light(&self, x: i32, y: i32, z: i32) -> u8 { + self.inner.get_sky_light(x, y, z) + } + + #[wasm_bindgen(js_name = setSkyLightEnabled)] + pub fn js_set_sky_light_enabled(&mut self, enabled: bool) { + self.inner.set_sky_light_enabled(enabled); + } +} + +fn event_from_js(event: &JsValue) -> Result { + let kind = js_string(event, "type")?; + match kind.as_str() { + "ingestBlockSection" => { + let states = js_u16_array(event, "states").unwrap_or_default(); + Ok(LightEvent::IngestBlockSection { + sx: js_i32(event, "sx")?, + sy: js_i32(event, "sy")?, + sz: js_i32(event, "sz")?, + states, + }) + } + "setAvailability" => Ok(LightEvent::SetAvailability { + sx: js_i32(event, "sx")?, + sy: js_i32(event, "sy")?, + sz: js_i32(event, "sz")?, + availability: match js_string(event, "availability")?.as_str() { + "loaded" => SectionAvailability::Loaded, + "lightOnly" => SectionAvailability::LightOnly, + "unloaded" => SectionAvailability::Unloaded, + other => return Err(format!("unknown availability {other}")), + }, + }), + "serverLight" => { + let channel = match js_string(event, "channel")?.as_str() { + "sky" => LightChannel::Sky, + _ => LightChannel::Block, + }; + let kind = match js_string(event, "kind")?.as_str() { + "omitted" => ServerLightKind::Omitted, + "empty" => ServerLightKind::Empty, + "data" => ServerLightKind::Data(js_u8_array(event, "data")?), + other => return Err(format!("unknown server light kind {other}")), + }; + Ok(LightEvent::ServerLight { + sx: js_i32(event, "sx")?, + sy: js_i32(event, "sy")?, + sz: js_i32(event, "sz")?, + channel, + kind, + }) + } + "blockChange" => Ok(LightEvent::BlockChange { + x: js_i32(event, "x")?, + y: js_i32(event, "y")?, + z: js_i32(event, "z")?, + state_id: js_i32(event, "stateId")? as u16, + }), + "unloadColumn" => Ok(LightEvent::UnloadColumn { + sx: js_i32(event, "sx")?, + sz: js_i32(event, "sz")?, + }), + other => Err(format!("unknown light event {other}")), + } +} + +fn publication_to_js(publication: &LightPublication) -> JsValue { + let obj = js_sys::Object::new(); + js_sys::Reflect::set( + &obj, + &JsValue::from_str("worldGeneration"), + &JsValue::from_f64(publication.world_generation as f64), + ) + .unwrap(); + js_sys::Reflect::set( + &obj, + &JsValue::from_str("publicationVersion"), + &JsValue::from_f64(publication.publication_version as f64), + ) + .unwrap(); + let list = js_sys::Array::new(); + for section in &publication.sections { + let item = js_sys::Object::new(); + js_sys::Reflect::set(&item, &JsValue::from_str("sx"), &JsValue::from_f64(section.sx as f64)).unwrap(); + js_sys::Reflect::set(&item, &JsValue::from_str("sy"), &JsValue::from_f64(section.sy as f64)).unwrap(); + js_sys::Reflect::set(&item, &JsValue::from_str("sz"), &JsValue::from_f64(section.sz as f64)).unwrap(); + let block = js_sys::Uint8Array::new_with_length(section.block_light.len() as u32); + block.copy_from(§ion.block_light); + js_sys::Reflect::set(&item, &JsValue::from_str("blockLight"), &block).unwrap(); + if let Some(sky) = §ion.sky_light { + let sky_arr = js_sys::Uint8Array::new_with_length(sky.len() as u32); + sky_arr.copy_from(sky); + js_sys::Reflect::set(&item, &JsValue::from_str("skyLight"), &sky_arr).unwrap(); + } + list.push(&item); + } + js_sys::Reflect::set(&obj, &JsValue::from_str("sections"), &list).unwrap(); + obj.into() +} + +fn js_i32(obj: &JsValue, key: &str) -> Result { + let value = js_sys::Reflect::get(obj, &JsValue::from_str(key)).map_err(|_| format!("missing {key}"))?; + value.as_f64().map(|n| n as i32).ok_or_else(|| format!("{key} is not a number")) +} + +fn js_string(obj: &JsValue, key: &str) -> Result { + let value = js_sys::Reflect::get(obj, &JsValue::from_str(key)).map_err(|_| format!("missing {key}"))?; + value.as_string().ok_or_else(|| format!("{key} is not a string")) +} + +fn js_u8_array(obj: &JsValue, key: &str) -> Result, String> { + let value = js_sys::Reflect::get(obj, &JsValue::from_str(key)).map_err(|_| format!("missing {key}"))?; + let array = js_sys::Uint8Array::new(&value); + Ok(array.to_vec()) +} + +fn js_u16_array(obj: &JsValue, key: &str) -> Result, String> { + let value = js_sys::Reflect::get(obj, &JsValue::from_str(key)).map_err(|_| format!("missing {key}"))?; + if value.is_undefined() || value.is_null() { + return Err(format!("missing {key}")); + } + let array = js_sys::Uint16Array::new(&value); + Ok(array.to_vec()) +} + +#[cfg(test)] +fn default_test_tables() -> (Vec, Vec) { + let mut emission = vec![0u8; 16]; + let mut light_block = vec![0u8; 16]; + emission[TORCH as usize] = 14; + light_block[STONE as usize] = 15; + (emission, light_block) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser_v16_v17::{parse_update_light_v17, NUM_SECTIONS_V17}; + use std::fs; + use std::path::Path; + + fn engine() -> LightEngine { + let mut e = LightEngine::new(0, 256); + let (emission, opacity) = default_test_tables(); + e.set_light_tables(&emission, &opacity); + e + } + + fn air_section() -> Vec { + vec![AIR; BLOCK_SECTION_VOLUME] + } + + fn load_section(e: &mut LightEngine, sx: i32, sy: i32, sz: i32, states: Vec) { + e.push_event(LightEvent::IngestBlockSection { sx, sy, sz, states }); + e.push_event(LightEvent::SetAvailability { + sx, + sy, + sz, + availability: SectionAvailability::Loaded, + }); + } + + fn finish(e: &mut LightEngine) -> Option { + let mut remaining = true; + let mut guard = 0; + while remaining && guard < 64 { + remaining = e.step(1_000.0); + guard += 1; + } + e.poll_completed_publication() + } + + fn drain_until_publication(e: &mut LightEngine) -> Option { + let mut guard = 0; + while guard < 64 { + let remaining = e.step(1_000.0); + if let Some(publication) = e.poll_completed_publication() { + return Some(publication); + } + if !remaining { + break; + } + guard += 1; + } + None + } + + fn sample(e: &LightEngine) -> [u8; 3] { + [ + e.get_block_light(8, 64, 8), + e.get_block_light(9, 64, 8), + e.get_block_light(8, 65, 8), + ] + } + + fn zero_seed_section(e: &mut LightEngine, sx: i32, sy: i32, sz: i32) { + e.push_event(LightEvent::ServerLight { + sx, + sy, + sz, + channel: LightChannel::Block, + kind: ServerLightKind::Data(pack_uniform_section(0)), + }); + } + + fn load_air_column_ready(e: &mut LightEngine) { + load_section(e, 0, 4, 0, air_section()); + finish(e); + } + + fn place_torch(e: &mut LightEngine) { + e.push_event(LightEvent::BlockChange { + x: 8, + y: 64, + z: 8, + state_id: TORCH, + }); + } + + fn remove_torch(e: &mut LightEngine) { + e.push_event(LightEvent::BlockChange { + x: 8, + y: 64, + z: 8, + state_id: AIR, + }); + } + + fn set_light_only_boundary(e: &mut LightEngine, sx: i32, sy: i32, sz: i32, value: u8) { + e.push_event(LightEvent::SetAvailability { + sx, + sy, + sz, + availability: SectionAvailability::LightOnly, + }); + e.push_event(LightEvent::ServerLight { + sx, + sy, + sz, + channel: LightChannel::Block, + kind: ServerLightKind::Data(pack_uniform_section(value)), + }); + } + + /// Boundary 14 + 6 Loaded air cells = 13,12,11,10,9,8. Not a spill-cut. + #[test] + fn invariant_boundary_14_lights_six_air_cells() { + let mut e = engine(); + load_section(&mut e, 0, 4, 0, air_section()); + set_light_only_boundary(&mut e, 1, 4, 0, 14); + finish(&mut e); + assert_eq!(e.get_block_light(16, 64, 8), 14, "LIGHT_ONLY boundary stays 14"); + let chain: Vec = (0..6).map(|i| e.get_block_light(15 - i, 64, 8)).collect(); + assert_eq!(chain, vec![13, 12, 11, 10, 9, 8]); + } + + /// Replacing the boundary with known zero clears the dependent air. + #[test] + fn invariant_boundary_replaced_with_zero_clears_air() { + let mut e = engine(); + load_section(&mut e, 0, 4, 0, air_section()); + set_light_only_boundary(&mut e, 1, 4, 0, 14); + finish(&mut e); + assert_eq!(e.get_block_light(15, 64, 8), 13); + set_light_only_boundary(&mut e, 1, 4, 0, 0); + finish(&mut e); + assert_eq!(e.get_block_light(16, 64, 8), 0, "known-zero boundary"); + for i in 0..6 { + assert_eq!(e.get_block_light(15 - i, 64, 8), 0, "air cell {i} must fall with the boundary"); + } + } + + /// Seeded air without a source or LIGHT_ONLY boundary must not self-sustain. + #[test] + fn invariant_seed_without_source_or_boundary_is_zero() { + let mut e = engine(); + load_section(&mut e, 0, 4, 0, air_section()); + e.push_event(LightEvent::ServerLight { + sx: 0, + sy: 4, + sz: 0, + channel: LightChannel::Block, + kind: ServerLightKind::Data(pack_uniform_section(14)), + }); + finish(&mut e); + assert_eq!(e.get_block_light(8, 64, 8), 0, "seed is a candidate, not a virtual emitter"); + assert_eq!(e.get_block_light(9, 64, 8), 0); + assert_eq!(e.get_block_light(15, 64, 8), 0); + } + + /// Sequence 1: torch → seed before the first queue pop. Sliced == continuous. + #[test] + fn sequence_torch_then_seed_before_first_pop() { + let mut continuous = engine(); + load_air_column_ready(&mut continuous); + place_torch(&mut continuous); + zero_seed_section(&mut continuous, 0, 4, 0); + finish(&mut continuous); + let want = sample(&continuous); + assert_eq!(want[0], 14, "source must stay 14 after seed-before-pop"); + assert!(want[1] >= 13); + assert!(want[2] >= 13); + + let mut sliced = engine(); + load_air_column_ready(&mut sliced); + place_torch(&mut sliced); + sliced.step_nodes(0); + zero_seed_section(&mut sliced, 0, 4, 0); + finish(&mut sliced); + assert_eq!(sample(&sliced), want, "sliced must match continuous (seed before first pop)"); + } + + /// Sequence 2: torch → one increase-pop → zero-seed → finish. Must not underlight to 12. + #[test] + fn sequence_seed_between_slices() { + let mut continuous = engine(); + load_air_column_ready(&mut continuous); + place_torch(&mut continuous); + zero_seed_section(&mut continuous, 0, 4, 0); + finish(&mut continuous); + let want = sample(&continuous); + assert_eq!(want[0], 14, "continuous seed must restore torch emission"); + + let mut sliced = engine(); + load_air_column_ready(&mut sliced); + place_torch(&mut sliced); + sliced.step_nodes(1); + zero_seed_section(&mut sliced, 0, 4, 0); + finish(&mut sliced); + assert_eq!(sliced.get_block_light(8, 64, 8), 14, "seed between slices must not leave source at 12"); + assert_eq!(sample(&sliced), want, "sliced must match continuous (seed between slices)"); + } + + /// Sequence 3: place then remove in one batch, no seed. Stale increase must not resurrect 14/13. + #[test] + fn sequence_place_then_remove_without_seed() { + let mut continuous = engine(); + load_air_column_ready(&mut continuous); + place_torch(&mut continuous); + remove_torch(&mut continuous); + finish(&mut continuous); + let want = sample(&continuous); + assert_eq!(want, [0, 0, 0], "place+remove must end at 0/0, not phantom 14/13"); + + let mut sliced = engine(); + load_air_column_ready(&mut sliced); + place_torch(&mut sliced); + sliced.step_nodes(1); + remove_torch(&mut sliced); + finish(&mut sliced); + assert_eq!(sample(&sliced), want, "sliced must match continuous (place then remove)"); + } + + /// Sequence 4: seed zeros on a clean section that still has a torch. + #[test] + fn sequence_seed_on_clean_section_with_torch() { + let mut continuous = engine(); + load_air_column_ready(&mut continuous); + place_torch(&mut continuous); + finish(&mut continuous); + assert_eq!(continuous.get_block_light(8, 64, 8), 14); + zero_seed_section(&mut continuous, 0, 4, 0); + finish(&mut continuous); + let want = sample(&continuous); + assert_eq!(want[0], 14, "clean-section seed must restore emission from stateId"); + assert!(want[1] >= 13); + + let mut sliced = engine(); + load_air_column_ready(&mut sliced); + place_torch(&mut sliced); + finish(&mut sliced); + zero_seed_section(&mut sliced, 0, 4, 0); + sliced.step_nodes(2); + finish(&mut sliced); + assert_eq!(sample(&sliced), want, "sliced must match continuous (seed on clean torch)"); + } + + #[test] + fn omitted_server_section_is_not_replaced() { + let mut e = engine(); + set_light_only_boundary(&mut e, 0, 0, 0, 7); + set_light_only_boundary(&mut e, 0, 1, 0, 3); + finish(&mut e); + + e.push_event(LightEvent::ServerLight { + sx: 0, + sy: 1, + sz: 0, + channel: LightChannel::Block, + kind: ServerLightKind::Data(pack_uniform_section(9)), + }); + finish(&mut e); + + assert_eq!(e.get_block_light(0, 0, 0), 7, "omitted LIGHT_ONLY neighbor must stay 7"); + assert_eq!(e.get_block_light(0, 16, 0), 9); + } + + #[test] + fn empty_server_bit_zeros_section() { + let mut e = engine(); + set_light_only_boundary(&mut e, 0, 0, 0, 7); + finish(&mut e); + assert_eq!(e.get_block_light(1, 1, 1), 7, "known boundary 7"); + e.push_event(LightEvent::ServerLight { + sx: 0, + sy: 0, + sz: 0, + channel: LightChannel::Block, + kind: ServerLightKind::Empty, + }); + finish(&mut e); + assert_eq!(e.get_block_light(1, 1, 1), 0, "empty-bit is known zero"); + } + + #[test] + fn torch_place_then_remove_has_no_phantom_light() { + let mut e = engine(); + load_section(&mut e, 0, 4, 0, air_section()); + finish(&mut e); + + e.push_event(LightEvent::BlockChange { + x: 8, + y: 64, + z: 8, + state_id: TORCH, + }); + finish(&mut e); + assert_eq!(e.get_block_light(8, 64, 8), 14); + assert!(e.get_block_light(9, 64, 8) >= 13); + assert!(e.get_block_light(8, 65, 8) >= 13); + + e.push_event(LightEvent::BlockChange { + x: 8, + y: 64, + z: 8, + state_id: AIR, + }); + finish(&mut e); + assert_eq!(e.get_block_light(8, 64, 8), 0); + assert_eq!(e.get_block_light(9, 64, 8), 0); + assert_eq!(e.get_block_light(8, 70, 8), 0); + } + + #[test] + fn second_source_survives_removal_of_first() { + let mut e = engine(); + load_section(&mut e, 0, 4, 0, air_section()); + e.push_event(LightEvent::BlockChange { + x: 4, + y: 64, + z: 8, + state_id: TORCH, + }); + e.push_event(LightEvent::BlockChange { + x: 12, + y: 64, + z: 8, + state_id: TORCH, + }); + finish(&mut e); + let mid_before = e.get_block_light(8, 64, 8); + assert!(mid_before > 0); + + e.push_event(LightEvent::BlockChange { + x: 4, + y: 64, + z: 8, + state_id: AIR, + }); + finish(&mut e); + assert_eq!(e.get_block_light(12, 64, 8), 14); + assert!(e.get_block_light(8, 64, 8) > 0, "overlap must stay lit by the remaining torch"); + assert_eq!(e.get_block_light(4, 64, 8), e.get_block_light(5, 64, 8).saturating_sub(1)); + assert!(e.get_block_light(4, 64, 8) < 14); + } + + #[test] + fn step_does_not_publish_mid_transaction() { + let mut e = engine(); + load_section(&mut e, 0, 4, 0, air_section()); + e.step(1_000.0); + let _ = e.poll_completed_publication(); + + e.push_event(LightEvent::BlockChange { + x: 8, + y: 64, + z: 8, + state_id: TORCH, + }); + let remaining = e.step(0.0); + assert!(remaining, "queues must still hold work after a zero-ms slice"); + assert!(e.poll_completed_publication().is_none(), "half transaction must not publish"); + + finish(&mut e); + assert_eq!(e.get_block_light(8, 64, 8), 14); + } + + #[test] + fn publication_versions_increase_and_block_only_omits_sky() { + let mut e = engine(); + load_section(&mut e, 0, 4, 0, air_section()); + let first = finish(&mut e).expect("ingest publication"); + assert_eq!(first.world_generation, 1); + assert!(first.publication_version >= 1); + assert!(first.sections.iter().all(|s| s.sky_light.is_none()), "block-light engine publishes block only"); + + e.push_event(LightEvent::BlockChange { + x: 8, + y: 64, + z: 8, + state_id: TORCH, + }); + let second = finish(&mut e).expect("torch publication"); + assert_eq!(second.world_generation, first.world_generation); + assert!(second.publication_version > first.publication_version); + assert!(second.sections.iter().any(|s| s.sx == 0 && s.sy == 4 && s.sz == 0)); + assert_eq!(second.sections[0].block_light.len(), LIGHT_SECTION_BUFFER_BYTES); + assert!(second.sections.iter().all(|s| s.sky_light.is_none())); + } + + #[test] + fn tables_drive_emission() { + let mut e = LightEngine::new(0, 256); + let mut emission = vec![0u8; 16]; + let light_block = vec![0u8; 16]; + emission[TORCH as usize] = 7; + e.set_light_tables(&emission, &light_block); + load_section(&mut e, 0, 4, 0, air_section()); + e.push_event(LightEvent::BlockChange { + x: 8, + y: 64, + z: 8, + state_id: TORCH, + }); + finish(&mut e); + assert_eq!(e.get_block_light(8, 64, 8), 7); + } + + #[test] + fn light_only_without_data_is_not_air() { + let mut e = engine(); + load_section(&mut e, 0, 4, 0, air_section()); + e.push_event(LightEvent::SetAvailability { + sx: 1, + sy: 4, + sz: 0, + availability: SectionAvailability::LightOnly, + }); + e.push_event(LightEvent::BlockChange { + x: 15, + y: 64, + z: 8, + state_id: TORCH, + }); + finish(&mut e); + assert_eq!(e.section_availability(1, 4, 0), SectionAvailability::LightOnly); + assert_eq!(e.get_block_light(15, 64, 8), 14); + assert_eq!( + e.get_block_light(16, 64, 8), + 0, + "LIGHT_ONLY with no accepted data is not writable air" + ); + } + + #[test] + fn captured_1_17_1_partial_does_not_wipe_omitted_engine_section() { + let mut e = engine(); + for sy in 0..NUM_SECTIONS_V17 as i32 { + if sy == 6 { + set_light_only_boundary(&mut e, 7, sy, 0, 7); + } else { + load_section(&mut e, 7, sy, 0, air_section()); + } + } + finish(&mut e); + assert_eq!(e.get_block_light(112, 96, 0), 7); + + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../chunk-packet-fixtures/fixtures/map_chunk/1.17.1/5_0.update_light.bin"); + let packet = fs::read(&path).unwrap_or_else(|_| panic!("missing fixture {:?}", path)); + let parsed = parse_update_light_v17(&packet, NUM_SECTIONS_V17).expect("parse 1.17.1 fixture"); + assert_eq!(parsed.x, 7); + assert_eq!(parsed.z, 0); + + for sy in 0..NUM_SECTIONS_V17 { + let bit = sy + 1; + let word = parsed.empty_block_light_mask.get(0).copied().unwrap_or(0); + let empty = (word & (1 << bit)) != 0; + let data = parsed.block_light_mask.get(0).copied().unwrap_or(0) & (1 << bit) != 0; + if !empty && !data { + continue; + } + e.push_event(LightEvent::ServerLight { + sx: parsed.x, + sy: sy as i32, + sz: parsed.z, + channel: LightChannel::Block, + kind: if data { + let start = sy * BLOCK_SECTION_VOLUME; + ServerLightKind::Data(pack_unpacked(&parsed.block_light[start..start + BLOCK_SECTION_VOLUME])) + } else { + ServerLightKind::Empty + }, + }); + } + finish(&mut e); + + assert_eq!( + e.get_block_light(112, 96, 0), + 7, + "fixture empty-bits 1-5 must not wipe omitted world section 6" + ); + assert_eq!(e.get_block_light(112, 0, 0), 0, "empty-bit world section 0 must clear"); + } + + fn pack_unpacked(values: &[u8]) -> Vec { + let mut out = vec![0u8; LIGHT_SECTION_BUFFER_BYTES]; + for (i, &v) in values.iter().enumerate() { + let byte = i >> 1; + if i & 1 == 0 { + out[byte] = (out[byte] & 0xf0) | (v & 0x0f); + } else { + out[byte] = (out[byte] & 0x0f) | ((v & 0x0f) << 4); + } + } + out + } + + fn top_section_y(e: &LightEngine) -> i32 { + (e.world_min_y + e.world_height).div_euclid(16) - 1 + } + + fn load_world_top_air(e: &mut LightEngine) { + let sy = top_section_y(e); + load_section(e, 0, sy, 0, air_section()); + } + + fn set_sky_only_boundary(e: &mut LightEngine, sx: i32, sy: i32, sz: i32, value: u8) { + e.push_event(LightEvent::SetAvailability { + sx, + sy, + sz, + availability: SectionAvailability::LightOnly, + }); + e.push_event(LightEvent::ServerLight { + sx, + sy, + sz, + channel: LightChannel::Sky, + kind: ServerLightKind::Data(pack_uniform_section(value)), + }); + } + + fn place_stone_roof(e: &mut LightEngine, y: i32, hole: Option<(i32, i32)>) { + for z in 0..16 { + for x in 0..16 { + if hole == Some((x, z)) { + continue; + } + e.push_event(LightEvent::BlockChange { + x, + y, + z, + state_id: STONE, + }); + } + } + } + + fn remove_stone_roof(e: &mut LightEngine, y: i32) { + for z in 0..16 { + for x in 0..16 { + e.push_event(LightEvent::BlockChange { + x, + y, + z, + state_id: AIR, + }); + } + } + } + + fn sample_sky(e: &LightEngine, y: i32) -> [u8; 3] { + [ + e.get_sky_light(8, y, 8), + e.get_sky_light(9, y, 8), + e.get_sky_light(8, y - 1, 8), + ] + } + + /// Open air at world top is a sky source: the column stays 15 (no downward decay). + #[test] + fn sky_open_column_stays_15() { + let mut e = engine(); + load_world_top_air(&mut e); + finish(&mut e); + let y = e.world_min_y + e.world_height - 1; + assert_eq!(e.get_sky_light(8, y, 8), 15, "world-top air is a sky source"); + assert_eq!(e.get_sky_light(8, y - 15, 8), 15, "unobstructed column does not decay downward"); + assert_eq!(e.get_sky_light(0, y - 7, 0), 15); + } + + /// A 1-block hole in an opaque roof: 15 down the hole, sideways decay under the roof. + #[test] + fn sky_hole_column_stays_15_and_decays_sideways() { + let mut e = engine(); + load_world_top_air(&mut e); + finish(&mut e); + let roof_y = e.world_min_y + e.world_height - 6; + place_stone_roof(&mut e, roof_y, Some((8, 8))); + finish(&mut e); + assert_eq!(e.get_sky_light(8, roof_y, 8), 15, "hole remains a sky source"); + assert_eq!(e.get_sky_light(8, roof_y - 1, 8), 15, "air under the hole stays 15"); + assert_eq!(e.get_sky_light(8, roof_y - 5, 8), 15); + assert_eq!(e.get_sky_light(9, roof_y - 1, 8), 14, "under-roof neighbor decays"); + assert_eq!(e.get_sky_light(10, roof_y - 1, 8), 13); + assert_eq!(e.get_sky_light(11, roof_y - 1, 8), 12); + } + + /// Opaque roof with no hole: everything below goes dark. + #[test] + fn sky_opaque_roof_darkens_below() { + let mut e = engine(); + load_world_top_air(&mut e); + finish(&mut e); + let roof_y = e.world_min_y + e.world_height - 6; + place_stone_roof(&mut e, roof_y, None); + finish(&mut e); + assert_eq!(e.get_sky_light(8, roof_y + 1, 8), 15, "above the roof stays sky"); + assert_eq!(e.get_sky_light(8, roof_y, 8), 0, "opaque roof is not a source"); + assert_eq!(e.get_sky_light(8, roof_y - 1, 8), 0); + assert_eq!(e.get_sky_light(0, roof_y - 4, 0), 0); + assert_eq!(e.get_block_light(8, roof_y - 1, 8), 0, "sky must not write the block channel"); + } + + /// Removing the roof restores sky 15. + #[test] + fn sky_remove_roof_restores_column() { + let mut e = engine(); + load_world_top_air(&mut e); + finish(&mut e); + let roof_y = e.world_min_y + e.world_height - 6; + place_stone_roof(&mut e, roof_y, None); + finish(&mut e); + assert_eq!(e.get_sky_light(8, roof_y - 1, 8), 0); + remove_stone_roof(&mut e, roof_y); + finish(&mut e); + assert_eq!(e.get_sky_light(8, roof_y, 8), 15); + assert_eq!(e.get_sky_light(8, roof_y - 1, 8), 15); + assert_eq!(e.get_sky_light(8, roof_y - 5, 8), 15); + } + + /// LIGHT_ONLY sky 15 is a boundary, not air: it lights a decaying chain into Loaded. + #[test] + fn sky_invariant_boundary_15_lights_six_air_cells() { + let mut e = engine(); + load_section(&mut e, 0, 4, 0, air_section()); + set_sky_only_boundary(&mut e, 1, 4, 0, 15); + finish(&mut e); + assert_eq!(e.get_sky_light(16, 64, 8), 15, "LIGHT_ONLY sky boundary stays 15"); + let chain: Vec = (0..6).map(|i| e.get_sky_light(15 - i, 64, 8)).collect(); + assert_eq!(chain, vec![14, 13, 12, 11, 10, 9]); + assert_eq!(e.get_block_light(15, 64, 8), 0, "sky boundary must not fill block light"); + } + + /// Seeded sky without a source or LIGHT_ONLY boundary must not self-sustain. + #[test] + fn sky_invariant_seed_without_source_or_boundary_is_zero() { + let mut e = engine(); + load_section(&mut e, 0, 4, 0, air_section()); + e.push_event(LightEvent::ServerLight { + sx: 0, + sy: 4, + sz: 0, + channel: LightChannel::Sky, + kind: ServerLightKind::Data(pack_uniform_section(15)), + }); + finish(&mut e); + assert_eq!(e.get_sky_light(8, 64, 8), 0, "sky seed is a candidate, not a virtual source"); + assert_eq!(e.get_sky_light(9, 64, 8), 0); + assert_eq!(e.get_sky_light(15, 64, 8), 0); + } + + /// Unloaded / unknown above a loaded air section is not treated as sky 15. + #[test] + fn sky_unknown_above_is_not_source() { + let mut e = engine(); + load_section(&mut e, 0, 4, 0, air_section()); + finish(&mut e); + assert_eq!(e.get_sky_light(8, 79, 8), 0, "top of a mid-world section is not open sky"); + assert_eq!(e.get_sky_light(8, 64, 8), 0); + } + + /// LIGHT_ONLY sky 15 above Loaded air continues the source column (15, no downward decay). + #[test] + fn sky_light_only_15_above_continues_source_column() { + let mut e = engine(); + load_section(&mut e, 0, 4, 0, air_section()); + set_sky_only_boundary(&mut e, 0, 5, 0, 15); + finish(&mut e); + assert_eq!(e.get_sky_light(8, 79, 8), 15); + assert_eq!(e.get_sky_light(8, 64, 8), 15); + } + + /// Sliced roof place/remove matches continuous; stale queue entries must not resurrect sky. + #[test] + fn sky_sliced_roof_matches_continuous() { + let roof_y = 250; + let mut continuous = engine(); + load_world_top_air(&mut continuous); + finish(&mut continuous); + place_stone_roof(&mut continuous, roof_y, None); + finish(&mut continuous); + let want_dark = sample_sky(&continuous, roof_y - 1); + assert_eq!(want_dark, [0, 0, 0]); + remove_stone_roof(&mut continuous, roof_y); + finish(&mut continuous); + let want_lit = sample_sky(&continuous, roof_y - 1); + assert_eq!(want_lit, [15, 15, 15]); + + let mut sliced = engine(); + load_world_top_air(&mut sliced); + finish(&mut sliced); + place_stone_roof(&mut sliced, roof_y, None); + sliced.step_nodes(1); + finish(&mut sliced); + assert_eq!(sample_sky(&sliced, roof_y - 1), want_dark, "sliced roof must match continuous"); + remove_stone_roof(&mut sliced, roof_y); + sliced.step_nodes(3); + finish(&mut sliced); + assert_eq!(sample_sky(&sliced, roof_y - 1), want_lit, "sliced restore must match continuous"); + } + + /// Sky seed between slices on a sourced column must not underlight; sliced == continuous. + #[test] + fn sky_sliced_seed_on_open_column_matches_continuous() { + let mut continuous = engine(); + load_world_top_air(&mut continuous); + finish(&mut continuous); + continuous.push_event(LightEvent::ServerLight { + sx: 0, + sy: top_section_y(&continuous), + sz: 0, + channel: LightChannel::Sky, + kind: ServerLightKind::Data(pack_uniform_section(0)), + }); + finish(&mut continuous); + let y = continuous.world_min_y + continuous.world_height - 1; + let want = sample_sky(&continuous, y); + assert_eq!(want[0], 15, "zero-seed on open sky must restore sources"); + + let mut sliced = engine(); + load_world_top_air(&mut sliced); + sliced.step_nodes(1); + sliced.push_event(LightEvent::ServerLight { + sx: 0, + sy: top_section_y(&sliced), + sz: 0, + channel: LightChannel::Sky, + kind: ServerLightKind::Data(pack_uniform_section(0)), + }); + finish(&mut sliced); + assert_eq!(sample_sky(&sliced, y), want, "sliced sky seed must match continuous"); + } + + #[test] + fn sky_publication_includes_sky_channel() { + let mut e = engine(); + load_world_top_air(&mut e); + let publication = finish(&mut e).expect("sky ingest publication"); + let sy = top_section_y(&e); + let section = publication + .sections + .iter() + .find(|s| s.sx == 0 && s.sy == sy && s.sz == 0) + .expect("top section published"); + let sky = section.sky_light.as_ref().expect("sky channel must be published"); + assert_eq!(sky.len(), LIGHT_SECTION_BUFFER_BYTES); + assert_eq!(nibble_at(sky, 8, 15, 8), 15); + } + + #[test] + fn sky_disabled_dimension_stays_uncomputed() { + let mut e = engine(); + e.set_sky_light_enabled(false); + load_world_top_air(&mut e); + finish(&mut e); + let y = e.world_min_y + e.world_height - 1; + assert_eq!(e.get_sky_light(8, y, 8), 0, "nether/end must not invent sky 15"); + e.push_event(LightEvent::ServerLight { + sx: 0, + sy: top_section_y(&e), + sz: 0, + channel: LightChannel::Sky, + kind: ServerLightKind::Data(pack_uniform_section(15)), + }); + finish(&mut e); + assert_eq!( + e.get_sky_light(8, y, 8), + 15, + "disabled sky stores seed without turning it into a self-sustaining source column" + ); + e.push_event(LightEvent::BlockChange { + x: 8, + y, + z: 8, + state_id: STONE, + }); + finish(&mut e); + assert_eq!( + e.get_sky_light(8, y - 1, 8), + 15, + "disabled sky must not recompute around a roof" + ); + } + + /// Raw lightBlock tables: air=0 so the open column is still a sky source. + fn engine_raw() -> LightEngine { + let mut e = LightEngine::new(0, 256); + let mut emission = vec![0u8; 16]; + let mut light_block = vec![0u8; 16]; + emission[TORCH as usize] = 14; + light_block[STONE as usize] = 15; + light_block[WATER as usize] = 1; + light_block[LEAVES as usize] = 1; + light_block[SLAB_DOUBLE as usize] = 15; + e.set_light_tables(&emission, &light_block); + e + } + + fn place_state_roof(e: &mut LightEngine, y: i32, state_id: u16) { + for z in 0..16 { + for x in 0..16 { + e.push_event(LightEvent::BlockChange { x, y, z, state_id }); + } + } + } + + /// Water has raw lightBlock 1: it is not a sky source. Decay still uses max(1, O). + #[test] + fn sky_water_stops_source_then_14() { + let mut e = engine_raw(); + load_world_top_air(&mut e); + finish(&mut e); + let y = e.world_min_y + e.world_height - 6; + place_state_roof(&mut e, y, WATER); + finish(&mut e); + assert_eq!(e.get_sky_light(8, y + 1, 8), 15, "air above water stays a sky source"); + assert_eq!(e.get_sky_light(8, y, 8), 14, "water itself is 14, not a source 15"); + assert_ne!(e.get_sky_light(8, y - 1, 8), 15, "below water is not an open sky column"); + assert_eq!(e.get_sky_light(8, y - 1, 8), 13); + } + + /// Leaves are the same class as water: lightBlock 1 stops the source column. + #[test] + fn sky_leaves_stop_source_then_14() { + let mut e = engine_raw(); + load_world_top_air(&mut e); + finish(&mut e); + let y = e.world_min_y + e.world_height - 6; + place_state_roof(&mut e, y, LEAVES); + finish(&mut e); + assert_eq!(e.get_sky_light(8, y, 8), 14); + assert_ne!(e.get_sky_light(8, y - 1, 8), 15); + assert_eq!(e.get_sky_light(8, y - 1, 8), 13); + } + + /// Glass: lightBlock 0, noOcclusion. Sky source continues (15), unlike a solid cube. + #[test] + fn sky_glass_does_not_stop_source() { + let mut e = engine_raw(); + load_world_top_air(&mut e); + finish(&mut e); + let y = e.world_min_y + e.world_height - 6; + place_state_roof(&mut e, y, GLASS); + finish(&mut e); + assert_eq!(e.get_sky_light(8, y, 8), 15, "glass is not a light-blocking cube"); + assert_eq!(e.get_sky_light(8, y - 1, 8), 15); + } + + /// Bottom slab is not a full cube: sky source continues. Double slab / stone do not. + #[test] + fn sky_bottom_slab_is_not_a_full_cube() { + let mut e = engine_raw(); + load_world_top_air(&mut e); + finish(&mut e); + let y = e.world_min_y + e.world_height - 6; + place_state_roof(&mut e, y, SLAB_BOTTOM); + finish(&mut e); + assert_eq!(e.get_sky_light(8, y, 8), 15, "bottom slab lightBlock 0 keeps the source"); + assert_eq!(e.get_sky_light(8, y - 1, 8), 15, "air under a bottom slab stays 15"); + + place_state_roof(&mut e, y, SLAB_DOUBLE); + finish(&mut e); + assert_eq!(e.get_sky_light(8, y, 8), 0, "double slab is a full cube"); + assert_eq!(e.get_sky_light(8, y - 1, 8), 0); + } + + /// Torch emission still pins at 14 with raw air lightBlock 0. + #[test] + fn raw_air_still_spreads_torch_14() { + let mut e = engine_raw(); + load_section(&mut e, 0, 4, 0, air_section()); + e.push_event(LightEvent::BlockChange { + x: 8, + y: 64, + z: 8, + state_id: TORCH, + }); + finish(&mut e); + assert_eq!(e.get_block_light(8, 64, 8), 14); + assert_eq!(e.get_block_light(9, 64, 8), 13); + } + + fn engine_raw_with_shapes() -> LightEngine { + let mut e = engine_raw(); + let mut occupancy = vec![0u8; 16]; + occupancy[SLAB_BOTTOM as usize] = 0x0F; + occupancy[SLAB_TOP as usize] = 0xF0; + occupancy[STAIR as usize] = 0x3F; + e.set_occlusion_table(&occupancy); + e + } + + fn stone_section() -> Vec { + vec![STONE; BLOCK_SECTION_VOLUME] + } + + /// Stone tunnel along X so light cannot path around the pair under test. + fn load_x_tunnel(e: &mut LightEngine) { + load_section(e, 0, 4, 0, stone_section()); + for x in 7..=11 { + e.push_event(LightEvent::BlockChange { + x, + y: 64, + z: 8, + state_id: AIR, + }); + } + e.push_event(LightEvent::BlockChange { + x: 7, + y: 64, + z: 8, + state_id: TORCH, + }); + } + + /// Complementary bottom+top slabs fill the cube: vanilla faceShapeOccludes, light does not pass. + #[test] + fn face_bottom_and_top_slab_block_horizontal_light() { + let mut e = engine_raw_with_shapes(); + load_x_tunnel(&mut e); + e.push_event(LightEvent::BlockChange { + x: 8, + y: 64, + z: 8, + state_id: SLAB_BOTTOM, + }); + e.push_event(LightEvent::BlockChange { + x: 9, + y: 64, + z: 8, + state_id: SLAB_TOP, + }); + finish(&mut e); + assert_eq!(e.get_block_light(7, 64, 8), 14); + assert_eq!(e.get_block_light(8, 64, 8), 13, "bottom slab vs air does not occlude"); + assert_eq!(e.get_block_light(9, 64, 8), 0, "bottom+top slab pair occludes"); + assert_eq!(e.get_block_light(10, 64, 8), 0); + } + + /// Two bottom slabs do not fill the cube — light goes through the open top half. + #[test] + fn face_two_bottom_slabs_do_not_block() { + let mut e = engine_raw_with_shapes(); + load_x_tunnel(&mut e); + e.push_event(LightEvent::BlockChange { + x: 8, + y: 64, + z: 8, + state_id: SLAB_BOTTOM, + }); + e.push_event(LightEvent::BlockChange { + x: 9, + y: 64, + z: 8, + state_id: SLAB_BOTTOM, + }); + finish(&mut e); + assert_eq!(e.get_block_light(8, 64, 8), 13); + assert_eq!(e.get_block_light(9, 64, 8), 12); + assert_eq!(e.get_block_light(10, 64, 8), 11); + } + + /// A stair is not air: with a complementary top slab it occludes; air + top slab does not. + #[test] + fn face_stair_is_not_air() { + let mut stair = engine_raw_with_shapes(); + load_x_tunnel(&mut stair); + stair.push_event(LightEvent::BlockChange { + x: 8, + y: 64, + z: 8, + state_id: STAIR, + }); + stair.push_event(LightEvent::BlockChange { + x: 9, + y: 64, + z: 8, + state_id: SLAB_TOP, + }); + finish(&mut stair); + assert_eq!(stair.get_block_light(8, 64, 8), 13); + assert_eq!(stair.get_block_light(9, 64, 8), 0, "stair ∪ top slab fills the cube"); + + let mut air = engine_raw_with_shapes(); + load_x_tunnel(&mut air); + air.push_event(LightEvent::BlockChange { + x: 9, + y: 64, + z: 8, + state_id: SLAB_TOP, + }); + finish(&mut air); + assert_eq!(air.get_block_light(8, 64, 8), 13, "air west of the top slab"); + assert_eq!(air.get_block_light(9, 64, 8), 12, "top slab vs air does not occlude"); + } + + /// Glass: lightBlock 0 and empty occupancy. Spread matches air, unlike a full cube. + #[test] + fn face_glass_does_not_occlude_spread() { + let mut e = engine_raw_with_shapes(); + load_x_tunnel(&mut e); + e.push_event(LightEvent::BlockChange { + x: 8, + y: 64, + z: 8, + state_id: GLASS, + }); + finish(&mut e); + assert_eq!(e.get_block_light(8, 64, 8), 13); + assert_eq!(e.get_block_light(9, 64, 8), 12); + assert_eq!(e.get_block_light(10, 64, 8), 11); + } + + /// Loading 16 sections of one column must update each (x,z) sky column once, not 16 times. + #[test] + fn ingest_many_sections_dedupes_sky_columns() { + let mut e = engine(); + for sy in 0..16 { + load_section(&mut e, 0, sy, 0, air_section()); + } + e.step_nodes(0); + assert_eq!( + e.sky_column_updates, 256, + "16 air sections must not rescan the same 256 columns 16 times" + ); + } + + /// step(5) must yield during prep: a later step finishes with the same lighting as continuous. + #[test] + fn drain_stops_mid_batch_and_later_step_matches_continuous() { + let mut continuous = engine(); + for sy in 12..16 { + load_section(&mut continuous, 0, sy, 0, air_section()); + } + finish(&mut continuous); + let y = continuous.world_min_y + continuous.world_height - 1; + let want = [ + continuous.get_sky_light(8, y, 8), + continuous.get_sky_light(8, y - 15, 8), + continuous.get_sky_light(8, 200, 8), + ]; + assert_eq!(want[0], 15); + + let mut sliced = engine(); + for sy in 12..16 { + load_section(&mut sliced, 0, sy, 0, air_section()); + } + { + let _clock = super::test_clock::start(0.0, 10.0); + let remaining = sliced.step(5.0); + assert!(remaining, "budgeted prep must report remaining work"); + assert_eq!( + sliced.section_availability(0, 15, 0), + SectionAvailability::Unloaded, + "one 5ms step must not ingest the whole column" + ); + } + finish(&mut sliced); + assert_eq!(sliced.get_sky_light(8, y, 8), want[0]); + assert_eq!(sliced.get_sky_light(8, y - 15, 8), want[1]); + assert_eq!(sliced.get_sky_light(8, 200, 8), want[2]); + } + + fn x_air_tunnel() -> Vec { + let mut states = stone_section(); + for x in 0..16 { + states[block_index(x, 64, 8)] = AIR; + } + states + } + + fn block_along_x(e: &LightEngine, start: i32, count: i32) -> Vec { + (0..count).map(|i| e.get_block_light(start + i, 64, 8)).collect() + } + + /// Torch already lit; removing a non-emitting stone must pull block light into the opened path. + #[test] + fn opening_block_light_path() { + let mut e = engine(); + let mut states = x_air_tunnel(); + states[block_index(4, 64, 8)] = TORCH; + states[block_index(8, 64, 8)] = STONE; + load_section(&mut e, 0, 4, 0, states); + finish(&mut e); + assert_eq!(block_along_x(&e, 4, 8), vec![14, 13, 12, 11, 0, 0, 0, 0]); + + e.push_event(LightEvent::BlockChange { + x: 8, + y: 64, + z: 8, + state_id: AIR, + }); + finish(&mut e); + assert_eq!(e.get_block_light(9, 64, 8), 9, "light must enter opened path"); + assert_eq!(block_along_x(&e, 4, 8), vec![14, 13, 12, 11, 10, 9, 8, 7]); + } + + /// Same opening must not depend on draining the whole BFS in one step. + #[test] + fn opening_block_light_path_sliced_matches_continuous() { + let setup = |e: &mut LightEngine| { + let mut states = x_air_tunnel(); + states[block_index(4, 64, 8)] = TORCH; + states[block_index(8, 64, 8)] = STONE; + load_section(e, 0, 4, 0, states); + finish(e); + e.push_event(LightEvent::BlockChange { + x: 8, + y: 64, + z: 8, + state_id: AIR, + }); + }; + + let mut continuous = engine(); + setup(&mut continuous); + finish(&mut continuous); + let want = block_along_x(&continuous, 4, 8); + assert_eq!(want[5], 9); + + let mut sliced = engine(); + setup(&mut sliced); + sliced.step_nodes(1); + finish(&mut sliced); + assert_eq!(block_along_x(&sliced, 4, 8), want, "sliced must match continuous (opened path)"); + } + + /// Known LIGHT_ONLY 14 arriving first must seed a later Loaded air neighbor. + #[test] + fn boundary_arrives_before_loaded_blocks() { + let mut e = engine(); + let mut packed = pack_uniform_section(0); + set_nibble(&mut packed, 15, 0, 8, 14); + e.push_event(LightEvent::ServerLight { + sx: 0, + sy: 4, + sz: 0, + channel: LightChannel::Block, + kind: ServerLightKind::Data(packed), + }); + finish(&mut e); + load_section(&mut e, 1, 4, 0, x_air_tunnel()); + finish(&mut e); + assert_eq!( + e.get_block_light(16, 64, 8), + 13, + "known boundary must seed newly loaded neighbor" + ); + assert_eq!(e.get_block_light(15, 64, 8), 14, "LIGHT_ONLY stays read-only 14"); + } + + /// Reverse arrival (Loaded first, then LIGHT_ONLY) stays the existing seed path. + #[test] + fn loaded_blocks_then_boundary_still_seeds() { + let mut e = engine(); + load_section(&mut e, 1, 4, 0, x_air_tunnel()); + finish(&mut e); + let mut packed = pack_uniform_section(0); + set_nibble(&mut packed, 15, 0, 8, 14); + e.push_event(LightEvent::ServerLight { + sx: 0, + sy: 4, + sz: 0, + channel: LightChannel::Block, + kind: ServerLightKind::Data(packed), + }); + finish(&mut e); + assert_eq!(e.get_block_light(16, 64, 8), 13); + assert_eq!(e.get_block_light(15, 64, 8), 14); + } + + /// Unloading a Loaded torch column must decrease the dependent Loaded neighbor. + #[test] + fn removing_loaded_source_column() { + let mut e = engine(); + load_section(&mut e, 0, 4, 0, x_air_tunnel()); + let mut source = x_air_tunnel(); + source[block_index(16, 64, 8)] = TORCH; + load_section(&mut e, 1, 4, 0, source); + finish(&mut e); + assert_eq!(e.get_block_light(15, 64, 8), 13); + assert_eq!(e.get_block_light(16, 64, 8), 14); + + e.push_event(LightEvent::UnloadColumn { sx: 1, sz: 0 }); + finish(&mut e); + assert_eq!( + e.get_block_light(15, 64, 8), + 0, + "unloaded source must not persist in the remaining Loaded area" + ); + assert_eq!(e.section_availability(1, 4, 0), SectionAvailability::Unloaded); + } + + /// Unloaded support is unknown, not a known-zero wall: reloading the torch restores light. + #[test] + fn removing_then_reloading_source_column_restores() { + let mut e = engine(); + load_section(&mut e, 0, 4, 0, x_air_tunnel()); + let mut source = x_air_tunnel(); + source[block_index(16, 64, 8)] = TORCH; + load_section(&mut e, 1, 4, 0, source.clone()); + finish(&mut e); + e.push_event(LightEvent::UnloadColumn { sx: 1, sz: 0 }); + finish(&mut e); + assert_eq!(e.get_block_light(15, 64, 8), 0); + + load_section(&mut e, 1, 4, 0, source); + finish(&mut e); + assert_eq!(e.get_block_light(16, 64, 8), 14); + assert_eq!(e.get_block_light(15, 64, 8), 13); + } + + #[test] + fn removing_loaded_source_column_sliced_matches_continuous() { + let setup = |e: &mut LightEngine| { + load_section(e, 0, 4, 0, x_air_tunnel()); + let mut source = x_air_tunnel(); + source[block_index(16, 64, 8)] = TORCH; + load_section(e, 1, 4, 0, source); + finish(e); + e.push_event(LightEvent::UnloadColumn { sx: 1, sz: 0 }); + }; + + let mut continuous = engine(); + setup(&mut continuous); + finish(&mut continuous); + assert_eq!(continuous.get_block_light(15, 64, 8), 0); + + let mut sliced = engine(); + setup(&mut sliced); + sliced.step_nodes(2); + finish(&mut sliced); + assert_eq!( + sliced.get_block_light(15, 64, 8), + continuous.get_block_light(15, 64, 8), + "sliced must match continuous (unload source column)" + ); + } + + /// Re-ingest that opens a path (stone → air) must pull from the already-lit Loaded neighbor. + #[test] + fn ingest_replacing_blocker_opens_block_path() { + let mut e = engine(); + let mut west = x_air_tunnel(); + west[block_index(15, 64, 8)] = TORCH; + load_section(&mut e, 0, 4, 0, west); + let mut east = x_air_tunnel(); + east[block_index(16, 64, 8)] = STONE; + load_section(&mut e, 1, 4, 0, east); + finish(&mut e); + assert_eq!(e.get_block_light(15, 64, 8), 14); + assert_eq!(e.get_block_light(16, 64, 8), 0); + assert_eq!(e.get_block_light(17, 64, 8), 0); + + load_section(&mut e, 1, 4, 0, x_air_tunnel()); + finish(&mut e); + assert_eq!(e.get_block_light(16, 64, 8), 13, "ingest must wake the opened contact"); + assert_eq!(e.get_block_light(17, 64, 8), 12); + } + + /// Sky analog: unloading a LIGHT_ONLY sky support must darken the dependent Loaded air. + #[test] + fn removing_sky_boundary_column_clears_dependent() { + let mut e = engine(); + load_section(&mut e, 0, 4, 0, x_air_tunnel()); + set_sky_only_boundary(&mut e, 1, 4, 0, 15); + finish(&mut e); + assert_eq!(e.get_sky_light(16, 64, 8), 15, "LIGHT_ONLY sky stays 15"); + assert_eq!(e.get_sky_light(15, 64, 8), 14); + + e.push_event(LightEvent::UnloadColumn { sx: 1, sz: 0 }); + finish(&mut e); + assert_eq!( + e.get_sky_light(15, 64, 8), + 0, + "unloaded sky boundary must not leave residual sky" + ); + } + + /// Seed in the same batch as torch removal still erases the far section (Phase 1). + #[test] + fn seed_erases_cross_section_decrease() { + let mut e = engine(); + load_section(&mut e, 0, 4, 0, x_air_tunnel()); + load_section(&mut e, 1, 4, 0, x_air_tunnel()); + e.push_event(LightEvent::BlockChange { + x: 15, + y: 64, + z: 8, + state_id: TORCH, + }); + finish(&mut e); + e.push_event(LightEvent::BlockChange { + x: 15, + y: 64, + z: 8, + state_id: AIR, + }); + e.push_event(LightEvent::ServerLight { + sx: 0, + sy: 4, + sz: 0, + channel: LightChannel::Block, + kind: ServerLightKind::Empty, + }); + finish(&mut e); + assert_eq!(e.get_block_light(15, 64, 8), 0); + assert_eq!(e.get_block_light(16, 64, 8), 0); + } + + /// Two loaded columns must not form one view-distance-sized transaction. + #[test] + fn batch_admits_one_column_not_entire_pending_queue() { + let mut e = engine(); + for sy in 0..16 { + load_section(&mut e, 0, sy, 0, air_section()); + } + for sy in 0..16 { + load_section(&mut e, 1, sy, 0, air_section()); + } + let first = drain_until_publication(&mut e).expect("first column must publish before later pending columns"); + assert!( + first.sections.iter().all(|section| section.sx == 0 && section.sz == 0), + "first publication must stay inside the admitted column" + ); + assert!( + e.step(0.0), + "second column must remain queued after the first publication" + ); + let second = finish(&mut e).expect("second column publishes after the first unit"); + assert!( + second.sections.iter().all(|section| section.sx == 1 && section.sz == 0), + "second publication must be the next FIFO column" + ); + assert_eq!(e.get_sky_light(8, 64, 8), 15); + assert_eq!(e.get_sky_light(24, 64, 8), 15); + } + + /// Load/availability/seed of one column stay one unit so sources are not reconciled 16 times. + #[test] + fn batch_keeps_same_column_events_together() { + let mut e = engine(); + for sy in 0..16 { + load_section(&mut e, 0, sy, 0, air_section()); + } + let first = drain_until_publication(&mut e).expect("column load must publish"); + assert!(!e.step(0.0), "a single column is one admission unit"); + assert_eq!(first.sections.len(), 16); + assert!(first.sections.iter().all(|section| section.sx == 0 && section.sz == 0)); + } + + /// FIFO inside the unit: a later edit cannot enter an earlier foreign-column batch. + /// After that unit publishes, the leftover edit jumps remaining bulk. + #[test] + fn batch_fifo_keeps_later_edit_after_prior_columns() { + let mut e = engine(); + for sy in 0..16 { + load_section(&mut e, 0, sy, 0, air_section()); + } + for sy in 0..16 { + load_section(&mut e, 1, sy, 0, air_section()); + } + e.push_event(LightEvent::BlockChange { + x: 8, + y: 64, + z: 8, + state_id: STONE, + }); + let first = drain_until_publication(&mut e).unwrap(); + assert!(first.sections.iter().all(|section| section.sx == 0)); + assert_eq!(e.get_sky_light(8, 64, 8), 15, "edit must not apply inside column 0 batch"); + let second = drain_until_publication(&mut e).unwrap(); + assert!( + second.sections.iter().all(|section| section.sx == 0), + "leftover edit of the already-loaded column jumps remaining bulk" + ); + assert!(e.get_sky_light(8, 64, 8) < 15, "edit applies once its leftover unit is admitted"); + let third = drain_until_publication(&mut e).unwrap(); + assert!(third.sections.iter().all(|section| section.sx == 1)); + } + + /// A later edit of a different column jumps the whole column unit ahead of the convoy. + #[test] + fn batch_edit_jumps_foreign_column_convoy() { + let mut e = engine(); + for sy in 0..16 { + load_section(&mut e, 0, sy, 0, air_section()); + } + for sy in 0..16 { + load_section(&mut e, 1, sy, 0, air_section()); + } + e.push_event(LightEvent::BlockChange { + x: 24, + y: 64, + z: 8, + state_id: STONE, + }); + let first = drain_until_publication(&mut e).unwrap(); + assert!( + first.sections.iter().all(|section| section.sx == 1 && section.sz == 0), + "edit column unit must be admitted first" + ); + assert!(e.get_sky_light(24, 64, 8) < 15, "jumped edit applies in its own unit"); + let second = drain_until_publication(&mut e).unwrap(); + assert!(second.sections.iter().all(|section| section.sx == 0 && section.sz == 0)); + } + + /// Interactive edits must not disappear beyond a bounded bulk scan window. + #[test] + fn batch_edit_jumps_long_convoy() { + for already_loaded in [false, true] { + let mut e = engine(); + e.set_sky_light_enabled(false); + load_section(&mut e, -1, 4, 0, air_section()); + if already_loaded { + finish(&mut e); + } + // Cheap bulk events isolate admission from solver cost. More than + // 1024 events, with the edited column initially at the FIFO head. + for sx in 1..4097 { + e.push_event(LightEvent::SetAvailability { + sx, + sy: 4, + sz: 0, + availability: SectionAvailability::Loaded, + }); + } + e.push_event(LightEvent::BlockChange { + x: -8, + y: 64, + z: 8, + state_id: TORCH, + }); + // If ingest is at the head it is still its own transaction. The + // edit must be admitted at the very next boundary, not after bulk. + if !already_loaded { + e.step_nodes(usize::MAX); + e.poll_completed_publication(); + assert_eq!(e.get_block_light(-8, 64, 8), 0); + } + e.step_nodes(usize::MAX); + assert_eq!(e.get_block_light(-8, 64, 8), 14, + "edit must jump long convoy (already_loaded={already_loaded})"); + assert!(e.poll_completed_publication().is_some()); + assert_eq!(e.pending.len(), 4096); + for (index, event) in e.pending.iter().enumerate() { + assert_eq!(event_column(event), (index as i32 + 1, 0)); + } + } + } + + /// Startup bulk must not delay the other side of an edited X/Z boundary. + #[test] + fn batch_edit_promotes_pending_light_neighbors() { + for edit_at_head in [false, true] { + for (x, z, neighbors, target) in [ + (15, 8, vec![(1, 0)], (16, 8)), + (8, 15, vec![(0, 1)], (8, 16)), + (0, 0, vec![(-1, 0), (0, -1), (-1, -1)], (-1, -1)), + ] { + let mut e = engine(); + e.set_sky_light_enabled(false); + load_section(&mut e, 0, 4, 0, air_section()); + finish(&mut e); + let edit = LightEvent::BlockChange { x, y: 72, z, state_id: TORCH }; + if edit_at_head { e.push_event(edit.clone()); } + for sx in 100..164 { + load_section(&mut e, sx, 4, 0, air_section()); + } + for &(sx, sz) in &neighbors { + load_section(&mut e, sx, 4, sz, air_section()); + } + if !edit_at_head { e.push_event(edit); } + // One transaction for the edit and one per pending neighbor. + for _ in 0..=neighbors.len() { + e.step_nodes(usize::MAX); + e.poll_completed_publication(); + } + let distance = (target.0 - x).abs() + (target.1 - z).abs(); + assert_eq!(e.get_block_light(target.0, 72, target.1), 14 - distance as u8, + "neighbor must light before bulk: head={edit_at_head}, x={x}, z={z}"); + assert_eq!(e.section_availability(100, 4, 0), SectionAvailability::Unloaded); + assert_eq!(e.pending.len(), 128, "unrelated bulk remains queued"); + let early = e.get_block_light(target.0, 72, target.1); + finish(&mut e); + assert_eq!(e.get_block_light(target.0, 72, target.1), early); + } + } + } + + #[test] + fn batch_neighbor_promotion_preserves_lifecycle_and_bulk_order() { + let edit = LightEvent::BlockChange { x: 15, y: 72, z: 8, state_id: TORCH }; + let neighbor = vec![ + LightEvent::UnloadColumn { sx: 1, sz: 0 }, + LightEvent::IngestBlockSection { sx: 1, sy: 4, sz: 0, states: air_section() }, + LightEvent::SetAvailability { sx: 1, sy: 4, sz: 0, availability: SectionAvailability::Loaded }, + LightEvent::ServerLight { sx: 1, sy: 4, sz: 0, channel: LightChannel::Block, kind: ServerLightKind::Empty }, + ]; + let bulk: Vec<_> = (100..104).map(|sx| LightEvent::UnloadColumn { sx, sz: 0 }).collect(); + let mut pending = VecDeque::new(); + for (a, b) in bulk.iter().zip(&neighbor) { + pending.push_back(a.clone()); + pending.push_back(b.clone()); + } + pending.push_back(edit.clone()); + promote_edit_column(&mut pending, |sx, sz| (sx, sz) == (0, 0)); + let expected: VecDeque<_> = std::iter::once(edit) + .chain(neighbor).chain(bulk).collect(); + assert_eq!(format!("{pending:?}"), format!("{expected:?}")); + } + + /// Bulk columns keep their relative order around a jumped edit unit. + #[test] + fn batch_edit_jump_preserves_remaining_bulk_order() { + let mut e = engine(); + for sx in 0..3 { + for sy in 0..16 { + load_section(&mut e, sx, sy, 0, air_section()); + } + } + e.push_event(LightEvent::BlockChange { + x: 24, + y: 64, + z: 8, + state_id: STONE, + }); + let first = drain_until_publication(&mut e).unwrap(); + assert!(first.sections.iter().all(|section| section.sx == 1)); + let second = drain_until_publication(&mut e).unwrap(); + assert!(second.sections.iter().all(|section| section.sx == 0)); + let third = drain_until_publication(&mut e).unwrap(); + assert!(third.sections.iter().all(|section| section.sx == 2)); + } + + /// A naked BlockChange for an unknown column must not jump or become AIR. + #[test] + fn batch_edit_does_not_jump_unknown_column() { + let mut e = engine(); + for sy in 0..16 { + load_section(&mut e, 0, sy, 0, air_section()); + } + e.push_event(LightEvent::BlockChange { + x: 80, + y: 64, + z: 8, + state_id: STONE, + }); + let first = drain_until_publication(&mut e).unwrap(); + assert!(first.sections.iter().all(|section| section.sx == 0 && section.sz == 0)); + assert!( + finish(&mut e).is_none(), + "unknown edit must not publish a phantom AIR column" + ); + assert!( + e.packed_block_light(5, 4, 0).is_none(), + "unknown column must stay unknown, not a Loaded AIR fill" + ); + assert!(!e.step(0.0)); + } + + /// Column (sx,sz) known via another sy still leaves a missing sy unknown. + /// AIR-filling that sy would materialize unknown as Loaded air. + #[test] + fn batch_edit_known_column_missing_sy_does_not_materialize_air() { + let mut e = engine(); + load_section(&mut e, 0, 4, 0, air_section()); + finish(&mut e); + assert!(e.packed_block_light(0, 4, 0).is_some()); + assert_eq!(e.section_availability(0, 5, 0), SectionAvailability::Unloaded); + + for sy in 0..16 { + load_section(&mut e, 1, sy, 0, air_section()); + } + e.push_event(LightEvent::BlockChange { + x: 8, + y: 80, + z: 8, + state_id: STONE, + }); + + let mut saw_publication = false; + while let Some(publication) = drain_until_publication(&mut e) { + saw_publication = true; + assert!( + publication + .sections + .iter() + .all(|section| !(section.sx == 0 && section.sy == 5 && section.sz == 0)), + "must not publish a phantom AIR section at the missing sy" + ); + } + assert!(saw_publication, "bulk column must still publish"); + assert!( + e.packed_block_light(0, 5, 0).is_none(), + "missing sy must stay unknown, not a Loaded AIR fill" + ); + assert_eq!(e.section_availability(0, 5, 0), SectionAvailability::Unloaded); + assert!(e.packed_block_light(0, 4, 0).is_some(), "known sy stays present"); + assert!(!e.step(0.0)); + } + + /// An edit of an already-loaded column jumps ahead of later bulk ingest. + #[test] + fn batch_edit_jumps_loaded_column_ahead_of_new_bulk() { + let mut e = engine(); + for sy in 0..16 { + load_section(&mut e, 0, sy, 0, air_section()); + } + finish(&mut e); + assert_eq!(e.get_sky_light(8, 64, 8), 15); + for sy in 0..16 { + load_section(&mut e, 1, sy, 0, air_section()); + } + for sy in 0..16 { + load_section(&mut e, 2, sy, 0, air_section()); + } + e.push_event(LightEvent::BlockChange { + x: 8, + y: 64, + z: 8, + state_id: STONE, + }); + let first = drain_until_publication(&mut e).unwrap(); + assert!( + first.sections.iter().all(|section| section.sx == 0 && section.sz == 0), + "loaded edit column must jump ahead of new bulk" + ); + assert!(e.get_sky_light(8, 64, 8) < 15); + let second = drain_until_publication(&mut e).unwrap(); + assert!(second.sections.iter().all(|section| section.sx == 1 && section.sz == 0)); + let third = drain_until_publication(&mut e).unwrap(); + assert!(third.sections.iter().all(|section| section.sx == 2 && section.sz == 0)); + } + + /// BFS must reuse derived (x,z) source-meta. One air column is 256 sticks × H=256. + #[test] + fn sky_source_scan_is_memoized_across_bfs() { + let mut e = LightEngine::new(0, 256); + let (emission, light_block) = default_test_tables(); + e.set_light_tables(&emission, &light_block); + for sy in 0..16 { + load_section(&mut e, 0, sy, 0, air_section()); + } + finish(&mut e); + assert_eq!(e.get_sky_light(8, 64, 8), 15); + let rows = e.sky_source_scan_rows.get(); + assert!( + rows <= 256 * 256, + "source scan must be O(C×H), not O(P×H); rows={rows}" + ); + } + + /// Changing a blocking cell must drop stale source-meta; sky below the roof darkens. + #[test] + fn sky_source_memo_invalidates_on_occluding_block() { + let mut e = engine(); + for sy in 0..16 { + load_section(&mut e, 0, sy, 0, air_section()); + } + finish(&mut e); + assert_eq!(e.get_sky_light(8, 64, 8), 15); + let rows_before = e.sky_source_scan_rows.get(); + + e.push_event(LightEvent::BlockChange { + x: 8, + y: 100, + z: 8, + state_id: STONE, + }); + finish(&mut e); + assert!( + e.get_sky_light(8, 64, 8) < 15, + "occluding block must cut the sky source" + ); + assert!( + e.sky_source_scan_rows.get() > rows_before, + "block/shape change must invalidate derived source-meta" + ); + } + + /// Availability + unload are source-scan dependencies; sliced must match continuous. + #[test] + fn sky_source_memo_invalidates_on_availability_unload_sliced() { + let setup = |e: &mut LightEngine| { + load_section(e, 0, 4, 0, x_air_tunnel()); + e.push_event(LightEvent::SetAvailability { + sx: 1, + sy: 4, + sz: 0, + availability: SectionAvailability::LightOnly, + }); + e.push_event(LightEvent::ServerLight { + sx: 1, + sy: 4, + sz: 0, + channel: LightChannel::Sky, + kind: ServerLightKind::Data(pack_uniform_section(15)), + }); + finish(e); + e.push_event(LightEvent::UnloadColumn { sx: 1, sz: 0 }); + }; + + let mut continuous = engine(); + setup(&mut continuous); + finish(&mut continuous); + let want = continuous.get_sky_light(15, 64, 8); + + let mut sliced = engine(); + setup(&mut sliced); + sliced.step_nodes(2); + finish(&mut sliced); + assert_eq!( + sliced.get_sky_light(15, 64, 8), + want, + "sliced must match continuous after sky-boundary unload" + ); + } +} diff --git a/wasm-mesher/src/parser_v16_v17.rs b/wasm-mesher/src/parser_v16_v17.rs index f87c482..916297e 100644 --- a/wasm-mesher/src/parser_v16_v17.rs +++ b/wasm-mesher/src/parser_v16_v17.rs @@ -191,12 +191,20 @@ pub struct ChunkSectionsResult { pub struct UpdateLightV17Result { pub x: i32, pub z: i32, - /// `num_sections * 4096`, layout `idx = x + z*16 + y_abs*256` — same as - /// `assemble_light_full_column` produces for 1.18+. Default is 0 for - /// sections the server omitted. + pub trust_edges: bool, + /// `num_sections * 4096`, layout `idx = x + z*16 + y_abs*256`. + /// Omitted sections are left 0 and are **not** authoritative — use the masks. pub sky_light: Vec, - /// Same shape as `sky_light`. Default 0 for omitted sections (no light). + /// Same shape as `sky_light`. pub block_light: Vec, + pub sky_light_mask: Vec, + pub empty_sky_light_mask: Vec, + pub block_light_mask: Vec, + pub empty_block_light_mask: Vec, + pub sky_below: Option>, + pub sky_above: Option>, + pub block_below: Option>, + pub block_above: Option>, /// Total bytes consumed from the input packet (including the leading /// packet-id varint). pub bytes_read: usize, @@ -230,7 +238,7 @@ pub fn parse_update_light_v17( let x = r.read_varint()?; let z = r.read_varint()?; - let _trust_edges = r.read_u8()?; + let trust_edges = r.read_u8()? != 0; let sky_light_mask = common::read_i64_array(&mut r)?; let block_light_mask = common::read_i64_array(&mut r)?; @@ -242,38 +250,35 @@ pub fn parse_update_light_v17( let bytes_read = r.position(); - let sky_full = common::build_full_column_light( + let sky_assembled = common::build_light_with_padding( &sky_light_sections, &sky_light_mask, &empty_sky_light_mask, num_sections, - // Sections the server omitted entirely (no bit in skyLightMask or - // emptySkyLightMask) are typically air above the build limit — they - // see full daylight in the vanilla client. prismarine-chunk's - // `getSkyLight` returns 0 here, which is what makes those areas - // pitch-black; matching the vanilla 15 fixes that. - 15, - // 1.17 wire format: each light section is the raw BitArrayNoSpan byte - // buffer — byte i directly holds blocks 2i and 2i+1, with no - // long-encoding (the BE byte-reversal that 1.18+ needs would scramble - // the X coords inside every Z-row and produce the dark-shadow - // artifacts users see under trees and on slopes). false, )?; - let block_full = common::build_full_column_light( + let block_assembled = common::build_light_with_padding( &block_light_sections, &block_light_mask, &empty_block_light_mask, num_sections, - 0, false, )?; Ok(UpdateLightV17Result { x, z, - sky_light: sky_full, - block_light: block_full, + trust_edges, + sky_light: sky_assembled.world, + block_light: block_assembled.world, + sky_light_mask: common::i64_mask_to_u32_pairs(&sky_light_mask), + empty_sky_light_mask: common::i64_mask_to_u32_pairs(&empty_sky_light_mask), + block_light_mask: common::i64_mask_to_u32_pairs(&block_light_mask), + empty_block_light_mask: common::i64_mask_to_u32_pairs(&empty_block_light_mask), + sky_below: sky_assembled.below, + sky_above: sky_assembled.above, + block_below: block_assembled.below, + block_above: block_assembled.above, bytes_read, }) } @@ -605,4 +610,143 @@ mod tests { let biomes = expand_biomes_v17(Some(&too_short), NUM_SECTIONS_V17, 5); assert!(biomes.iter().all(|&v| v == 5)); } + + fn write_varint(out: &mut Vec, value: i32) { + let mut u = value as u32; + loop { + let mut byte = (u & 0x7f) as u8; + u >>= 7; + if u != 0 { + byte |= 0x80; + } + out.push(byte); + if u == 0 { + break; + } + } + } + + fn write_i64_array(out: &mut Vec, values: &[i64]) { + write_varint(out, values.len() as i32); + for v in values { + out.extend_from_slice(&v.to_be_bytes()); + } + } + + fn write_light_arrays(out: &mut Vec, sections: &[Vec]) { + write_varint(out, sections.len() as i32); + for section in sections { + write_varint(out, section.len() as i32); + out.extend_from_slice(section); + } + } + + fn encode_update_light_v17( + x: i32, + z: i32, + trust_edges: bool, + sky_mask: &[i64], + block_mask: &[i64], + empty_sky_mask: &[i64], + empty_block_mask: &[i64], + sky_sections: &[Vec], + block_sections: &[Vec], + ) -> Vec { + let mut out = Vec::new(); + write_varint(&mut out, 0x25); + write_varint(&mut out, x); + write_varint(&mut out, z); + out.push(if trust_edges { 1 } else { 0 }); + write_i64_array(&mut out, sky_mask); + write_i64_array(&mut out, block_mask); + write_i64_array(&mut out, empty_sky_mask); + write_i64_array(&mut out, empty_block_mask); + write_light_arrays(&mut out, sky_sections); + write_light_arrays(&mut out, block_sections); + out + } + + fn nibble_section(value: u8) -> Vec { + vec![value | (value << 4); common::LIGHT_SECTION_BUFFER_BYTES] + } + + #[test] + fn update_light_omitted_sky_is_not_filled_with_15() { + let packet = encode_update_light_v17( + 3, + 4, + true, + &[0b100], + &[], + &[], + &[], + &[nibble_section(3)], + &[], + ); + let result = parse_update_light_v17(&packet, NUM_SECTIONS_V17) + .expect("parse synthetic update_light"); + let omitted = &result.sky_light[0..common::BLOCK_SECTION_VOLUME]; + assert!( + !omitted.iter().all(|&v| v == 15), + "omitted sky must not be baked as 15 in the parse result" + ); + assert!(omitted.iter().all(|&v| v == 0), "omitted sky bytes stay 0, not a seed"); + assert_eq!(result.x, 3); + assert_eq!(result.z, 4); + assert!(result.trust_edges); + assert!(!common::mask_bit_get(&result.sky_light_mask, 1), "world section 0 omitted"); + assert!(common::mask_bit_get(&result.sky_light_mask, 2), "world section 1 has data"); + let present = &result.sky_light[common::BLOCK_SECTION_VOLUME..common::BLOCK_SECTION_VOLUME * 2]; + assert!(present.iter().all(|&v| v == 3)); + } + + #[test] + fn captured_1_17_1_update_light_fixture_parses() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../chunk-packet-fixtures/fixtures/map_chunk/1.17.1/5_0.update_light.bin"); + let packet = fs::read(&path).unwrap_or_else(|_| panic!("missing fixture {:?}", path)); + let result = parse_update_light_v17(&packet, NUM_SECTIONS_V17) + .expect("parse captured 1.17.1 update_light"); + assert_eq!(result.x, 7); + assert_eq!(result.z, 0); + assert!(result.trust_edges); + assert!(result.sky_light_mask.iter().all(|&w| w == 0)); + assert!(result.empty_sky_light_mask.iter().all(|&w| w == 0)); + assert!(result.block_light_mask.iter().all(|&w| w == 0)); + assert_eq!(result.empty_block_light_mask[0] & 0x3e, 0x3e); + assert!(result.sky_light.iter().all(|&v| v == 0)); + } + + #[test] + fn captured_1_17_1_second_fixture_parses() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../chunk-packet-fixtures/fixtures/map_chunk/1.17.1/7_0.update_light.bin"); + let packet = fs::read(&path).unwrap_or_else(|_| panic!("missing fixture {:?}", path)); + let result = parse_update_light_v17(&packet, NUM_SECTIONS_V17) + .expect("parse captured 1.17.1 update_light"); + assert_eq!(result.x, 6); + assert_eq!(result.z, 0); + assert!(result.trust_edges); + assert_eq!(result.empty_block_light_mask[0] & 0x3e, 0x3e); + } + + #[test] + fn update_light_keeps_padding_section() { + let packet = encode_update_light_v17( + 0, + 0, + false, + &[0b1], + &[], + &[], + &[], + &[nibble_section(9)], + &[], + ); + let result = parse_update_light_v17(&packet, NUM_SECTIONS_V17).unwrap(); + assert!(common::mask_bit_get(&result.sky_light_mask, 0)); + let below = result.sky_below.expect("padding below"); + assert!(below.iter().all(|&v| v == 9)); + assert!(result.sky_light.iter().all(|&v| v == 0)); + } } diff --git a/wasm-mesher/tests/bench-dig-column-paths-entry.ts b/wasm-mesher/tests/bench-dig-column-paths-entry.ts new file mode 100644 index 0000000..2991d8b --- /dev/null +++ b/wasm-mesher/tests/bench-dig-column-paths-entry.ts @@ -0,0 +1,6 @@ +import { benchDigColumnPaths } from './bench-dig-column-paths' + +//@ts-ignore +const wasm = require('../pkg/wasm_mesher.js') + +void benchDigColumnPaths(wasm) diff --git a/wasm-mesher/tests/bench-dig-column-paths.ts b/wasm-mesher/tests/bench-dig-column-paths.ts new file mode 100644 index 0000000..88f2639 --- /dev/null +++ b/wasm-mesher/tests/bench-dig-column-paths.ts @@ -0,0 +1,315 @@ +/** + * Bench: cost of ONE block edit in a settled 3x3 neighbourhood, split by the + * column-mesh paths `processColumnTick` can actually take. + * + * Mirrors `mesherWasm.ts` exactly: + * - a 3x3x3 owner stencil dirties 9 columns (3 sections each), + * - `collectChunksForColumnUnion` returns 9 columns per target, so the + * `chunkCount === 1` fused single path is unreachable, + * - `multi_fused` needs EVERY column of the neighbourhood in a parsed cache, + * - `blockUpdate` deletes the edited column's parsed cache, so the edited + * neighbourhood falls back to two-step (WASM parse per neighbour + one JS + * column walk + typed-array build + generate_geometry_multi). + */ +import { getChunk, VERSION } from '../../src/mesher-legacy/test/run/chunk.js' +import { bitMap, chunkData, biomes } from '../../test-snapshots/1.16.5/chunk.json' +import { convertChunkToWasm, getBlockMeta } from '../../src/wasm-mesher/bridge/convertChunk.js' + +const WORLD_MIN_Y = 0 +const WORLD_HEIGHT = 256 +const NUM_SECTIONS = 16 +const MAX_BITS_PER_BLOCK = 15 +const DEFAULT_BIOME = 1 +const COLUMNS = 9 // 3x3 neighbourhood +const TARGETS = 9 // 3x3x3 owner stencil dirties 9 columns + +const OFFSETS: Array<[number, number]> = [] +for (const dx of [-16, 0, 16]) for (const dz of [-16, 0, 16]) OFFSETS.push([dx, dz]) + +function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b) + return sorted[Math.floor(sorted.length / 2)] +} + +export async function benchDigColumnPaths(wasm: typeof import('../pkg/wasm_mesher.js')) { + const chunk = getChunk() + const meta = getBlockMeta(VERSION) + const chunkBytes = new Uint8Array(Buffer.from(chunkData.data)) + const biomesCells = new Int32Array(biomes ?? []) + const bitMapLoHi1 = new Uint32Array([bitMap >>> 0, 0]) + + const chunkXs = new Int32Array(COLUMNS) + const chunkZs = new Int32Array(COLUMNS) + const bitMapLoHiAll = new Uint32Array(COLUMNS * 2) + const numSectionsList = new Uint32Array(COLUMNS) + const chunkDataList: Uint8Array[] = [] + const biomesList: Int32Array[] = [] + const skyLightList: Uint8Array[] = [] + const blockLightList: Uint8Array[] = [] + for (let i = 0; i < COLUMNS; i++) { + chunkXs[i] = OFFSETS[i][0] + chunkZs[i] = OFFSETS[i][1] + bitMapLoHiAll[i * 2] = bitMap >>> 0 + bitMapLoHiAll[i * 2 + 1] = 0 + numSectionsList[i] = NUM_SECTIONS + chunkDataList.push(chunkBytes) + biomesList.push(biomesCells) + skyLightList.push(new Uint8Array(0)) + blockLightList.push(new Uint8Array(0)) + } + + const fusedMulti = () => + (wasm as any).generateGeometryFromParsedV16V17Multi( + chunkDataList, + bitMapLoHiAll, + numSectionsList, + MAX_BITS_PER_BLOCK, + biomesList, + DEFAULT_BIOME, + skyLightList, + blockLightList, + chunkXs, + chunkZs, + 0, + WORLD_MIN_Y, + 0, + WORLD_HEIGHT, + WORLD_MIN_Y, + WORLD_MIN_Y + WORLD_HEIGHT, + WORLD_MIN_Y, + meta.invisibleBlocks, + meta.transparentBlocks, + meta.noAoBlocks, + meta.cullIdenticalBlocks, + meta.occludingBlocks, + true, + false, + 15 + ) + + const wasmParseOne = () => (wasm as any).parseChunkSectionsV16V17(chunkBytes, bitMapLoHi1, NUM_SECTIONS, MAX_BITS_PER_BLOCK, biomesCells, DEFAULT_BIOME) + + // --- two-step: 8 neighbour WASM parses + 1 cached edited column + build + mesh + const jsWalk = () => convertChunkToWasm(chunk, VERSION, 0, 0, WORLD_MIN_Y, WORLD_HEIGHT) + const editedConversion = jsWalk() // conversion cache holds this after the first walk + + const twoStepMulti = () => { + const conversions: any[] = [] + for (let i = 0; i < COLUMNS - 1; i++) { + const parsed = wasmParseOne() + conversions.push({ + blockStates: parsed.blockStates, + blockLight: new Uint8Array(parsed.blockStates.length), + skyLight: new Uint8Array(parsed.blockStates.length).fill(15), + biomesArray: parsed.biomes + }) + } + conversions.push(editedConversion) // cache hit for the edited column + + const perChunkLen = conversions[0].blockStates.length + const xs = new Int32Array(COLUMNS) + const zs = new Int32Array(COLUMNS) + const blockStatesAll = new Uint16Array(perChunkLen * COLUMNS) + const blockLightAll = new Uint8Array(perChunkLen * COLUMNS) + const skyLightAll = new Uint8Array(perChunkLen * COLUMNS) + const biomesAll = new Uint8Array(perChunkLen * COLUMNS) + for (let i = 0; i < COLUMNS; i++) { + xs[i] = OFFSETS[i][0] + zs[i] = OFFSETS[i][1] + blockStatesAll.set(conversions[i].blockStates, perChunkLen * i) + blockLightAll.set(conversions[i].blockLight, perChunkLen * i) + skyLightAll.set(conversions[i].skyLight, perChunkLen * i) + biomesAll.set(conversions[i].biomesArray, perChunkLen * i) + } + return (wasm as any).generate_geometry_multi( + 0, + WORLD_MIN_Y, + 0, + WORLD_HEIGHT, + WORLD_MIN_Y, + WORLD_MIN_Y + WORLD_HEIGHT, + WORLD_MIN_Y, + xs, + zs, + blockStatesAll, + blockLightAll, + skyLightAll, + biomesAll, + meta.invisibleBlocks, + meta.transparentBlocks, + meta.noAoBlocks, + meta.cullIdenticalBlocks, + meta.occludingBlocks, + true, + false, + 15 + ) + } + + // --- same neighbourhood, but only the Y range the 3x3x3 stencil dirtied. + // `Mesher::generate_with_world` iterates `section_y..section_y+section_height`, + // so the WASM entry already supports a narrow Y window. + const buildTwoStepArrays = () => { + const conversions: any[] = [] + for (let i = 0; i < COLUMNS - 1; i++) { + const parsed = wasmParseOne() + conversions.push({ + blockStates: parsed.blockStates, + blockLight: new Uint8Array(parsed.blockStates.length), + skyLight: new Uint8Array(parsed.blockStates.length).fill(15), + biomesArray: parsed.biomes + }) + } + conversions.push(editedConversion) + const perChunkLen = conversions[0].blockStates.length + const xs = new Int32Array(COLUMNS) + const zs = new Int32Array(COLUMNS) + const blockStatesAll = new Uint16Array(perChunkLen * COLUMNS) + const blockLightAll = new Uint8Array(perChunkLen * COLUMNS) + const skyLightAll = new Uint8Array(perChunkLen * COLUMNS) + const biomesAll = new Uint8Array(perChunkLen * COLUMNS) + for (let i = 0; i < COLUMNS; i++) { + xs[i] = OFFSETS[i][0] + zs[i] = OFFSETS[i][1] + blockStatesAll.set(conversions[i].blockStates, perChunkLen * i) + blockLightAll.set(conversions[i].blockLight, perChunkLen * i) + skyLightAll.set(conversions[i].skyLight, perChunkLen * i) + biomesAll.set(conversions[i].biomesArray, perChunkLen * i) + } + return { xs, zs, blockStatesAll, blockLightAll, skyLightAll, biomesAll } + } + const prebuilt = buildTwoStepArrays() + + const meshYRange = (sectionY: number, height: number) => + (wasm as any).generate_geometry_multi( + 0, + sectionY, + 0, + height, + WORLD_MIN_Y, + WORLD_MIN_Y + WORLD_HEIGHT, + WORLD_MIN_Y, + prebuilt.xs, + prebuilt.zs, + prebuilt.blockStatesAll, + prebuilt.blockLightAll, + prebuilt.skyLightAll, + prebuilt.biomesAll, + meta.invisibleBlocks, + meta.transparentBlocks, + meta.noAoBlocks, + meta.cullIdenticalBlocks, + meta.occludingBlocks, + true, + false, + 15 + ) + + const time = (label: string, fn: () => unknown, runs = 5) => { + fn() // warmup + const samples: number[] = [] + for (let i = 0; i < runs; i++) { + const t0 = performance.now() + fn() + samples.push(performance.now() - t0) + } + const med = median(samples) + console.log(` ${label.padEnd(46)} ${med.toFixed(2)}ms (min ${Math.min(...samples).toFixed(2)} max ${Math.max(...samples).toFixed(2)})`) + return med + } + + console.log(`\n=== one-column primitives (${VERSION} fixture, ${WORLD_HEIGHT} height) ===`) + const tParse = time('parseChunkSectionsV16V17 (1 column)', wasmParseOne) + const tJsWalk = time('convertChunkToWasm JS walk (1 column)', jsWalk) + + console.log(`\n=== per target column, 9-column neighbourhood ===`) + const tFused = time('multi_fused generateGeometryFrom...Multi', fusedMulti) + const tTwoStep = time('two_step_multi (8 parses+build+mesh)', twoStepMulti) + + const twoStepTickCachedDig = () => { + const parseCache = new Map>() + let parses = 0 + for (let t = 0; t < TARGETS; t++) { + const conversions: any[] = [] + for (let i = 0; i < COLUMNS - 1; i++) { + const key = `${OFFSETS[i][0]},${OFFSETS[i][1]}` + let parsed = parseCache.get(key) + if (!parsed) { + parsed = wasmParseOne() + parseCache.set(key, parsed) + parses++ + } + conversions.push({ + blockStates: parsed.blockStates, + blockLight: new Uint8Array(parsed.blockStates.length), + skyLight: new Uint8Array(parsed.blockStates.length).fill(15), + biomesArray: parsed.biomes + }) + } + conversions.push(editedConversion) + const perChunkLen = conversions[0].blockStates.length + const xs = new Int32Array(COLUMNS) + const zs = new Int32Array(COLUMNS) + const blockStatesAll = new Uint16Array(perChunkLen * COLUMNS) + const blockLightAll = new Uint8Array(perChunkLen * COLUMNS) + const skyLightAll = new Uint8Array(perChunkLen * COLUMNS) + const biomesAll = new Uint8Array(perChunkLen * COLUMNS) + for (let i = 0; i < COLUMNS; i++) { + xs[i] = OFFSETS[i][0] + zs[i] = OFFSETS[i][1] + blockStatesAll.set(conversions[i].blockStates, perChunkLen * i) + blockLightAll.set(conversions[i].blockLight, perChunkLen * i) + skyLightAll.set(conversions[i].skyLight, perChunkLen * i) + biomesAll.set(conversions[i].biomesArray, perChunkLen * i) + } + ;(wasm as any).generate_geometry_multi( + 0, + WORLD_MIN_Y, + 0, + WORLD_HEIGHT, + WORLD_MIN_Y, + WORLD_MIN_Y + WORLD_HEIGHT, + WORLD_MIN_Y, + xs, + zs, + blockStatesAll, + blockLightAll, + skyLightAll, + biomesAll, + meta.invisibleBlocks, + meta.transparentBlocks, + meta.noAoBlocks, + meta.cullIdenticalBlocks, + meta.occludingBlocks, + true, + false, + 15 + ) + } + return parses + } + + console.log(`\n=== mesh Y-window (same 9-column neighbourhood, prebuilt arrays) ===`) + const tFullY = time('full column Y=0..256 (what the tick does)', () => meshYRange(WORLD_MIN_Y, WORLD_HEIGHT)) + const tY48 = time('3 sections Y=48..96 (stencil window)', () => meshYRange(48, 48)) + const tY16 = time('1 section Y=64..80', () => meshYRange(64, 16)) + + const perChunkLen = 16 * 16 * WORLD_HEIGHT + const bytesPerBuild = perChunkLen * COLUMNS * (2 + 1 + 1 + 1) + + console.log(`\n=== ONE DIG (3x3x3 stencil -> ${TARGETS} dirty columns) ===`) + const tTickCached = time('two_step tick-cached (9 unique parses)', twoStepTickCachedDig) + console.log(` multi_fused everywhere ${(tFused * TARGETS).toFixed(0)}ms`) + console.log(` two_step_multi everywhere ${(tTwoStep * TARGETS + tJsWalk).toFixed(0)}ms`) + console.log(` two_step tick-cached (this change) ${tTickCached.toFixed(0)}ms`) + console.log(` slowdown factor ${((tTwoStep * TARGETS + tJsWalk) / (tFused * TARGETS)).toFixed(1)}x`) + console.log( + ` redundant WASM parses per dig ${(COLUMNS - 1) * TARGETS} → ${COLUMNS - 1} unique (${(tParse * (COLUMNS - 1)).toFixed(0)}ms after tick cache)` + ) + console.log(` typed-array copies per dig ${((bytesPerBuild * TARGETS) / 1024 / 1024).toFixed(1)}MB`) + console.log(` blocks visited by the mesher ${((perChunkLen * COLUMNS * TARGETS) / 1e6).toFixed(1)}M`) + console.log(` full-Y mesh x ${TARGETS} columns ${(tFullY * TARGETS).toFixed(0)}ms`) + console.log(` stencil-Y mesh x ${TARGETS} columns ${(tY48 * TARGETS).toFixed(0)}ms (${(tFullY / tY48).toFixed(1)}x cheaper)`) + console.log(` 1-section-Y mesh x ${TARGETS} columns ${(tY16 * TARGETS).toFixed(0)}ms (${(tFullY / tY16).toFixed(1)}x cheaper)`) + console.log() +} diff --git a/wasm-mesher/tests/bench-post-phase-entry.ts b/wasm-mesher/tests/bench-post-phase-entry.ts new file mode 100644 index 0000000..7e0ca7d --- /dev/null +++ b/wasm-mesher/tests/bench-post-phase-entry.ts @@ -0,0 +1,2 @@ +import { benchPostPhase } from './bench-post-phase' +benchPostPhase() diff --git a/wasm-mesher/tests/bench-post-phase.ts b/wasm-mesher/tests/bench-post-phase.ts new file mode 100644 index 0000000..c6d3d86 --- /dev/null +++ b/wasm-mesher/tests/bench-post-phase.ts @@ -0,0 +1,80 @@ +/** + * Bench: the per-section `post` phase of `processColumnTick` — the part that + * runs for EVERY requested section after the WASM call: a 4096-block + * `world.getBlock` walk feeding VisGraph + block-entity metadata. + * + * One dig with the 3x3x3 owner stencil requests 27 sections, so this walk + * runs 27 times per dig. + */ +import { Vec3 } from 'vec3' +import { getChunk, VERSION } from '../../src/mesher-legacy/test/run/chunk.js' +import { World } from '../../src/mesher-shared/world.js' +import { getBlockMeta } from '../../src/wasm-mesher/bridge/convertChunk.js' +import { VisGraph } from '../../src/mesher-shared/visGraph.js' +import { packVisibilitySet } from '../../src/mesher-shared/visibilitySet.js' +import { collectBlockEntityMetadata } from '../../src/mesher-shared/blockEntityMetadata.js' + +const SECTIONS_PER_DIG = 27 // 3x3x3 owner stencil + +function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b) + return sorted[Math.floor(sorted.length / 2)] +} + +export function benchPostPhase() { + const world = new World(VERSION) + world.config = { ...world.config, version: VERSION } + ;(globalThis as any).world = world + ;(globalThis as any).Vec3 = Vec3 + for (const dx of [-16, 0, 16]) { + for (const dz of [-16, 0, 16]) { + world.addColumn(dx, dz, (getChunk() as any).toJson()) + } + } + const { occludingLookup } = getBlockMeta(VERSION) + + // Verbatim copy of the per-section walk in `processColumnTick`. + const sectionWalk = (sx: number, sy: number, sz: number) => { + const signs: Record = {} + const heads: Record = {} + const banners: Record = {} + const beTarget = { signs, heads, banners } + const beOpts = { disableBlockEntityTextures: world.config.disableBlockEntityTextures } + const visGraph = new VisGraph() + const cursor = new Vec3(0, 0, 0) + for (cursor.y = sy; cursor.y < sy + 16; cursor.y++) { + for (cursor.z = sz; cursor.z < sz + 16; cursor.z++) { + for (cursor.x = sx; cursor.x < sx + 16; cursor.x++) { + const b = world.getBlock(cursor) + if (!b) continue + if (occludingLookup[b.stateId]) visGraph.setOpaque(cursor.x - sx, cursor.y - sy, cursor.z - sz) + collectBlockEntityMetadata(b, cursor.x, cursor.y, cursor.z, beTarget, beOpts, world) + } + } + } + return packVisibilitySet(visGraph.resolve()) + } + + const time = (label: string, fn: () => unknown, runs = 5) => { + fn() + const samples: number[] = [] + for (let i = 0; i < runs; i++) { + const t0 = performance.now() + fn() + samples.push(performance.now() - t0) + } + const med = median(samples) + console.log(` ${label.padEnd(46)} ${med.toFixed(2)}ms (min ${Math.min(...samples).toFixed(2)} max ${Math.max(...samples).toFixed(2)})`) + return med + } + + console.log(`\n=== post phase: per-section world walk (${VERSION} fixture) ===`) + const tSolid = time('1 section, solid terrain (y=64)', () => sectionWalk(0, 64, 0)) + const tAir = time('1 section, air (y=192)', () => sectionWalk(0, 192, 0)) + + console.log(`\n=== ONE DIG (${SECTIONS_PER_DIG} requested sections) ===`) + console.log(` world walk, all solid ${(tSolid * SECTIONS_PER_DIG).toFixed(0)}ms`) + console.log(` world walk, all air ${(tAir * SECTIONS_PER_DIG).toFixed(0)}ms`) + console.log(` getBlock calls ${((4096 * SECTIONS_PER_DIG) / 1000).toFixed(0)}k`) + console.log() +}