minecraft-renderer 0.1.38 → 0.1.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,155 @@
1
+ //@ts-nocheck
2
+ // Worker-side cache for `convertChunkToWasm` outputs in column mode.
3
+ //
4
+ // In column-mode meshing, every dirty column triggers a 3x3 conversion
5
+ // (target + up to 8 neighbors). During the initial-load wave each column ends
6
+ // up being converted up to 9 times (once for itself, once per surrounding
7
+ // column that lists it as a neighbor). This cache short-circuits the
8
+ // redundant conversions.
9
+ //
10
+ // Correctness invariants:
11
+ // - Cache value MUST NOT be mutated by consumers. The `ChunkConversionResult`
12
+ // typed arrays are used as `set()` SOURCES (and as wasm INPUTS) in the
13
+ // mesher tick, never as destinations, so the read-only contract holds.
14
+ // - Identity-based key validation: a hit is served only when the stored
15
+ // chunk reference is `===` the live `world.getColumn(x,z)` reference, so a
16
+ // replaced column (via `world.addColumn`) can never accidentally serve
17
+ // stale data even if the explicit invalidation in the message handler is
18
+ // skipped (defense in depth).
19
+ // - In-place mutation of an existing column object (e.g. via
20
+ // `world.setBlockStateId` from a `blockUpdate` message) preserves identity
21
+ // but changes content. The `blockUpdate` handler explicitly invalidates
22
+ // the affected `(chunkX, chunkZ)` to handle this.
23
+
24
+ import type { ChunkConversionResult } from '../wasm-lib/convertChunk'
25
+
26
+ // Hard cap on entries. A 12x12 visible area ≈ 144 columns; 64 keeps the hot
27
+ // ~8x8 window resident. Tunable.
28
+ export const CONVERSION_CACHE_LIMIT = 64
29
+
30
+ // Active limit, mutable via `setConversionCacheLimit`. `0` disables caching
31
+ // entirely (memory hotfix path for low-RAM environments such as iOS Safari).
32
+ let activeLimit = CONVERSION_CACHE_LIMIT
33
+
34
+ interface CacheEntry {
35
+ chunkRef: any
36
+ version: string
37
+ worldMinY: number
38
+ worldMaxY: number
39
+ result: ChunkConversionResult
40
+ }
41
+
42
+ const cache = new Map<string, CacheEntry>()
43
+ let hits = 0
44
+ let misses = 0
45
+
46
+ const keyOf = (x: number, z: number) => `${x},${z}`
47
+
48
+ const isDev = () => {
49
+ try {
50
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
51
+ return typeof process === 'undefined' || process.env?.NODE_ENV !== 'production'
52
+ } catch {
53
+ return true
54
+ }
55
+ }
56
+
57
+ export interface GetOrConvertResult {
58
+ result: ChunkConversionResult
59
+ hit: boolean
60
+ }
61
+
62
+ export function getOrConvertColumn(
63
+ x: number,
64
+ z: number,
65
+ chunkRef: any,
66
+ version: string,
67
+ worldMinY: number,
68
+ worldMaxY: number,
69
+ convert: () => ChunkConversionResult,
70
+ liveChunkRef?: any
71
+ ): GetOrConvertResult {
72
+ const k = keyOf(x, z)
73
+ if (activeLimit <= 0) {
74
+ // Cache disabled — bypass entirely. Drop any stale entry that may
75
+ // pre-date the disable call.
76
+ if (cache.size > 0) cache.delete(k)
77
+ misses++
78
+ return { result: convert(), hit: false }
79
+ }
80
+ const e = cache.get(k)
81
+ if (
82
+ e
83
+ && e.chunkRef === chunkRef
84
+ && e.version === version
85
+ && e.worldMinY === worldMinY
86
+ && e.worldMaxY === worldMaxY
87
+ ) {
88
+ // Defense-in-depth: if the live world ref no longer matches the stored
89
+ // ref, the explicit invalidation path was missed somewhere upstream.
90
+ if (liveChunkRef !== undefined && liveChunkRef !== e.chunkRef) {
91
+ if (isDev()) {
92
+ console.warn(`[WASM Mesher] conversion cache identity drift at ${k} — invalidation likely missed`)
93
+ }
94
+ cache.delete(k)
95
+ } else {
96
+ // LRU bump: re-insert to move to most-recent.
97
+ cache.delete(k)
98
+ cache.set(k, e)
99
+ hits++
100
+ return { result: e.result, hit: true }
101
+ }
102
+ }
103
+
104
+ const result = convert()
105
+ cache.delete(k)
106
+ cache.set(k, { chunkRef, version, worldMinY, worldMaxY, result })
107
+ while (cache.size > activeLimit) {
108
+ const oldest = cache.keys().next().value
109
+ if (oldest === undefined) break
110
+ cache.delete(oldest)
111
+ }
112
+ misses++
113
+ return { result, hit: false }
114
+ }
115
+
116
+ export function setConversionCacheLimit(n: number): void {
117
+ activeLimit = Math.max(0, n | 0)
118
+ if (activeLimit === 0) {
119
+ cache.clear()
120
+ return
121
+ }
122
+ while (cache.size > activeLimit) {
123
+ const oldest = cache.keys().next().value
124
+ if (oldest === undefined) break
125
+ cache.delete(oldest)
126
+ }
127
+ }
128
+
129
+ export function getConversionCacheLimit(): number {
130
+ return activeLimit
131
+ }
132
+
133
+ export function invalidateConversion(x: number, z: number): boolean {
134
+ return cache.delete(keyOf(x, z))
135
+ }
136
+
137
+ export function clearConversionCache(): void {
138
+ cache.clear()
139
+ }
140
+
141
+ export function getConversionCacheSize(): number {
142
+ return cache.size
143
+ }
144
+
145
+ export function consumeConversionCacheStats(): { hits: number, misses: number } {
146
+ const r = { hits, misses }
147
+ hits = 0
148
+ misses = 0
149
+ return r
150
+ }
151
+
152
+ // Test-only helper: peek without bumping LRU or mutating counters.
153
+ export function _peekConversionCache(x: number, z: number): CacheEntry | undefined {
154
+ return cache.get(keyOf(x, z))
155
+ }
@@ -0,0 +1,56 @@
1
+ //@ts-nocheck
2
+ // Tracks requested section keys and their pending dirty counts separately from
3
+ // the section keys that a full-column WASM meshing call can generate.
4
+ //
5
+ // Why this exists:
6
+ // - The legacy per-section path usually generates exactly the requested key.
7
+ // - The column path can mesh a whole chunk column and produce data for more
8
+ // sections than the main thread requested. `WorldRendererCommon` throws on
9
+ // `sectionFinished` for keys it did not register, so the worker must filter
10
+ // outgoing `geometry`/`sectionFinished` events through this tracker.
11
+ // - Each `setSectionDirty(value=true)` is one logical request and must yield
12
+ // exactly one `sectionFinished` event, mirroring the existing per-key
13
+ // counter semantics of `dirtySections`.
14
+
15
+ export class SectionRequestTracker {
16
+ private readonly counts = new Map<string, number>()
17
+
18
+ /** Register one pending request for `key` (called per dirty-section ingest). */
19
+ addRequest (key: string): void {
20
+ this.counts.set(key, (this.counts.get(key) ?? 0) + 1)
21
+ }
22
+
23
+ /** True if at least one request for `key` is still pending. */
24
+ hasPending (key: string): boolean {
25
+ return (this.counts.get(key) ?? 0) > 0
26
+ }
27
+
28
+ /** Pending request count for `key` (0 if none). */
29
+ pendingCount (key: string): number {
30
+ return this.counts.get(key) ?? 0
31
+ }
32
+
33
+ /**
34
+ * Consume one pending request for `key`. Returns true if a request was
35
+ * consumed, false if there was nothing pending. Callers in the column
36
+ * path must treat `false` as a contract violation (the main thread did
37
+ * not request this key).
38
+ */
39
+ consumeOne (key: string): boolean {
40
+ const c = this.counts.get(key) ?? 0
41
+ if (c <= 0) return false
42
+ if (c === 1) this.counts.delete(key)
43
+ else this.counts.set(key, c - 1)
44
+ return true
45
+ }
46
+
47
+ /** Clear all pending requests (used on worker reset). */
48
+ clear (): void {
49
+ this.counts.clear()
50
+ }
51
+
52
+ /** Number of distinct keys with pending requests. */
53
+ size (): number {
54
+ return this.counts.size
55
+ }
56
+ }
@@ -8,6 +8,7 @@ import { World, BlockModelPartsResolved, WorldBlock as Block, WorldBlock, worldC
8
8
  import { BlockElement, buildRotationMatrix, elemFaces, matmul3, matmulmat3, vecadd3, vecsub3 } from './modelsGeometryCommon'
9
9
  import { INVISIBLE_BLOCKS } from './worldConstants'
10
10
  import { MesherGeometryOutput, HighestBlockInfo } from './shared'
11
+ import { collectBlockEntityMetadata } from './blockEntityMetadata'
11
12
 
12
13
  // Log function disabled by default for zero overhead in production hot loops
13
14
  const ENABLE_TS_LOGS = false
@@ -598,52 +599,7 @@ export function getSectionGeometry(sx: number, sy: number, sz: number, world: Wo
598
599
  for (cursor.x = sx; cursor.x < sx + 16; cursor.x++) {
599
600
  let block = world.getBlock(cursor, blockProvider, attr)!
600
601
  if (INVISIBLE_BLOCKS.has(block.name)) continue
601
- if ((block.name.includes('_sign') || block.name === 'sign') && !world.config.disableBlockEntityTextures) {
602
- const key = `${cursor.x},${cursor.y},${cursor.z}`
603
- const props: any = block.getProperties()
604
- const facingRotationMap = {
605
- 'north': 2,
606
- 'south': 0,
607
- 'west': 1,
608
- 'east': 3
609
- }
610
- const isWall = block.name.endsWith('wall_sign') || block.name.endsWith('wall_hanging_sign')
611
- const isHanging = block.name.endsWith('hanging_sign')
612
- attr.signs[key] = {
613
- isWall,
614
- isHanging,
615
- rotation: isWall ? facingRotationMap[props.facing] : +props.rotation
616
- }
617
- } else if (block.name === 'player_head' || block.name === 'player_wall_head') {
618
- const key = `${cursor.x},${cursor.y},${cursor.z}`
619
- const props: any = block.getProperties()
620
- const facingRotationMap = {
621
- 'north': 0,
622
- 'south': 2,
623
- 'west': 3,
624
- 'east': 1
625
- }
626
- const isWall = block.name === 'player_wall_head'
627
- attr.heads[key] = {
628
- isWall,
629
- rotation: isWall ? facingRotationMap[props.facing] : +props.rotation
630
- }
631
- } else if (block.name.includes('_banner') && !world.config.disableBlockEntityTextures) {
632
- const key = `${cursor.x},${cursor.y},${cursor.z}`
633
- const props: any = block.getProperties()
634
- const facingRotationMap = {
635
- 'north': 2,
636
- 'south': 0,
637
- 'west': 1,
638
- 'east': 3
639
- }
640
- const isWall = block.name.endsWith('_wall_banner')
641
- attr.banners[key] = {
642
- isWall,
643
- blockName: block.name, // Pass block name for base color extraction
644
- rotation: isWall ? facingRotationMap[props.facing] : (props.rotation === undefined ? 0 : +props.rotation)
645
- }
646
- }
602
+ collectBlockEntityMetadata(block, cursor.x, cursor.y, cursor.z, attr, { disableBlockEntityTextures: world.config.disableBlockEntityTextures })
647
603
  const biome = block.biome.name
648
604
 
649
605
  if (world.preflat) { // 10% perf
@@ -1,7 +1,6 @@
1
1
  //@ts-nocheck
2
2
  import { BlockType } from '../playground/shared'
3
3
 
4
- export const IS_FULL_WORLD_SECTION = false
5
4
  export const SECTION_HEIGHT = 16
6
5
 
7
6
  // only here for easier testing
@@ -18,7 +17,8 @@ export const defaultMesherConfig = {
18
17
  // textureSize: 1024, // for testing
19
18
  debugModelVariant: undefined as undefined | number[],
20
19
  clipWorldBelowY: undefined as undefined | number,
21
- disableBlockEntityTextures: false
20
+ disableBlockEntityTextures: false,
21
+ disableConversionCache: false,
22
22
  }
23
23
 
24
24
  export type CustomBlockModels = {
@@ -65,7 +65,26 @@ export type MesherGeometryOutput = {
65
65
 
66
66
  export interface MesherMainEvents {
67
67
  geometry: { type: 'geometry'; key: string; geometry: MesherGeometryOutput; workerIndex: number };
68
- sectionFinished: { type: 'sectionFinished'; key: string; workerIndex: number; processTime?: number };
68
+ sectionFinished: {
69
+ type: 'sectionFinished';
70
+ key: string;
71
+ workerIndex: number;
72
+ processTime?: number;
73
+ pre?: number;
74
+ wasm?: number;
75
+ post?: number;
76
+ // Pre-stage substages (added for column-mode perf instrumentation).
77
+ // All times in ms. `preNeighborConvert` is a SUM across neighbors;
78
+ // divide by `preNeighborCount` for per-neighbor average.
79
+ preTargetConvert?: number;
80
+ preNeighborConvert?: number;
81
+ preNeighborCount?: number;
82
+ preTypedArrayBuild?: number;
83
+ preOther?: number;
84
+ // Per-event counts for the column-mode conversion cache.
85
+ preCacheHits?: number;
86
+ preCacheMisses?: number;
87
+ };
69
88
  blockStateModelInfo: { type: 'blockStateModelInfo'; info: Record<string, BlockStateModelInfo> };
70
89
  heightmap: { type: 'heightmap'; key: string; heightmap: Int16Array };
71
90
  }
@@ -0,0 +1,231 @@
1
+ //@ts-nocheck
2
+ import { test, expect } from 'vitest'
3
+ import Chunks from 'prismarine-chunk'
4
+ import MinecraftData from 'minecraft-data'
5
+ import { Vec3 } from 'vec3'
6
+ import { World } from '../world'
7
+ import { computeHeightmap } from '../computeHeightmap'
8
+ import { INVISIBLE_BLOCKS } from '../worldConstants'
9
+ import { extractColumnHeightmap, WasmGeometryOutput } from '../../wasm-lib/render-from-wasm'
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Heightmap parity test
13
+ //
14
+ // Verifies that the (future) Rust full-column heightmap (as exposed via the
15
+ // `extractColumnHeightmap` adapter) yields the same 256-entry Int16Array as
16
+ // the existing JS source-of-truth `computeHeightmap`, for representative
17
+ // chunk fixtures.
18
+ //
19
+ // Strategy:
20
+ // 1. Build a real `World` with known blocks.
21
+ // 2. Run the real JS `computeHeightmap` to get the reference.
22
+ // 3. Simulate Rust's full-column iteration in JS (mirroring
23
+ // `wasm-mesher/src/mesher.rs::generate_with_world`: scan all
24
+ // `(y, z, x)` over the full Y range, last write per `(x,z)` wins,
25
+ // skipping blocks whose state IDs are in the `invisible_blocks`
26
+ // set — equivalent to `INVISIBLE_BLOCKS` by name) and pack it into
27
+ // a `WasmGeometryOutput.heightmap` field shaped exactly like Rust
28
+ // returns it (`Vec<i16>` => plain `number[]`, length 256, indexed
29
+ // `z*16+x`, sentinel `-32768`).
30
+ // 4. Run the simulated output through the SAME `extractColumnHeightmap`
31
+ // adapter that the runtime uses, and assert element-wise equality
32
+ // with the JS heightmap.
33
+ //
34
+ // If parity ever fails here, Rust heightmap usage in `mesherWasm.ts` MUST stay
35
+ // disabled and `getHeightmap` MUST keep using the JS handler.
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const VERSION = '1.16.5'
39
+
40
+ type BlockSpec = { x: number, y: number, z: number, name: string }
41
+
42
+ function buildWorld(blocks: BlockSpec[]): { world: World, invisibleStateIds: Set<number> } {
43
+ const mcData = MinecraftData(VERSION)
44
+ const Chunk = Chunks(VERSION) as any
45
+ const chunk = new Chunk(undefined as any)
46
+
47
+ for (const b of blocks) {
48
+ const id = mcData.blocksByName[b.name]?.defaultState
49
+ if (id == null) throw new Error(`Unknown block name in fixture: ${b.name}`)
50
+ chunk.setBlockStateId(new Vec3(b.x, b.y, b.z), id)
51
+ }
52
+
53
+ const world = new World(VERSION)
54
+ // computeHeightmap requires worldMinY / worldMaxY on world.config; the
55
+ // defaults (0..256) match the 1.16.5 chunk shape we're using here.
56
+ world.addColumn(0, 0, chunk.toJson())
57
+
58
+ // Build the invisible-state-ID set the way `convertChunkToWasm` does at
59
+ // runtime: every state ID of every block whose name is in
60
+ // INVISIBLE_BLOCKS. Used by the Rust simulation below.
61
+ const invisibleStateIds = new Set<number>()
62
+ for (const block of mcData.blocksArray) {
63
+ if (!INVISIBLE_BLOCKS.has(block.name)) continue
64
+ const min = block.minStateId ?? block.defaultState
65
+ const max = block.maxStateId ?? block.defaultState
66
+ for (let id = min; id <= max; id++) invisibleStateIds.add(id)
67
+ }
68
+ return { world, invisibleStateIds }
69
+ }
70
+
71
+ /**
72
+ * JS port of `Mesher::generate_with_world` heightmap pass — bottom-up
73
+ * iteration over the full column with last-write-wins per `(x,z)`.
74
+ * Returns a plain `number[]` matching the on-the-wire shape of Rust's
75
+ * `Vec<i16>` (length 256, indexed `z*16+x`, sentinel `-32768`).
76
+ */
77
+ function simulateRustColumnHeightmap(
78
+ world: World,
79
+ invisibleStateIds: Set<number>,
80
+ worldMinY: number,
81
+ worldMaxY: number
82
+ ): number[] {
83
+ const heightmap = new Array<number>(256).fill(-32768)
84
+ const column = world.getColumn(0, 0)
85
+ if (!column) return heightmap
86
+
87
+ const pos = new Vec3(0, 0, 0)
88
+ for (let y = worldMinY; y < worldMaxY; y++) {
89
+ for (let z = 0; z < 16; z++) {
90
+ for (let x = 0; x < 16; x++) {
91
+ pos.x = x
92
+ pos.y = y
93
+ pos.z = z
94
+ const stateId = column.getBlockStateId(pos)
95
+ if (stateId === 0 || invisibleStateIds.has(stateId)) continue
96
+ heightmap[z * 16 + x] = y
97
+ }
98
+ }
99
+ }
100
+ return heightmap
101
+ }
102
+
103
+ function makeWasmOutputWithHeightmap(heightmap: number[]): WasmGeometryOutput {
104
+ return {
105
+ blocks: [],
106
+ block_count: 0,
107
+ block_iterations: 0,
108
+ heightmap,
109
+ }
110
+ }
111
+
112
+ function runParity(blocks: BlockSpec[]): { js: Int16Array, rust: Int16Array } {
113
+ const { world, invisibleStateIds } = buildWorld(blocks)
114
+ const js = computeHeightmap(world, 0, 0)
115
+
116
+ const rustShaped = simulateRustColumnHeightmap(
117
+ world,
118
+ invisibleStateIds,
119
+ world.config.worldMinY,
120
+ world.config.worldMaxY
121
+ )
122
+ const wasmOutput = makeWasmOutputWithHeightmap(rustShaped)
123
+ const rust = extractColumnHeightmap(wasmOutput)
124
+ expect(rust).not.toBeNull()
125
+ return { js, rust: rust! }
126
+ }
127
+
128
+ test('heightmap parity: flat stone layer at y=5', () => {
129
+ const blocks: BlockSpec[] = []
130
+ for (let z = 0; z < 16; z++) {
131
+ for (let x = 0; x < 16; x++) {
132
+ blocks.push({ x, y: 5, z, name: 'stone' })
133
+ }
134
+ }
135
+ const { js, rust } = runParity(blocks)
136
+ expect(rust.length).toBe(256)
137
+ expect(js.length).toBe(256)
138
+ expect(Array.from(rust)).toEqual(Array.from(js))
139
+ // Sanity: every column should report y=5.
140
+ for (let i = 0; i < 256; i++) expect(js[i]).toBe(5)
141
+ })
142
+
143
+ test('heightmap parity: varied heights, every column populated (Rust == JS)', () => {
144
+ // Fully-populated chunk (every (x,z) has at least one block) so we sidestep
145
+ // the documented empty-column gap captured in the next test.
146
+ const blocks: BlockSpec[] = []
147
+ for (let z = 0; z < 16; z++) {
148
+ for (let x = 0; x < 16; x++) {
149
+ // Default floor.
150
+ blocks.push({ x, y: 5, z, name: 'stone' })
151
+ }
152
+ }
153
+ // Layered overrides exercising mid/high/low surfaces and invisible-skipping.
154
+ blocks.push({ x: 0, y: 64, z: 0, name: 'stone' })
155
+ blocks.push({ x: 1, y: 70, z: 0, name: 'dirt' })
156
+ blocks.push({ x: 2, y: 80, z: 0, name: 'oak_log' }) // top wins over y=64 below
157
+ blocks.push({ x: 2, y: 64, z: 0, name: 'stone' })
158
+ blocks.push({ x: 5, y: 255, z: 5, name: 'stone' }) // high-Y edge (worldMaxY-1)
159
+ blocks.push({ x: 6, y: 0, z: 6, name: 'stone' }) // low-Y edge (worldMinY)
160
+ // Invisible block above a real surface must be skipped on both sides.
161
+ blocks.push({ x: 7, y: 10, z: 7, name: 'stone' })
162
+ blocks.push({ x: 7, y: 11, z: 7, name: 'barrier' })
163
+ blocks.push({ x: 8, y: 12, z: 8, name: 'stone' })
164
+ blocks.push({ x: 8, y: 13, z: 8, name: 'cave_air' })
165
+
166
+ const { js, rust } = runParity(blocks)
167
+ expect(Array.from(rust)).toEqual(Array.from(js))
168
+
169
+ // Spot-check absolute values so a future regression in either side
170
+ // doesn't silently align them on a wrong shared value.
171
+ expect(js[0 * 16 + 0]).toBe(64)
172
+ expect(js[0 * 16 + 1]).toBe(70)
173
+ expect(js[0 * 16 + 2]).toBe(80)
174
+ expect(js[5 * 16 + 5]).toBe(255)
175
+ expect(js[6 * 16 + 6]).toBe(5) // y=0 stone is below the y=5 floor stone
176
+ expect(js[7 * 16 + 7]).toBe(10) // barrier above must be skipped
177
+ expect(js[8 * 16 + 8]).toBe(12) // cave_air above must be skipped
178
+ })
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // ---------------------------------------------------------------------------
182
+ // Empty-column parity (post-alignment).
183
+ //
184
+ // Historically `computeHeightmap` returned `0` (== worldMinY) for a fully-
185
+ // empty column, because its loop reads at worldMinY, finds air (truthy block,
186
+ // name in INVISIBLE_BLOCKS), exits the `while` because `blockPos.y > worldMinY`
187
+ // becomes false, and fell through to `heightmap[index] = blockPos.y`
188
+ // (== worldMinY). Rust's mesher writes `-32768` for any column with no
189
+ // non-invisible block, so the two encodings disagreed and the WASM
190
+ // `getHeightmap` runtime switch was blocked on that gap.
191
+ //
192
+ // `computeHeightmap` has since been aligned: it now writes
193
+ // `EMPTY_COLUMN_HEIGHTMAP_SENTINEL` (-32768) for empty columns, matching
194
+ // Rust. This test pins that alignment so a regression can never silently
195
+ // re-introduce the divergence.
196
+ // ---------------------------------------------------------------------------
197
+ test('heightmap parity: empty columns produce the same sentinel (-32768) in JS and Rust', () => {
198
+ // No blocks at all — fully empty chunk.
199
+ const { js, rust } = runParity([])
200
+ for (let i = 0; i < 256; i++) {
201
+ expect(js[i]).toBe(-32768)
202
+ expect(rust[i]).toBe(-32768)
203
+ }
204
+ expect(Array.from(rust)).toEqual(Array.from(js))
205
+ })
206
+
207
+ test('extractColumnHeightmap: returns null for missing or wrong-length heightmap (forces JS fallback)', () => {
208
+ expect(extractColumnHeightmap({ heightmap: null })).toBeNull()
209
+ expect(extractColumnHeightmap({})).toBeNull()
210
+ expect(extractColumnHeightmap({ heightmap: [1, 2, 3] })).toBeNull()
211
+ expect(extractColumnHeightmap(undefined)).toBeNull()
212
+ })
213
+
214
+ test('extractColumnHeightmap: accepts both number[] and Int16Array shapes', () => {
215
+ const arr = new Array<number>(256).fill(-32768)
216
+ arr[0] = 42
217
+ const fromArr = extractColumnHeightmap({ heightmap: arr })!
218
+ expect(fromArr).toBeInstanceOf(Int16Array)
219
+ expect(fromArr[0]).toBe(42)
220
+ expect(fromArr[1]).toBe(-32768)
221
+
222
+ const typed = new Int16Array(256)
223
+ typed.fill(-32768)
224
+ typed[5] = 99
225
+ const fromTyped = extractColumnHeightmap({ heightmap: typed })!
226
+ expect(fromTyped).toBeInstanceOf(Int16Array)
227
+ expect(fromTyped[5]).toBe(99)
228
+ // Must be a copy, not aliased — runtime transfers the buffer to the
229
+ // main thread and would otherwise detach the cached one.
230
+ expect(fromTyped).not.toBe(typed)
231
+ })
@@ -0,0 +1,128 @@
1
+ //@ts-nocheck
2
+ import { describe, test, expect, beforeEach, vi } from 'vitest'
3
+ import {
4
+ CONVERSION_CACHE_LIMIT,
5
+ _peekConversionCache,
6
+ clearConversionCache,
7
+ getOrConvertColumn,
8
+ invalidateConversion,
9
+ } from '../mesherWasmConversionCache'
10
+ import type { ChunkConversionResult } from '../../wasm-lib/convertChunk'
11
+
12
+ const makeResult = (tag: number): ChunkConversionResult => ({
13
+ blockStates: new Uint16Array([tag]),
14
+ blockLight: new Uint8Array(0),
15
+ skyLight: new Uint8Array(0),
16
+ biomesArray: new Uint8Array(0),
17
+ invisibleBlocks: new Uint16Array(0),
18
+ transparentBlocks: new Uint16Array(0),
19
+ noAoBlocks: new Uint16Array(0),
20
+ cullIdenticalBlocks: new Uint16Array(0),
21
+ occludingBlocks: new Uint16Array(0),
22
+ blockCount: 0,
23
+ })
24
+
25
+ describe('mesherWasmConversionCache', () => {
26
+ beforeEach(() => {
27
+ clearConversionCache()
28
+ })
29
+
30
+ test('miss then hit on same chunk ref returns cached result', () => {
31
+ const ref = { id: 'chunk-a' }
32
+ let calls = 0
33
+ const convert = () => {
34
+ calls++
35
+ return makeResult(1)
36
+ }
37
+ const a = getOrConvertColumn(0, 0, ref, 'v', 0, 256, convert, ref)
38
+ expect(a.hit).toBe(false)
39
+ expect(calls).toBe(1)
40
+ const b = getOrConvertColumn(0, 0, ref, 'v', 0, 256, convert, ref)
41
+ expect(b.hit).toBe(true)
42
+ expect(calls).toBe(1)
43
+ expect(b.result).toBe(a.result)
44
+ })
45
+
46
+ test('miss when chunk reference changes (chunk message replacement)', () => {
47
+ const ref1 = { id: 'r1' }
48
+ const ref2 = { id: 'r2' }
49
+ const r1 = getOrConvertColumn(16, 32, ref1, 'v', 0, 256, () => makeResult(1), ref1)
50
+ const r2 = getOrConvertColumn(16, 32, ref2, 'v', 0, 256, () => makeResult(2), ref2)
51
+ expect(r1.hit).toBe(false)
52
+ expect(r2.hit).toBe(false)
53
+ expect(r2.result).not.toBe(r1.result)
54
+ })
55
+
56
+ test('explicit invalidation forces recompute', () => {
57
+ const ref = { id: 'r' }
58
+ const r1 = getOrConvertColumn(0, 0, ref, 'v', 0, 256, () => makeResult(1), ref)
59
+ expect(r1.hit).toBe(false)
60
+ expect(invalidateConversion(0, 0)).toBe(true)
61
+ const r2 = getOrConvertColumn(0, 0, ref, 'v', 0, 256, () => makeResult(2), ref)
62
+ expect(r2.hit).toBe(false)
63
+ expect(_peekConversionCache(0, 0)?.result).toBe(r2.result)
64
+ })
65
+
66
+ test('invalidating a non-existent key returns false', () => {
67
+ expect(invalidateConversion(999, 999)).toBe(false)
68
+ })
69
+
70
+ test('LRU evicts oldest beyond CONVERSION_CACHE_LIMIT', () => {
71
+ const refs: any[] = []
72
+ for (let i = 0; i < CONVERSION_CACHE_LIMIT + 5; i++) {
73
+ const ref = { id: i }
74
+ refs.push(ref)
75
+ getOrConvertColumn(i * 16, 0, ref, 'v', 0, 256, () => makeResult(i), ref)
76
+ }
77
+ // The first 5 entries should have been evicted.
78
+ for (let i = 0; i < 5; i++) {
79
+ expect(_peekConversionCache(i * 16, 0)).toBeUndefined()
80
+ }
81
+ // The most-recent entries remain.
82
+ for (let i = 5; i < CONVERSION_CACHE_LIMIT + 5; i++) {
83
+ expect(_peekConversionCache(i * 16, 0)).toBeDefined()
84
+ }
85
+ })
86
+
87
+ test('hit bumps LRU recency (touched entry survives eviction)', () => {
88
+ const ref0 = { id: 'keep' }
89
+ getOrConvertColumn(0, 0, ref0, 'v', 0, 256, () => makeResult(0), ref0)
90
+ // Fill the cache up to the limit.
91
+ for (let i = 1; i < CONVERSION_CACHE_LIMIT; i++) {
92
+ const ref = { id: i }
93
+ getOrConvertColumn(i * 16, 0, ref, 'v', 0, 256, () => makeResult(i), ref)
94
+ }
95
+ // Touch the oldest (0,0) -> it becomes most-recent.
96
+ const touched = getOrConvertColumn(0, 0, ref0, 'v', 0, 256, () => makeResult(99), ref0)
97
+ expect(touched.hit).toBe(true)
98
+ // Insert one more; the new oldest (16,0) should be evicted, not (0,0).
99
+ const refExtra = { id: 'extra' }
100
+ getOrConvertColumn(9999, 0, refExtra, 'v', 0, 256, () => makeResult(7), refExtra)
101
+ expect(_peekConversionCache(0, 0)).toBeDefined()
102
+ expect(_peekConversionCache(16, 0)).toBeUndefined()
103
+ })
104
+
105
+ test('miss when worldMinY/worldMaxY/version metadata changes', () => {
106
+ const ref = { id: 'r' }
107
+ const r1 = getOrConvertColumn(0, 0, ref, 'v1', 0, 256, () => makeResult(1), ref)
108
+ const r2 = getOrConvertColumn(0, 0, ref, 'v2', 0, 256, () => makeResult(2), ref)
109
+ const r3 = getOrConvertColumn(0, 0, ref, 'v2', -64, 256, () => makeResult(3), ref)
110
+ const r4 = getOrConvertColumn(0, 0, ref, 'v2', -64, 320, () => makeResult(4), ref)
111
+ expect(r1.hit).toBe(false)
112
+ expect(r2.hit).toBe(false)
113
+ expect(r3.hit).toBe(false)
114
+ expect(r4.hit).toBe(false)
115
+ })
116
+
117
+ test('identity drift between stored and live ref triggers warn + miss', () => {
118
+ const stored = { id: 'old' }
119
+ const live = { id: 'new' } // simulates a missed invalidation path
120
+ const r1 = getOrConvertColumn(0, 0, stored, 'v', 0, 256, () => makeResult(1), stored)
121
+ expect(r1.hit).toBe(false)
122
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
123
+ const r2 = getOrConvertColumn(0, 0, stored, 'v', 0, 256, () => makeResult(2), live)
124
+ expect(warn).toHaveBeenCalledTimes(1)
125
+ expect(r2.hit).toBe(false)
126
+ warn.mockRestore()
127
+ })
128
+ })
@@ -10,8 +10,8 @@ export const getChunk = () => {
10
10
  const chunkDataBuffer = Buffer.from(chunkData.data)
11
11
 
12
12
  const chunk = new Chunk({ minY: 0, worldHeight: 256, x: 0, z: 0 }) as PCChunk
13
- // chunk.load(chunkDataBuffer, bitMap, true, groundUp)
14
- chunk.setBlockStateId(new Vec3(0, 1, 0), 1)
13
+ chunk.load(chunkDataBuffer, bitMap, true, groundUp)
14
+ // chunk.setBlockStateId(new Vec3(0, 1, 0), 1)
15
15
  // chunk.setBlockStateId(new Vec3(0, 0, 1), 1)
16
16
 
17
17
  return chunk