minecraft-renderer 0.1.107 → 0.1.108

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.
Files changed (90) hide show
  1. package/dist/lightOwnerWorker.js +9 -0
  2. package/dist/lightOwnerWorker.js.map +7 -0
  3. package/dist/mesher.js +1 -1
  4. package/dist/mesher.js.map +2 -2
  5. package/dist/mesherWasm.js +222 -222
  6. package/dist/minecraft-renderer.js +80 -80
  7. package/dist/minecraft-renderer.js.meta.json +1 -1
  8. package/dist/threeWorker.js +513 -513
  9. package/package.json +1 -1
  10. package/src/bundler/bundlePrepare.test.ts +25 -0
  11. package/src/bundler/bundlePrepare.ts +1 -1
  12. package/src/graphicsBackend/appViewer.ts +15 -0
  13. package/src/graphicsBackend/config.ts +5 -0
  14. package/src/graphicsBackend/rendererDefaultOptions.test.ts +51 -0
  15. package/src/graphicsBackend/rendererDefaultOptions.ts +13 -1
  16. package/src/graphicsBackend/rendererOptionsSync.ts +3 -3
  17. package/src/lib/blockEntityLightRegistry.ts +4 -0
  18. package/src/lib/worldrendererCommon.lightOwner.test.ts +916 -0
  19. package/src/lib/worldrendererCommon.removeColumn.test.ts +16 -0
  20. package/src/lib/worldrendererCommon.ts +329 -67
  21. package/src/lighting/cache/rendererLightCache.test.ts +158 -0
  22. package/src/lighting/cache/rendererLightCache.ts +248 -0
  23. package/src/lighting/coordinator/clientLightCoordinator.test.ts +102 -0
  24. package/src/lighting/coordinator/clientLightCoordinator.ts +574 -0
  25. package/src/lighting/debug/clientLightProbe.test.ts +24 -0
  26. package/src/lighting/debug/clientLightProbe.ts +20 -0
  27. package/src/lighting/debug/clientLightTrace.test.ts +271 -0
  28. package/src/lighting/debug/clientLightTrace.ts +335 -0
  29. package/src/lighting/entity/entityLightController.test.ts +216 -0
  30. package/src/lighting/entity/entityLightController.ts +322 -0
  31. package/src/lighting/owner/clientLightOwner.test.ts +467 -0
  32. package/src/lighting/owner/clientLightOwner.ts +523 -0
  33. package/src/lighting/owner/lightOwnerHost.test.ts +78 -0
  34. package/src/lighting/owner/lightOwnerHost.ts +202 -0
  35. package/src/lighting/owner/lightOwnerWorker.ts +154 -0
  36. package/src/lighting/owner/updateLight1171OwnerPath.test.ts +68 -0
  37. package/src/lighting/owner/updateLightToOwnerEvents.ts +49 -0
  38. package/src/lighting/tables/lightTables1171.generated.json +10 -0
  39. package/src/lighting/tables/lightTables1171.test.ts +168 -0
  40. package/src/lighting/tables/lightTables1171.ts +59 -0
  41. package/src/lighting/versioning/clientLightVersions.test.ts +241 -0
  42. package/src/lighting/versioning/clientLightVersions.ts +203 -0
  43. package/src/lighting/versioning/pendingSectionFlush.test.ts +214 -0
  44. package/src/lighting/versioning/pendingSectionFlush.ts +123 -0
  45. package/src/mesher-shared/lightNibblePack.ts +26 -0
  46. package/src/mesher-shared/mcDataWorkerShim.cjs +9 -7
  47. package/src/mesher-shared/modelsGeometryCommon.test.ts +6 -1
  48. package/src/mesher-shared/shared.ts +48 -1
  49. package/src/three/chunkMeshManager.ts +26 -0
  50. package/src/three/entities.ts +77 -11
  51. package/src/three/entity/EntityMesh.ts +1 -0
  52. package/src/three/entity/legWalkAnimation.test.ts +11 -2
  53. package/src/three/entity/legWalkAnimation.ts +1 -9
  54. package/src/three/globalBlockBuffer.ts +94 -2
  55. package/src/three/globalLegacyBuffer.ts +94 -2
  56. package/src/three/graphicsBackendBase.ts +2 -9
  57. package/src/three/tests/globalBlockBuffer.test.ts +98 -0
  58. package/src/three/tests/globalLegacyBuffer.test.ts +108 -0
  59. package/src/three/worldRendererThree.ts +75 -34
  60. package/src/wasm-mesher/bridge/render-from-wasm.ts +46 -1
  61. package/src/wasm-mesher/runtime-build/wasm_mesher.d.ts +31 -5
  62. package/src/wasm-mesher/runtime-build/wasm_mesher.js +180 -5
  63. package/src/wasm-mesher/runtime-build/wasm_mesher_bg.wasm +0 -0
  64. package/src/wasm-mesher/runtime-build/wasm_mesher_bg.wasm.d.ts +11 -0
  65. package/src/wasm-mesher/tests/mesherLightDependencies.test.ts +83 -0
  66. package/src/wasm-mesher/tests/mesherWasmColumnTickSelect.test.ts +39 -0
  67. package/src/wasm-mesher/tests/mesherWasmConversionCache.test.ts +30 -1
  68. package/src/wasm-mesher/tests/mesherWasmTickParseCache.test.ts +73 -0
  69. package/src/wasm-mesher/tests/mesherWasmTickSchedule.test.ts +51 -0
  70. package/src/wasm-mesher/tests/mesherWasmTopologyPostCache.test.ts +78 -0
  71. package/src/wasm-mesher/worker/clientLightDarkPublication.probe.test.ts +32 -0
  72. package/src/wasm-mesher/worker/mesherLightDependencies.ts +43 -0
  73. package/src/wasm-mesher/worker/mesherWasm.ownerLightSync.test.ts +198 -0
  74. package/src/wasm-mesher/worker/mesherWasm.ts +506 -96
  75. package/src/wasm-mesher/worker/mesherWasmChunkBuffer.ts +1 -0
  76. package/src/wasm-mesher/worker/mesherWasmColumnTickSelect.ts +47 -0
  77. package/src/wasm-mesher/worker/mesherWasmConversionCache.ts +16 -5
  78. package/src/wasm-mesher/worker/mesherWasmLightDirty.test.ts +17 -1
  79. package/src/wasm-mesher/worker/mesherWasmLightDirty.ts +10 -0
  80. package/src/wasm-mesher/worker/mesherWasmLightMerge.test.ts +208 -0
  81. package/src/wasm-mesher/worker/mesherWasmLightMerge.ts +228 -0
  82. package/src/wasm-mesher/worker/mesherWasmOwnerLight.test.ts +161 -0
  83. package/src/wasm-mesher/worker/mesherWasmOwnerLight.ts +112 -0
  84. package/src/wasm-mesher/worker/mesherWasmTickParseCache.ts +45 -0
  85. package/src/wasm-mesher/worker/mesherWasmTickSchedule.ts +44 -0
  86. package/src/wasm-mesher/worker/mesherWasmTopologyPostCache.ts +63 -0
  87. package/src/worldView/types.ts +1 -0
  88. package/src/worldView/worldView.geometryReload.test.ts +68 -0
  89. package/src/worldView/worldView.spiral.test.ts +1 -1
  90. package/src/worldView/worldView.ts +46 -1
