minecraft-renderer 0.1.106 → 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 (98) 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 +105 -76
  7. package/dist/minecraft-renderer.js.meta.json +1 -1
  8. package/dist/threeWorker.js +537 -508
  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 +8 -0
  14. package/src/graphicsBackend/rendererDefaultOptions.test.ts +51 -0
  15. package/src/graphicsBackend/rendererDefaultOptions.ts +18 -1
  16. package/src/graphicsBackend/rendererOptionsSync.ts +4 -3
  17. package/src/lib/blockEntityLightRegistry.ts +4 -0
  18. package/src/lib/skyLight.ts +17 -0
  19. package/src/lib/worldrendererCommon.lightOwner.test.ts +916 -0
  20. package/src/lib/worldrendererCommon.removeColumn.test.ts +16 -0
  21. package/src/lib/worldrendererCommon.ts +340 -67
  22. package/src/lighting/cache/rendererLightCache.test.ts +158 -0
  23. package/src/lighting/cache/rendererLightCache.ts +248 -0
  24. package/src/lighting/coordinator/clientLightCoordinator.test.ts +102 -0
  25. package/src/lighting/coordinator/clientLightCoordinator.ts +574 -0
  26. package/src/lighting/debug/clientLightProbe.test.ts +24 -0
  27. package/src/lighting/debug/clientLightProbe.ts +20 -0
  28. package/src/lighting/debug/clientLightTrace.test.ts +271 -0
  29. package/src/lighting/debug/clientLightTrace.ts +335 -0
  30. package/src/lighting/entity/entityLightController.test.ts +216 -0
  31. package/src/lighting/entity/entityLightController.ts +322 -0
  32. package/src/lighting/owner/clientLightOwner.test.ts +467 -0
  33. package/src/lighting/owner/clientLightOwner.ts +523 -0
  34. package/src/lighting/owner/lightOwnerHost.test.ts +78 -0
  35. package/src/lighting/owner/lightOwnerHost.ts +202 -0
  36. package/src/lighting/owner/lightOwnerWorker.ts +154 -0
  37. package/src/lighting/owner/updateLight1171OwnerPath.test.ts +68 -0
  38. package/src/lighting/owner/updateLightToOwnerEvents.ts +49 -0
  39. package/src/lighting/tables/lightTables1171.generated.json +10 -0
  40. package/src/lighting/tables/lightTables1171.test.ts +168 -0
  41. package/src/lighting/tables/lightTables1171.ts +59 -0
  42. package/src/lighting/versioning/clientLightVersions.test.ts +241 -0
  43. package/src/lighting/versioning/clientLightVersions.ts +203 -0
  44. package/src/lighting/versioning/pendingSectionFlush.test.ts +214 -0
  45. package/src/lighting/versioning/pendingSectionFlush.ts +123 -0
  46. package/src/mesher-shared/lightNibblePack.ts +26 -0
  47. package/src/mesher-shared/mcDataWorkerShim.cjs +9 -7
  48. package/src/mesher-shared/modelsGeometryCommon.test.ts +6 -1
  49. package/src/mesher-shared/shared.ts +48 -1
  50. package/src/three/chunkMeshManager.ts +26 -0
  51. package/src/three/entities.ts +97 -8
  52. package/src/three/entity/EntityMesh.ts +1 -0
  53. package/src/three/entity/legWalkAnimation.test.ts +154 -0
  54. package/src/three/entity/legWalkAnimation.ts +193 -0
  55. package/src/three/globalBlockBuffer.ts +94 -2
  56. package/src/three/globalLegacyBuffer.ts +94 -2
  57. package/src/three/graphicsBackendBase.ts +2 -9
  58. package/src/three/modules/clouds.ts +191 -0
  59. package/src/three/modules/index.ts +4 -0
  60. package/src/three/modules/sunMoon.ts +151 -0
  61. package/src/three/skyboxRenderer.ts +15 -8
  62. package/src/three/tests/clouds.test.ts +59 -0
  63. package/src/three/tests/globalBlockBuffer.test.ts +98 -0
  64. package/src/three/tests/globalLegacyBuffer.test.ts +108 -0
  65. package/src/three/tests/waterFog.test.ts +25 -0
  66. package/src/three/waterFog.ts +28 -0
  67. package/src/three/worldRendererThree.ts +84 -39
  68. package/src/wasm-mesher/bridge/render-from-wasm.ts +46 -1
  69. package/src/wasm-mesher/runtime-build/wasm_mesher.d.ts +31 -5
  70. package/src/wasm-mesher/runtime-build/wasm_mesher.js +180 -5
  71. package/src/wasm-mesher/runtime-build/wasm_mesher_bg.wasm +0 -0
  72. package/src/wasm-mesher/runtime-build/wasm_mesher_bg.wasm.d.ts +11 -0
  73. package/src/wasm-mesher/tests/mesherLightDependencies.test.ts +83 -0
  74. package/src/wasm-mesher/tests/mesherWasmColumnTickSelect.test.ts +39 -0
  75. package/src/wasm-mesher/tests/mesherWasmConversionCache.test.ts +30 -1
  76. package/src/wasm-mesher/tests/mesherWasmTickParseCache.test.ts +73 -0
  77. package/src/wasm-mesher/tests/mesherWasmTickSchedule.test.ts +51 -0
  78. package/src/wasm-mesher/tests/mesherWasmTopologyPostCache.test.ts +78 -0
  79. package/src/wasm-mesher/worker/clientLightDarkPublication.probe.test.ts +32 -0
  80. package/src/wasm-mesher/worker/mesherLightDependencies.ts +43 -0
  81. package/src/wasm-mesher/worker/mesherWasm.ownerLightSync.test.ts +198 -0
  82. package/src/wasm-mesher/worker/mesherWasm.ts +506 -96
  83. package/src/wasm-mesher/worker/mesherWasmChunkBuffer.ts +1 -0
  84. package/src/wasm-mesher/worker/mesherWasmColumnTickSelect.ts +47 -0
  85. package/src/wasm-mesher/worker/mesherWasmConversionCache.ts +16 -5
  86. package/src/wasm-mesher/worker/mesherWasmLightDirty.test.ts +17 -1
  87. package/src/wasm-mesher/worker/mesherWasmLightDirty.ts +10 -0
  88. package/src/wasm-mesher/worker/mesherWasmLightMerge.test.ts +208 -0
  89. package/src/wasm-mesher/worker/mesherWasmLightMerge.ts +228 -0
  90. package/src/wasm-mesher/worker/mesherWasmOwnerLight.test.ts +161 -0
  91. package/src/wasm-mesher/worker/mesherWasmOwnerLight.ts +112 -0
  92. package/src/wasm-mesher/worker/mesherWasmTickParseCache.ts +45 -0
  93. package/src/wasm-mesher/worker/mesherWasmTickSchedule.ts +44 -0
  94. package/src/wasm-mesher/worker/mesherWasmTopologyPostCache.ts +63 -0
  95. package/src/worldView/types.ts +3 -0
  96. package/src/worldView/worldView.geometryReload.test.ts +68 -0
  97. package/src/worldView/worldView.spiral.test.ts +1 -1
  98. package/src/worldView/worldView.ts +49 -1