@@ -0,0 +1,467 @@
1
+ //@ts-nocheck
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import Chunks from 'prismarine-chunk'
4
+ import MinecraftData from 'minecraft-data'
5
+ import { Vec3 } from 'vec3'
6
+ import { RendererLightCache } from '../cache/rendererLightCache'
7
+ import { defaultWorldRendererConfig } from '../../graphicsBackend/config'
8
+ import { LightOwnerHost, type LightPublication, type PublicationGate } from './lightOwnerHost'
9
+ import { maskBitSet, worldSectionMaskBit, type ParsedUpdateLight } from '../../wasm-mesher/worker/mesherWasmLightMerge'
10
+ import {
11
+ applyOwnerPublicationToRenderer,
12
+ blockChangeEvent,
13
+ ClientLightOwnerSession,
14
+ dirtyMeshSectionsFromChangedLight,
15
+ eventsFromColumnLoad,
16
+ meshWorkerIndexesForDirtySections,
17
+ eventsFromColumnUnload,
18
+ eventsFromParsedUpdateLight,
19
+ packUnpackedLightSection,
20
+ raiseRequiredLightRevisions,
21
+ shouldAcceptMeshGeometry,
22
+ clientLightOwnerVersionBlockReason,
23
+ shouldSpawnClientLightOwner,
24
+ skyLightEnabledFromRendererState,
25
+ unpackPackedLightSection
26
+ } from './clientLightOwner'
27
+
28
+ const VERSION = '1.17.1'
29
+
30
+ function packedNibble(value: number): Uint8Array {
31
+ const n = value & 0x0f
32
+ return new Uint8Array(2048).fill(n | (n << 4))
33
+ }
34
+
35
+ function makeAirColumnJson() {
36
+ const Chunk = Chunks(VERSION) as any
37
+ const chunk = new Chunk()
38
+ return chunk.toJson()
39
+ }
40
+
41
+ function unpackedSection(value: number): Uint8Array {
42
+ return new Uint8Array(4096).fill(value & 0x0f)
43
+ }
44
+
45
+ function makeSession() {
46
+ let onMessage: (data: any) => void = () => {}
47
+ const worker = {
48
+ postMessage: () => {},
49
+ terminate: () => {},
50
+ onerror: null as ((event: ErrorEvent) => void) | null
51
+ }
52
+ const cache = new RendererLightCache(VERSION)
53
+ cache.setWorldBounds(0, 256)
54
+ const session = new ClientLightOwnerSession(cache, {
55
+ createWorker: handler => {
56
+ onMessage = handler
57
+ return worker as unknown as Worker
58
+ },
59
+ worldMinY: 0,
60
+ worldHeight: 256,
61
+ skyLightEnabled: true,
62
+ onApplied: () => {}
63
+ })
64
+ return { session, worker, deliver: (data: any) => onMessage(data) }
65
+ }
66
+
67
+ describe('client light owner flag', () => {
68
+ it('stays default-off so production does not spawn the owner', () => {
69
+ expect(defaultWorldRendererConfig.enableClientLightOwner).toBe(false)
70
+ expect(shouldSpawnClientLightOwner(defaultWorldRendererConfig, '1.17.1')).toBe(false)
71
+ })
72
+
73
+ it('spawns only when the flag is true and the session is 1.17.1', () => {
74
+ expect(shouldSpawnClientLightOwner({ enableClientLightOwner: true }, '1.17.1')).toBe(true)
75
+ expect(shouldSpawnClientLightOwner({ enableClientLightOwner: false }, '1.17.1')).toBe(false)
76
+ expect(shouldSpawnClientLightOwner({}, '1.17.1')).toBe(false)
77
+ expect(shouldSpawnClientLightOwner({ enableClientLightOwner: true }, '1.16.5')).toBe(false)
78
+ expect(shouldSpawnClientLightOwner({ enableClientLightOwner: true }, '1.18.2')).toBe(false)
79
+ expect(clientLightOwnerVersionBlockReason('1.17.1')).toBeNull()
80
+ expect(clientLightOwnerVersionBlockReason('1.16.5')).toMatch(/1\.17\.1/)
81
+ })
82
+ })
83
+
84
+ describe('client light owner lifecycle', () => {
85
+ it('retains light requirements but clears topology only for geometry-reloaded columns', () => {
86
+ const { session, worker } = makeSession()
87
+ const send = vi.spyOn(worker, 'postMessage')
88
+ session.requiredLightBySection.set('0,64,0', { worldGeneration: 1, requiredVersion: 7, topologyRevision: 9 })
89
+ session.requiredLightBySection.set('16,64,0', { worldGeneration: 1, requiredVersion: 8, topologyRevision: 10 })
90
+ session.onGeometryReload(0, 0)
91
+ expect(session.requiredLightBySection.get('0,64,0')).toEqual({ worldGeneration: 1, requiredVersion: 7 })
92
+ expect(session.requiredLightBySection.get('16,64,0')?.topologyRevision).toBe(10)
93
+ expect(send).not.toHaveBeenCalled()
94
+ session.onUnload(0, 0)
95
+ expect(session.requiredLightBySection.has('0,64,0')).toBe(false)
96
+ })
97
+
98
+ it('starts in starting and is not ready until the worker reports ready', () => {
99
+ const { session } = makeSession()
100
+ expect(session.state).toBe('starting')
101
+ expect(session.isReady).toBe(false)
102
+ })
103
+
104
+ it('becomes ready after the worker ready message', () => {
105
+ const { session, deliver } = makeSession()
106
+ deliver({ type: 'ready' })
107
+ expect(session.state).toBe('ready')
108
+ expect(session.isReady).toBe(true)
109
+ })
110
+
111
+ it('goes to failed on a worker error message', () => {
112
+ const { session, deliver } = makeSession()
113
+ deliver({ type: 'error', error: 'NetworkError loading lightOwnerWorker.js' })
114
+ expect(session.state).toBe('failed')
115
+ expect(session.isReady).toBe(false)
116
+ })
117
+
118
+ it('goes to failed on worker onerror (script 404)', () => {
119
+ const { session, worker } = makeSession()
120
+ expect(typeof worker.onerror).toBe('function')
121
+ worker.onerror?.({ type: 'error', message: 'NetworkError' } as ErrorEvent)
122
+ expect(session.state).toBe('failed')
123
+ expect(session.isReady).toBe(false)
124
+ })
125
+
126
+ it('does not leave failed after a late ready', () => {
127
+ const { session, deliver } = makeSession()
128
+ deliver({ type: 'error', error: 'init failed' })
129
+ deliver({ type: 'ready' })
130
+ expect(session.state).toBe('failed')
131
+ })
132
+ })
133
+
134
+ describe('owner step scheduling', () => {
135
+ it('does not bounce remaining slices through main setTimeout', () => {
136
+ vi.useFakeTimers()
137
+ try {
138
+ const posts: Array<{ type?: string }> = []
139
+ let onMessage: (data: any) => void = () => {}
140
+ const worker = {
141
+ postMessage: (message: { type?: string }) => {
142
+ posts.push(message)
143
+ },
144
+ terminate: () => {},
145
+ onerror: null as ((event: ErrorEvent) => void) | null
146
+ }
147
+ const cache = new RendererLightCache(VERSION)
148
+ cache.setWorldBounds(0, 256)
149
+ const session = new ClientLightOwnerSession(cache, {
150
+ createWorker: handler => {
151
+ onMessage = handler
152
+ return worker as unknown as Worker
153
+ },
154
+ worldMinY: 0,
155
+ worldHeight: 256,
156
+ skyLightEnabled: true,
157
+ onApplied: () => {}
158
+ })
159
+ onMessage({ type: 'ready' })
160
+ posts.length = 0
161
+ session.pushEvent(blockChangeEvent(8, 64, 8, 1))
162
+ vi.runAllTimers()
163
+ expect(posts.filter(message => message.type === 'step')).toHaveLength(1)
164
+ posts.length = 0
165
+ onMessage({ type: 'stepped', remaining: true, publication: null })
166
+ vi.runAllTimers()
167
+ expect(posts.filter(message => message.type === 'step')).toHaveLength(0)
168
+ } finally {
169
+ vi.useRealTimers()
170
+ }
171
+ })
172
+
173
+ it('selects only mesh workers that own dirty sections', () => {
174
+ expect(
175
+ meshWorkerIndexesForDirtySections(
176
+ [
177
+ { sx: 0, sy: 64, sz: 0 },
178
+ { sx: 16, sy: 64, sz: 0 },
179
+ { sx: 0, sy: 80, sz: 0 }
180
+ ],
181
+ 8,
182
+ (sx, _sy, sz) => (((sx / 16 + sz / 16) % 8) + 8) % 8
183
+ ).sort((a, b) => a - b)
184
+ ).toEqual([0, 1])
185
+ })
186
+ })
187
+
188
+ describe('nether/end sky detection', () => {
189
+ it('disables sky from lightingDisabled (nether/end / !hasSkyLight)', () => {
190
+ expect(skyLightEnabledFromRendererState({ lightingDisabled: false })).toBe(true)
191
+ expect(skyLightEnabledFromRendererState({ lightingDisabled: true })).toBe(false)
192
+ expect(skyLightEnabledFromRendererState({})).toBe(true)
193
+ })
194
+ })
195
+
196
+ describe('dirtyMeshSections stencil', () => {
197
+ it('covers the 3x3x3 sample neighborhood, including the diagonal section', () => {
198
+ const dirty = dirtyMeshSectionsFromChangedLight([{ sx: 0, sy: 64, sz: 0 }], { coords: 'world' })
199
+ const keys = new Set(dirty.map(s => `${s.sx},${s.sy},${s.sz}`))
200
+ expect(keys.has('0,64,0')).toBe(true)
201
+ expect(keys.has('-16,64,0')).toBe(true)
202
+ expect(keys.has('16,64,0')).toBe(true)
203
+ expect(keys.has('0,48,0')).toBe(true)
204
+ expect(keys.has('0,80,0')).toBe(true)
205
+ expect(keys.has('0,64,-16')).toBe(true)
206
+ expect(keys.has('0,64,16')).toBe(true)
207
+ expect(keys.has('16,80,16')).toBe(true)
208
+ expect(keys.size).toBe(27)
209
+ })
210
+
211
+ it('schedules geometry (16,16,16) when light (0,0,0) changes — west-face corner samples (15,15,15)', () => {
212
+ const dirty = dirtyMeshSectionsFromChangedLight([{ sx: 0, sy: 0, sz: 0 }], { coords: 'world' })
213
+ expect(dirty.some(s => s.sx === 16 && s.sy === 16 && s.sz === 16)).toBe(true)
214
+ })
215
+
216
+ it('converts engine section-index publications to world origins before expanding', () => {
217
+ const dirty = dirtyMeshSectionsFromChangedLight([{ sx: 0, sy: 4, sz: 0 }], { coords: 'section-index' })
218
+ expect(dirty.some(s => s.sx === 0 && s.sy === 64 && s.sz === 0)).toBe(true)
219
+ expect(dirty.some(s => s.sx === 0 && s.sy === 80 && s.sz === 0)).toBe(true)
220
+ })
221
+ })
222
+
223
+ describe('versioned mesh drop', () => {
224
+ const gate: PublicationGate = { acceptedGeneration: 2, lastVersion: 5 }
225
+
226
+ it('accepts geometry with no version (flag-off / pre-owner mesh path)', () => {
227
+ expect(shouldAcceptMeshGeometry({}, gate)).toBe(true)
228
+ })
229
+
230
+ it('owner-managed path rejects unversioned geometry as current', () => {
231
+ const required = { requiredVersion: 5, worldGeneration: 2 }
232
+ expect(shouldAcceptMeshGeometry({}, gate, required, { ownerManaged: true })).toBe(false)
233
+ })
234
+
235
+ it('accepts a mesh of an independent section after an unrelated later publication', () => {
236
+ expect(shouldAcceptMeshGeometry({ worldGeneration: 1, lightPublicationVersion: 1 }, { acceptedGeneration: 1, lastVersion: 2 })).toBe(true)
237
+ })
238
+
239
+ it('drops a mesh below the required revision of its own section', () => {
240
+ const required = { requiredVersion: 5, worldGeneration: 2 }
241
+ expect(shouldAcceptMeshGeometry({ worldGeneration: 1, lightPublicationVersion: 9 }, gate, required)).toBe(false)
242
+ expect(shouldAcceptMeshGeometry({ worldGeneration: 2, lightPublicationVersion: 4 }, gate, required)).toBe(false)
243
+ })
244
+
245
+ it('accepts geometry stamped with the required publication of its section', () => {
246
+ expect(shouldAcceptMeshGeometry({ worldGeneration: 2, lightPublicationVersion: 5 }, gate, { requiredVersion: 5, worldGeneration: 2 })).toBe(true)
247
+ })
248
+ })
249
+
250
+ describe('raiseRequiredLightRevisions', () => {
251
+ it('keeps the higher topology revision when a later publication raises light version', () => {
252
+ const required = new Map()
253
+ raiseRequiredLightRevisions(required, [{ sx: 0, sy: 64, sz: 0 }], 2, 1)
254
+ required.set('0,64,0', { ...required.get('0,64,0')!, topologyRevision: 4 })
255
+ raiseRequiredLightRevisions(required, [{ sx: 0, sy: 64, sz: 0 }], 5, 1)
256
+ expect(required.get('0,64,0')).toEqual({ requiredVersion: 5, worldGeneration: 1, topologyRevision: 4 })
257
+ })
258
+
259
+ it('does not roll topology back when merging a lower live snapshot after a higher one', () => {
260
+ const required = new Map()
261
+ raiseRequiredLightRevisions(required, [{ sx: 0, sy: 64, sz: 0 }], 2, 1, new Map([['0,64,0', 7]]))
262
+ raiseRequiredLightRevisions(required, [{ sx: 0, sy: 64, sz: 0 }], 3, 1, new Map([['0,64,0', 5]]))
263
+ expect(required.get('0,64,0')).toEqual({ requiredVersion: 3, worldGeneration: 1, topologyRevision: 7 })
264
+ })
265
+ })
266
+
267
+ describe('packed nibble convert', () => {
268
+ it('round-trips unpacked 4096 nibble-bytes through packed 2048', () => {
269
+ const unpacked = unpackedSection(0)
270
+ unpacked[0] = 7
271
+ unpacked[1] = 14
272
+ unpacked[256] = 3
273
+ const packed = packUnpackedLightSection(unpacked)
274
+ expect(packed).toHaveLength(2048)
275
+ const back = unpackPackedLightSection(packed)
276
+ expect(back[0]).toBe(7)
277
+ expect(back[1]).toBe(14)
278
+ expect(back[256]).toBe(3)
279
+ })
280
+ })
281
+
282
+ describe('update_light → owner serverLight events', () => {
283
+ it('emits omitted/empty/data independently for sky and block', () => {
284
+ const skyLightMask = new Uint32Array(2)
285
+ const emptySkyLightMask = new Uint32Array(2)
286
+ const blockLightMask = new Uint32Array(2)
287
+ const emptyBlockLightMask = new Uint32Array(2)
288
+ maskBitSet(blockLightMask, worldSectionMaskBit(4))
289
+ maskBitSet(emptyBlockLightMask, worldSectionMaskBit(0))
290
+ maskBitSet(skyLightMask, worldSectionMaskBit(4))
291
+ const blockLight = new Uint8Array(16 * 4096)
292
+ blockLight.fill(9, 4 * 4096, 5 * 4096)
293
+ const skyLight = new Uint8Array(16 * 4096)
294
+ skyLight.fill(15, 4 * 4096, 5 * 4096)
295
+ const parsed: ParsedUpdateLight = {
296
+ x: 1,
297
+ z: 2,
298
+ trustEdges: true,
299
+ numSections: 16,
300
+ skyLight,
301
+ blockLight,
302
+ skyLightMask,
303
+ emptySkyLightMask,
304
+ blockLightMask,
305
+ emptyBlockLightMask
306
+ }
307
+ const events = eventsFromParsedUpdateLight(parsed, 0)
308
+ const blockData = events.find(e => e.type === 'serverLight' && e.channel === 'block' && e.sy === 4 && e.kind === 'data')
309
+ const blockEmpty = events.find(e => e.type === 'serverLight' && e.channel === 'block' && e.sy === 0 && e.kind === 'empty')
310
+ const skyData = events.find(e => e.type === 'serverLight' && e.channel === 'sky' && e.sy === 4 && e.kind === 'data')
311
+ const omittedBlock = events.find(e => e.type === 'serverLight' && e.channel === 'block' && e.sy === 1)
312
+ expect(blockData).toBeTruthy()
313
+ expect(blockEmpty).toBeTruthy()
314
+ expect(skyData).toBeTruthy()
315
+ expect(omittedBlock).toBeUndefined()
316
+ if (blockData?.type === 'serverLight' && blockData.kind === 'data') {
317
+ expect(blockData.data).toHaveLength(2048)
318
+ expect(blockData.sx).toBe(1)
319
+ expect(blockData.sz).toBe(2)
320
+ }
321
+ })
322
+ })
323
+
324
+ describe('column load events', () => {
325
+ it('ingests block sections from prismarine JSON as Loaded (main thread, not mesh workers)', () => {
326
+ const mcData = MinecraftData(VERSION)
327
+ const Chunk = Chunks(VERSION) as any
328
+ const chunk = new Chunk()
329
+ const torch = mcData.blocksByName.torch.defaultState
330
+ chunk.setBlockStateId(new Vec3(8, 64, 8), torch)
331
+ const events = eventsFromColumnLoad({
332
+ chunkX: 0,
333
+ chunkZ: 16,
334
+ chunkJson: chunk.toJson(),
335
+ version: VERSION,
336
+ worldMinY: 0,
337
+ worldHeight: 256
338
+ })
339
+ const ingest = events.filter(e => e.type === 'ingestBlockSection')
340
+ const loaded = events.filter(e => e.type === 'setAvailability' && e.availability === 'loaded')
341
+ expect(ingest.length).toBeGreaterThan(0)
342
+ expect(loaded.length).toBe(ingest.length)
343
+ const section4 = ingest.find(e => e.type === 'ingestBlockSection' && e.sy === 4 && e.sz === 1)
344
+ expect(section4?.type === 'ingestBlockSection' && section4.states[8 + 8 * 16 + 0 * 256]).toBe(torch)
345
+ expect(blockChangeEvent(8, 64, 8, 0)).toEqual({ type: 'blockChange', x: 8, y: 64, z: 8, stateId: 0 })
346
+ expect(eventsFromColumnUnload(32, 48)).toEqual({ type: 'unloadColumn', sx: 2, sz: 3 })
347
+ })
348
+
349
+ it('does not call getBlockStateId 4096 times per section when section data exists', () => {
350
+ const mcData = MinecraftData(VERSION)
351
+ const Chunk = Chunks(VERSION) as any
352
+ const chunk = new Chunk()
353
+ const torch = mcData.blocksByName.torch.defaultState
354
+ const stone = mcData.blocksByName.stone.defaultState
355
+ chunk.setBlockStateId(new Vec3(8, 64, 8), torch)
356
+ chunk.setBlockStateId(new Vec3(3, 20, 3), stone)
357
+ let calls = 0
358
+ const original = chunk.getBlockStateId.bind(chunk)
359
+ chunk.getBlockStateId = (pos: Vec3) => {
360
+ calls++
361
+ return original(pos)
362
+ }
363
+ const events = eventsFromColumnLoad({
364
+ chunkX: 0,
365
+ chunkZ: 0,
366
+ chunkJson: chunk,
367
+ version: VERSION,
368
+ worldMinY: 0,
369
+ worldHeight: 256
370
+ })
371
+ expect(calls).toBe(0)
372
+ const ingest = events.filter(e => e.type === 'ingestBlockSection')
373
+ const section4 = ingest.find(e => e.type === 'ingestBlockSection' && e.sy === 4)
374
+ const section1 = ingest.find(e => e.type === 'ingestBlockSection' && e.sy === 1)
375
+ expect(section4?.type === 'ingestBlockSection' && section4.states[8 + 8 * 16 + 0 * 256]).toBe(torch)
376
+ expect(section1?.type === 'ingestBlockSection' && section1.states[3 + 3 * 16 + 4 * 256]).toBe(stone)
377
+ })
378
+ })
379
+
380
+ describe('apply publication → cache + dirty mesh + worker fan-out', () => {
381
+ it('writes packed channels directly and reports dirtyMeshSections plus a worker message', () => {
382
+ const cache = new RendererLightCache(VERSION)
383
+ cache.setWorldBounds(0, 256)
384
+ const pub: LightPublication = {
385
+ worldGeneration: 1,
386
+ publicationVersion: 3,
387
+ sections: [{ sx: 0, sy: 4, sz: 0, blockLight: packedNibble(9), skyLight: packedNibble(2) }]
388
+ }
389
+ const result = applyOwnerPublicationToRenderer(cache, pub, { acceptedGeneration: 1, lastVersion: 0 }, 'section-index')
390
+ expect(result.applied).toBe(true)
391
+ expect(result.lastVersion).toBe(3)
392
+ expect(cache.getLight(0, 64, 0).block).toBeCloseTo(11 / 15, 5)
393
+ expect(cache.getLight(0, 64, 0).sky).toBeCloseTo(4 / 15, 5)
394
+ expect(result.dirtyMeshSections.some(s => s.sx === 0 && s.sy === 64 && s.sz === 0)).toBe(true)
395
+ expect(result.dirtyMeshSections.some(s => s.sx === 16 && s.sy === 64 && s.sz === 0)).toBe(true)
396
+ expect(result.workerMessage?.type).toBe('applyOwnerLightPublication')
397
+ expect(result.workerMessage?.publicationVersion).toBe(3)
398
+ expect(result.workerMessage?.sections[0]?.blockLight).toHaveLength(2048)
399
+ })
400
+
401
+ it('uses exact owner world-origin dirtiness without expanding the stencil again', () => {
402
+ const cache = new RendererLightCache(VERSION)
403
+ cache.setWorldBounds(0, 256)
404
+ const result = applyOwnerPublicationToRenderer(
405
+ cache,
406
+ {
407
+ worldGeneration: 1,
408
+ publicationVersion: 1,
409
+ sections: [{ sx: 0, sy: 4, sz: 0, blockLight: packedNibble(1) }],
410
+ dirtyMeshSections: new Int32Array([0, 64, 0])
411
+ },
412
+ { acceptedGeneration: 1, lastVersion: 0 },
413
+ 'section-index'
414
+ )
415
+ expect(result.dirtyMeshSections).toEqual([{ sx: 0, sy: 64, sz: 0 }])
416
+ })
417
+
418
+ it('rejects a foreign publication with the wrong packed length and does not write the cache', () => {
419
+ const cache = new RendererLightCache(VERSION)
420
+ cache.setWorldBounds(0, 256)
421
+ const result = applyOwnerPublicationToRenderer(
422
+ cache,
423
+ {
424
+ worldGeneration: 1,
425
+ publicationVersion: 4,
426
+ sections: [{ sx: 0, sy: 4, sz: 0, blockLight: new Uint8Array(8) }]
427
+ },
428
+ { acceptedGeneration: 1, lastVersion: 0 },
429
+ 'section-index'
430
+ )
431
+ expect(result.applied).toBe(false)
432
+ expect(result.workerMessage).toBeNull()
433
+ expect(cache.getLight(0, 64, 0).block).toBe(0)
434
+ })
435
+ })
436
+
437
+ describe('flag-on in-process host: load / setBlock / unload → publication → cache', () => {
438
+ it('ingests a torch place and then air through the owner ABI', async () => {
439
+ const cache = new RendererLightCache(VERSION)
440
+ cache.setWorldBounds(0, 256)
441
+ const host = await LightOwnerHost.createInProcess(cache, { worldMinY: 0, worldHeight: 256 })
442
+ host.setLightTables({
443
+ emission: Uint8Array.of(0, 0, 14),
444
+ opacity: Uint8Array.of(0, 15, 0)
445
+ })
446
+ for (const event of eventsFromColumnLoad({
447
+ chunkX: 0,
448
+ chunkZ: 0,
449
+ chunkJson: makeAirColumnJson(),
450
+ version: VERSION,
451
+ worldMinY: 0,
452
+ worldHeight: 256
453
+ })) {
454
+ if ('sy' in event && event.sy !== 4) continue
455
+ host.pushEvent(event)
456
+ }
457
+ host.pushEvent(blockChangeEvent(8, 64, 8, 2))
458
+ const placed = await host.stepUntilIdle(64)
459
+ expect(placed?.publicationVersion).toBeGreaterThan(0)
460
+ expect(cache.getLight(8, 64, 8).block).toBeGreaterThan(0.5)
461
+ host.pushEvent(blockChangeEvent(8, 64, 8, 0))
462
+ await host.stepUntilIdle(32)
463
+ expect(cache.getLight(8, 64, 8).block).toBeCloseTo(2 / 15, 5)
464
+ host.pushEvent(eventsFromColumnUnload(0, 0))
465
+ await host.stepUntilIdle(16)
466
+ })
467
+ })