@@ -0,0 +1,158 @@
1
+ //@ts-nocheck
2
+ import { describe, expect, it } from 'vitest'
3
+ import Chunks from 'prismarine-chunk'
4
+ import MinecraftData from 'minecraft-data'
5
+ import { Vec3 } from 'vec3'
6
+ import { RENDERER_LIGHT_BYTES_PER_SECTION, RendererLightCache } from './rendererLightCache'
7
+
8
+ const VERSION = '1.16.5'
9
+
10
+ function makeChunk() {
11
+ const Chunk = Chunks(VERSION) as any
12
+ return new Chunk(undefined as any)
13
+ }
14
+
15
+ function makePresentZeroSection(y = 64) {
16
+ const mcData = MinecraftData(VERSION)
17
+ const chunk = makeChunk()
18
+ const stoneId = mcData.blocksByName.stone.defaultState
19
+ chunk.setBlockStateId(new Vec3(8, y, 8), stoneId)
20
+ // Allocate light sections, then zero them. All-zero light is otherwise omitted
21
+ // and must stay the fullbright "absent section" fallback.
22
+ chunk.setBlockLight(new Vec3(8, y, 8), 1)
23
+ chunk.setSkyLight(new Vec3(8, y, 8), 1)
24
+ chunk.setBlockLight(new Vec3(8, y, 8), 0)
25
+ chunk.setSkyLight(new Vec3(8, y, 8), 0)
26
+ return chunk
27
+ }
28
+
29
+ function makeLitBlock(blockLight: number, skyLight: number, x = 8, y = 64, z = 8) {
30
+ const mcData = MinecraftData(VERSION)
31
+ const chunk = makeChunk()
32
+ const stoneId = mcData.blocksByName.stone.defaultState
33
+ chunk.setBlockStateId(new Vec3(x, y, z), stoneId)
34
+ chunk.setBlockLight(new Vec3(x, y, z), blockLight)
35
+ chunk.setSkyLight(new Vec3(x, y, z), skyLight)
36
+ return chunk
37
+ }
38
+
39
+ describe('RendererLightCache', () => {
40
+ it('returns fullbright for a missing column or absent section', () => {
41
+ const cache = new RendererLightCache(VERSION)
42
+ cache.setWorldBounds(0, 256)
43
+ expect(cache.getLight(8, 64, 8)).toEqual({ block: 0, sky: 1 })
44
+
45
+ cache.ingestColumn(0, 0, makeChunk().toJson())
46
+ expect(cache.getLight(8, 64, 8)).toEqual({ block: 0, sky: 1 })
47
+ })
48
+
49
+ it('applies +2 floor for a present raw-zero section and clamps at 15', () => {
50
+ const cache = new RendererLightCache(VERSION)
51
+ cache.setWorldBounds(0, 256)
52
+ cache.ingestColumn(0, 0, makePresentZeroSection().toJson())
53
+ expect(cache.getLight(8, 64, 8).block).toBeCloseTo(2 / 15, 5)
54
+ expect(cache.getLight(8, 64, 8).sky).toBeCloseTo(2 / 15, 5)
55
+
56
+ cache.ingestColumn(0, 0, makeLitBlock(14, 14).toJson())
57
+ expect(cache.getLight(8, 64, 8)).toEqual({ block: 1, sky: 1 })
58
+ })
59
+
60
+ it('reads packed channels at negative chunk coordinates', () => {
61
+ const cache = new RendererLightCache(VERSION)
62
+ cache.setWorldBounds(0, 256)
63
+ cache.ingestColumn(-16, -16, makeLitBlock(13, 4, 15, 64, 15).toJson())
64
+ const light = cache.getLight(-1, 64, -1)
65
+ expect(light.block).toBeCloseTo(15 / 15, 5)
66
+ expect(light.sky).toBeCloseTo(6 / 15, 5)
67
+ })
68
+
69
+ it('does not bump column revision on identical re-ingest', () => {
70
+ const cache = new RendererLightCache(VERSION)
71
+ cache.setWorldBounds(0, 256)
72
+ const json = makeLitBlock(10, 5).toJson()
73
+ const first = cache.ingestColumn(0, 0, json)
74
+ const second = cache.ingestColumn(0, 0, json)
75
+ expect(first.changed).toBe(true)
76
+ expect(second.changed).toBe(false)
77
+ expect(second.revision).toBe(first.revision)
78
+ expect(cache.getColumnRevision(0, 0)).toBe(first.revision)
79
+ })
80
+
81
+ it('bumps revision when light changes and forgets removed columns', () => {
82
+ const cache = new RendererLightCache(VERSION)
83
+ cache.setWorldBounds(0, 256)
84
+ const first = cache.ingestColumn(0, 0, makeLitBlock(3, 0).toJson())
85
+ const second = cache.ingestColumn(0, 0, makeLitBlock(12, 0).toJson())
86
+ expect(second.changed).toBe(true)
87
+ expect(second.revision).toBeGreaterThan(first.revision)
88
+ expect(cache.getLight(8, 64, 8).block).toBeCloseTo(14 / 15, 5)
89
+
90
+ const globalBefore = cache.getGlobalRevision()
91
+ cache.removeColumn(0, 0)
92
+ expect(cache.getLight(8, 64, 8)).toEqual({ block: 0, sky: 1 })
93
+ expect(cache.getGlobalRevision()).toBe(globalBefore)
94
+ expect(cache.getAllocatedSectionCount()).toBe(0)
95
+ })
96
+
97
+ it('does not bump global revision on ingest of another column', () => {
98
+ const cache = new RendererLightCache(VERSION)
99
+ cache.setWorldBounds(0, 256)
100
+ cache.ingestColumn(0, 0, makeLitBlock(3, 0).toJson())
101
+ const globalBefore = cache.getGlobalRevision()
102
+ cache.ingestColumn(16, 0, makeLitBlock(12, 0).toJson())
103
+ expect(cache.getGlobalRevision()).toBe(globalBefore)
104
+ expect(cache.getColumnRevision(16, 0)).toBeGreaterThan(0)
105
+ })
106
+
107
+ it('keeps prior column state when ingest fails', () => {
108
+ const cache = new RendererLightCache(VERSION)
109
+ cache.setWorldBounds(0, 256)
110
+ const ok = cache.ingestColumn(0, 0, makeLitBlock(8, 0).toJson())
111
+ const failed = cache.ingestColumn(0, 0, { not: 'a-chunk' })
112
+ expect(failed.changed).toBe(false)
113
+ expect(failed.revision).toBe(ok.revision)
114
+ expect(cache.getLight(8, 64, 8).block).toBeCloseTo(10 / 15, 5)
115
+ })
116
+
117
+ it('clear and bounds change drop light and bump the global revision', () => {
118
+ const cache = new RendererLightCache(VERSION)
119
+ cache.setWorldBounds(0, 256)
120
+ cache.ingestColumn(16, 32, makeLitBlock(15, 15).toJson())
121
+ expect(cache.getAllocatedBytes()).toBeGreaterThanOrEqual(RENDERER_LIGHT_BYTES_PER_SECTION)
122
+ const globalBefore = cache.getGlobalRevision()
123
+ cache.clear()
124
+ expect(cache.getLight(24, 64, 40)).toEqual({ block: 0, sky: 1 })
125
+ expect(cache.getGlobalRevision()).toBeGreaterThan(globalBefore)
126
+ })
127
+ })
128
+
129
+ describe('RendererLightCache 1.18+', () => {
130
+ const VERSION_118 = '1.18.2'
131
+ const MIN_Y = -64
132
+ const WORLD_HEIGHT = 384
133
+
134
+ function make118Chunk() {
135
+ const Chunk = Chunks(VERSION_118) as any
136
+ return new Chunk({ minY: MIN_Y, worldHeight: WORLD_HEIGHT })
137
+ }
138
+
139
+ function make118LitBlock(blockLight: number, skyLight: number, x = 8, y = -60, z = 8) {
140
+ const mcData = MinecraftData(VERSION_118)
141
+ const chunk = make118Chunk()
142
+ const stoneId = mcData.blocksByName.stone.defaultState
143
+ chunk.setBlockStateId(new Vec3(x, y, z), stoneId)
144
+ chunk.setBlockLight(new Vec3(x, y, z), blockLight)
145
+ chunk.setSkyLight(new Vec3(x, y, z), skyLight)
146
+ return chunk
147
+ }
148
+
149
+ it('packs and reads light in a negative-Y section', () => {
150
+ const cache = new RendererLightCache(VERSION_118)
151
+ cache.setWorldBounds(MIN_Y, WORLD_HEIGHT)
152
+ cache.ingestColumn(0, 0, make118LitBlock(11, 3).toJson())
153
+ const light = cache.getLight(8, -60, 8)
154
+ expect(light.block).toBeCloseTo(13 / 15, 5)
155
+ expect(light.sky).toBeCloseTo(5 / 15, 5)
156
+ expect(cache.getLight(8, -64, 8).block).toBeGreaterThan(0)
157
+ })
158
+ })
@@ -0,0 +1,248 @@
1
+ //@ts-nocheck
2
+ import Chunks from 'prismarine-chunk'
3
+ import { Vec3 } from 'vec3'
4
+
5
+ /** 16³ blocks, one packed byte (block<<4 | sky) per block. */
6
+ export const RENDERER_LIGHT_BYTES_PER_SECTION = 4096
7
+
8
+ const FULLBRIGHT = { block: 0, sky: 1 } as const
9
+
10
+ const sectionKey = (sx: number, sy: number, sz: number) => `${sx},${sy},${sz}`
11
+ const columnKey = (sx: number, sz: number) => `${sx},${sz}`
12
+ const alignSection = (coord: number) => Math.floor(coord / 16) * 16
13
+ const localCoord = (coord: number) => ((coord % 16) + 16) % 16
14
+ const blockIndex = (lx: number, ly: number, lz: number) => lx + lz * 16 + ly * 256
15
+
16
+ const channelNorm = (raw: number) => Math.min(15, raw + 2) / 15
17
+
18
+ const getLightSectionIndex = (y: number, minY = 0) => Math.floor((y - minY) / 16) + 1
19
+
20
+ const hasChunkSection = (column: any, pos: Vec3) => {
21
+ if (column._getSection) return column._getSection(pos)
22
+ if (column.skyLightSections) {
23
+ return column.skyLightSections[getLightSectionIndex(pos.y, column.minY)] || column.blockLightSections[getLightSectionIndex(pos.y, column.minY)]
24
+ }
25
+ if (column.sections) return column.sections[pos.y >> 4]
26
+ return false
27
+ }
28
+
29
+ const unpackNibble = (packed: Uint8Array, lx: number, ly: number, lz: number) => {
30
+ const local = (ly << 8) | (lz << 4) | lx
31
+ const byte = packed[local >> 1] ?? 0
32
+ return local & 1 ? byte >> 4 : byte & 0x0f
33
+ }
34
+
35
+ const hashBytes = (sections: Map<string, Uint8Array>) => {
36
+ let hash = 2166136261
37
+ const keys = [...sections.keys()].sort()
38
+ for (const key of keys) {
39
+ const data = sections.get(key)!
40
+ for (let i = 0; i < key.length; i++) {
41
+ hash ^= key.charCodeAt(i)
42
+ hash = Math.imul(hash, 16777619)
43
+ }
44
+ for (let i = 0; i < data.length; i++) {
45
+ hash ^= data[i]!
46
+ hash = Math.imul(hash, 16777619)
47
+ }
48
+ }
49
+ return hash >>> 0
50
+ }
51
+
52
+ type LightChunk = {
53
+ getBlockLight: (pos: Vec3) => number
54
+ getSkyLight: (pos: Vec3) => number
55
+ minY?: number
56
+ _getSection?: (pos: Vec3) => unknown
57
+ skyLightSections?: unknown[]
58
+ blockLightSections?: unknown[]
59
+ sections?: unknown
60
+ }
61
+
62
+ export type RendererLightSample = { block: number; sky: number }
63
+
64
+ export type RendererLightIngestResult = { changed: boolean; revision: number }
65
+
66
+ /**
67
+ * Main-thread packed block/sky light for entity sampling.
68
+ * Absent sections stay fullbright; present raw-zero gets the project +2 floor.
69
+ */
70
+ export class RendererLightCache {
71
+ private readonly sections = new Map<string, Uint8Array>()
72
+ private readonly columnHashes = new Map<string, number>()
73
+ private readonly columnRevisions = new Map<string, number>()
74
+ private Chunk: ReturnType<typeof Chunks>
75
+ private worldMinY = 0
76
+ private worldMaxY = 256
77
+ private globalRevision = 0
78
+ private nextColumnRevision = 1
79
+
80
+ constructor(version: string) {
81
+ this.Chunk = Chunks(version) as ReturnType<typeof Chunks>
82
+ }
83
+
84
+ setVersion(version: string) {
85
+ this.Chunk = Chunks(version) as ReturnType<typeof Chunks>
86
+ this.clear()
87
+ }
88
+
89
+ setWorldBounds(minY: number, worldHeight: number) {
90
+ if (this.worldMinY === minY && this.worldMaxY === worldHeight) return
91
+ this.worldMinY = minY
92
+ this.worldMaxY = worldHeight
93
+ this.clear()
94
+ }
95
+
96
+ clear() {
97
+ if (this.sections.size === 0 && this.columnRevisions.size === 0) {
98
+ this.globalRevision++
99
+ return
100
+ }
101
+ this.sections.clear()
102
+ this.columnHashes.clear()
103
+ this.columnRevisions.clear()
104
+ this.globalRevision++
105
+ }
106
+
107
+ getAllocatedSectionCount(): number {
108
+ return this.sections.size
109
+ }
110
+
111
+ getAllocatedBytes(): number {
112
+ return this.sections.size * RENDERER_LIGHT_BYTES_PER_SECTION
113
+ }
114
+
115
+ getGlobalRevision(): number {
116
+ return this.globalRevision
117
+ }
118
+
119
+ getColumnRevision(chunkX: number, chunkZ: number): number | undefined {
120
+ return this.columnRevisions.get(columnKey(alignSection(chunkX), alignSection(chunkZ)))
121
+ }
122
+
123
+ removeColumn(chunkX: number, chunkZ: number) {
124
+ const sx = alignSection(chunkX)
125
+ const sz = alignSection(chunkZ)
126
+ const col = columnKey(sx, sz)
127
+ const syStart = alignSection(this.worldMinY)
128
+ for (let sy = syStart; sy < this.worldMaxY; sy += 16) {
129
+ this.sections.delete(sectionKey(sx, sy, sz))
130
+ }
131
+ this.columnHashes.delete(col)
132
+ this.columnRevisions.delete(col)
133
+ }
134
+
135
+ /**
136
+ * Direct publication write: packed 2048-byte nibble channels.
137
+ * Block-only sections keep the current sky nibble; missing sky is 0, never 15.
138
+ * `sx/sy/sz` are world-aligned section origins.
139
+ */
140
+ ingestPackedSections(sections: Array<{ sx: number; sy: number; sz: number; blockLight: Uint8Array; skyLight?: Uint8Array }>): RendererLightIngestResult {
141
+ const touchedColumns = new Set<string>()
142
+ for (const section of sections) {
143
+ const sx = alignSection(section.sx)
144
+ const sy = alignSection(section.sy)
145
+ const sz = alignSection(section.sz)
146
+ if (section.blockLight.length !== 2048) continue
147
+ if (section.skyLight && section.skyLight.length !== 2048) continue
148
+ const key = sectionKey(sx, sy, sz)
149
+ const prev = this.sections.get(key)
150
+ const data = prev ? new Uint8Array(prev) : new Uint8Array(RENDERER_LIGHT_BYTES_PER_SECTION)
151
+ for (let ly = 0; ly < 16; ly++) {
152
+ for (let lz = 0; lz < 16; lz++) {
153
+ for (let lx = 0; lx < 16; lx++) {
154
+ const idx = blockIndex(lx, ly, lz)
155
+ const block = unpackNibble(section.blockLight, lx, ly, lz)
156
+ const sky = section.skyLight ? unpackNibble(section.skyLight, lx, ly, lz) : data[idx]! & 0x0f
157
+ data[idx] = (block << 4) | sky
158
+ }
159
+ }
160
+ }
161
+ this.sections.set(key, data)
162
+ touchedColumns.add(columnKey(sx, sz))
163
+ }
164
+
165
+ let revision = 0
166
+ for (const col of touchedColumns) {
167
+ revision = this.nextColumnRevision++
168
+ this.columnRevisions.set(col, revision)
169
+ const packed = new Map<string, Uint8Array>()
170
+ for (const [key, data] of this.sections) {
171
+ const [colSx, , colSz] = key.split(',').map(Number)
172
+ if (`${colSx},${colSz}` === col) packed.set(key, data)
173
+ }
174
+ this.columnHashes.set(col, hashBytes(packed))
175
+ }
176
+ return { changed: touchedColumns.size > 0, revision }
177
+ }
178
+
179
+ ingestColumn(chunkX: number, chunkZ: number, chunkJson: unknown): RendererLightIngestResult {
180
+ const sx = alignSection(chunkX)
181
+ const sz = alignSection(chunkZ)
182
+ const col = columnKey(sx, sz)
183
+ const previousRevision = this.columnRevisions.get(col) ?? 0
184
+
185
+ let chunk: LightChunk
186
+ try {
187
+ chunk = this.Chunk.fromJson(chunkJson as Parameters<ReturnType<typeof Chunks>['fromJson']>[0]) as unknown as LightChunk
188
+ if (typeof chunk?.getBlockLight !== 'function' || typeof chunk?.getSkyLight !== 'function') {
189
+ throw new Error('invalid chunk light API')
190
+ }
191
+ } catch {
192
+ return { changed: false, revision: previousRevision }
193
+ }
194
+
195
+ const packed = new Map<string, Uint8Array>()
196
+ const pos = new Vec3(0, 0, 0)
197
+ const syStart = alignSection(this.worldMinY)
198
+ for (let sy = syStart; sy < this.worldMaxY; sy += 16) {
199
+ pos.set(0, sy, 0)
200
+ if (!hasChunkSection(chunk, pos)) continue
201
+ const data = new Uint8Array(RENDERER_LIGHT_BYTES_PER_SECTION)
202
+ for (let ly = 0; ly < 16; ly++) {
203
+ pos.y = sy + ly
204
+ for (let lz = 0; lz < 16; lz++) {
205
+ pos.z = lz
206
+ for (let lx = 0; lx < 16; lx++) {
207
+ pos.x = lx
208
+ const block = Math.max(0, Math.min(15, chunk.getBlockLight(pos) | 0))
209
+ const sky = Math.max(0, Math.min(15, chunk.getSkyLight(pos) | 0))
210
+ data[blockIndex(lx, ly, lz)] = (block << 4) | sky
211
+ }
212
+ }
213
+ }
214
+ packed.set(sectionKey(sx, sy, sz), data)
215
+ }
216
+
217
+ const hash = hashBytes(packed)
218
+ if (this.columnHashes.get(col) === hash && this.columnRevisions.has(col)) {
219
+ return { changed: false, revision: previousRevision }
220
+ }
221
+
222
+ const syStartRemove = alignSection(this.worldMinY)
223
+ for (let sy = syStartRemove; sy < this.worldMaxY; sy += 16) {
224
+ this.sections.delete(sectionKey(sx, sy, sz))
225
+ }
226
+ for (const [key, data] of packed) {
227
+ this.sections.set(key, data)
228
+ }
229
+ this.columnHashes.set(col, hash)
230
+ const revision = this.nextColumnRevision++
231
+ this.columnRevisions.set(col, revision)
232
+ return { changed: true, revision }
233
+ }
234
+
235
+ getLight(x: number, y: number, z: number): RendererLightSample {
236
+ if (y < this.worldMinY || y >= this.worldMaxY) return { ...FULLBRIGHT }
237
+ const sx = alignSection(x)
238
+ const sy = alignSection(y)
239
+ const sz = alignSection(z)
240
+ const data = this.sections.get(sectionKey(sx, sy, sz))
241
+ if (!data) return { ...FULLBRIGHT }
242
+ const packed = data[blockIndex(localCoord(x), y - sy, localCoord(z))] ?? 0
243
+ return {
244
+ block: channelNorm(packed >> 4),
245
+ sky: channelNorm(packed & 0x0f)
246
+ }
247
+ }
248
+ }
@@ -0,0 +1,102 @@
1
+ //@ts-nocheck
2
+ import { describe, expect, test, vi } from 'vitest'
3
+ import { Vec3 } from 'vec3'
4
+ import { defaultWorldRendererConfig } from '../../graphicsBackend/config'
5
+ import { ClientLightCoordinator, type ClientLightHost } from './clientLightCoordinator'
6
+
7
+ function createHost(overrides: Partial<ClientLightHost> = {}) {
8
+ const dispatched: Array<{ pos: Vec3; value: boolean; useChangeWorker: boolean }> = []
9
+ const host: ClientLightHost = {
10
+ version: '1.17.1',
11
+ workers: [],
12
+ rendererLightCache: {} as ClientLightHost['rendererLightCache'],
13
+ worldRendererConfig: { ...defaultWorldRendererConfig },
14
+ worldSizeParams: { minY: 0, worldHeight: 256 },
15
+ playerStateReactive: {} as ClientLightHost['playerStateReactive'],
16
+ sectionsWaiting: new Map(),
17
+ loadedChunks: {},
18
+ sectionDirtyLightMeta: new Map(),
19
+ getSectionHeight: () => 16,
20
+ getWorkerNumber: () => 0,
21
+ setSectionDirty: vi.fn(),
22
+ dispatchDirtyImmediate: (pos, value, useChangeWorker) => dispatched.push({ pos, value, useChangeWorker }),
23
+ createLightOwnerWorker: vi.fn(),
24
+ ...overrides
25
+ }
26
+ return { host, dispatched, coordinator: new ClientLightCoordinator(host) }
27
+ }
28
+
29
+ describe('ClientLightCoordinator', () => {
30
+ test('stays inert and accepts geometry while the owner is disabled', () => {
31
+ const { coordinator } = createHost()
32
+ coordinator.maybeSpawn()
33
+ expect(coordinator.isSessionLive()).toBe(false)
34
+ expect(coordinator.hasOwner()).toBe(false)
35
+ expect(coordinator.evaluateOwnerGeometry({ type: 'geometry', key: '0,64,0' })).toEqual({ accepted: true, keepCovering: false })
36
+ })
37
+
38
+ test('explains why the owner did not start when the version gate refuses it', () => {
39
+ const { coordinator } = createHost({ version: '1.20.4', worldRendererConfig: { ...defaultWorldRendererConfig, enableClientLightOwner: true } })
40
+ coordinator.maybeSpawn()
41
+ expect(coordinator.isSessionLive()).toBe(false)
42
+ expect(coordinator.getFailureReason()).toContain('1.17.1')
43
+ })
44
+
45
+ test('a block change bumps the touched section and an edge neighbour, and tracks the edit', () => {
46
+ const { coordinator } = createHost()
47
+ coordinator.noteBlockChange(new Vec3(0, 64, 5), 1, true)
48
+ expect(coordinator.topologyRevisionBySection.has('0,64,0')).toBe(true)
49
+ expect(coordinator.topologyRevisionBySection.has('-16,64,0')).toBe(true)
50
+ expect(coordinator.topologyRevisionBySection.has('16,64,0')).toBe(false)
51
+ expect(coordinator.interactiveSections.has('0,64,0')).toBe(true)
52
+ // neighbours of an interactive section count as interactive work too
53
+ expect(coordinator.sectionTouchesInteractiveWork('16,64,0')).toBe(true)
54
+ expect(coordinator.sectionTouchesInteractiveWork('48,64,0')).toBe(false)
55
+ })
56
+
57
+ test('neighbour topology is not bumped when neighbour updates are off', () => {
58
+ const { coordinator } = createHost()
59
+ coordinator.noteBlockChange(new Vec3(0, 64, 5), 1, false)
60
+ expect([...coordinator.topologyRevisionBySection.keys()]).toEqual(['0,64,0'])
61
+ })
62
+
63
+ test('removing a column bumps its incarnation and forgets only that column', () => {
64
+ const { coordinator } = createHost()
65
+ coordinator.topologyRevisionBySection.set('0,64,0', 3)
66
+ coordinator.topologyRevisionBySection.set('16,64,0', 3)
67
+ coordinator.pendingCoveringBySection.set('0,32,0', { requiredVersion: 1, worldGeneration: 1 })
68
+ coordinator.pendingTopologyBySection.set('0,32,0', 2)
69
+ coordinator.rejectedFinishedBySection.set('0,32,0', 1)
70
+ const before = coordinator.columnIncarnationFor('0,0')
71
+
72
+ coordinator.onColumnRemoved(0, 0)
73
+
74
+ expect(coordinator.columnIncarnationFor('0,0')).toBe(before + 1)
75
+ expect(coordinator.columnIncarnationFor('16,0')).toBe(1)
76
+ expect([...coordinator.topologyRevisionBySection.keys()]).toEqual(['16,64,0'])
77
+ expect(coordinator.pendingCoveringBySection.size).toBe(0)
78
+ expect(coordinator.pendingTopologyBySection.size).toBe(0)
79
+ expect(coordinator.rejectedFinishedBySection.size).toBe(0)
80
+ })
81
+
82
+ test('rejected finishes are consumed one by one', () => {
83
+ const { coordinator } = createHost()
84
+ coordinator.noteRejectedOwnerGeometry('0,64,0', undefined, { expectLaterFinished: true })
85
+ coordinator.noteRejectedOwnerGeometry('0,64,0', undefined, { expectLaterFinished: true })
86
+ expect(coordinator.consumeRejectedFinish('0,64,0')).toBe(true)
87
+ expect(coordinator.consumeRejectedFinish('0,64,0')).toBe(true)
88
+ expect(coordinator.consumeRejectedFinish('0,64,0')).toBe(false)
89
+ })
90
+
91
+ test('dirty stamps carry no owner versions while the owner is off, and urgency is restored', () => {
92
+ const { coordinator } = createHost()
93
+ const { lightMeta, ownerVersions, traceIds } = coordinator.describeDirty(new Vec3(0, 64, 0), '0,64,0')
94
+ expect(lightMeta).toBeUndefined()
95
+ expect(ownerVersions).toEqual({})
96
+ expect(traceIds).toBeUndefined()
97
+
98
+ coordinator.nextDirtyUrgent = true
99
+ coordinator.withDirtyUrgency(false, () => expect(coordinator.nextDirtyUrgent).toBe(false))
100
+ expect(coordinator.nextDirtyUrgent).toBe(true)
101
+ })
102
+ })