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,574 @@
1
+ //@ts-nocheck
2
+ import { Vec3 } from 'vec3'
3
+ import type { WorldRendererConfig } from '../../graphicsBackend/config'
4
+ import type { PlayerStateReactive } from '../../playerState/playerState'
5
+ import type { MesherMainEvent } from '../../mesher-shared/shared'
6
+ import type { RendererLightCache } from '../cache/rendererLightCache'
7
+ import { isClientLightProbeSection, setClientLightProbePositions } from '../debug/clientLightProbe'
8
+ import {
9
+ currentClientLightTraceIds,
10
+ isClientLightTraceArmed,
11
+ isClientLightTraceEnabled,
12
+ nextClientLightEditSeq,
13
+ nextClientLightRequestId,
14
+ recordClientLightTrace
15
+ } from '../debug/clientLightTrace'
16
+ import {
17
+ ClientLightOwnerSession,
18
+ clientLightOwnerVersionBlockReason,
19
+ coveringReplacementForReject,
20
+ meshWorkerIndexesForDirtySections,
21
+ raiseRequiredLightRevisions,
22
+ shouldAcceptMeshGeometry,
23
+ shouldSpawnClientLightOwner,
24
+ skyLightEnabledFromRendererState,
25
+ type OwnerPublicationApplyResult
26
+ } from '../owner/clientLightOwner'
27
+ import {
28
+ INITIAL_TOPOLOGY_REVISION,
29
+ isOwnerGeometryLightStale,
30
+ nextTopologyRevision,
31
+ shouldDispatchCoveringRemesh,
32
+ validateMeshResultVersions,
33
+ type MeshSectionLightRequirement
34
+ } from '../versioning/clientLightVersions'
35
+
36
+ const CHUNK_SIZE = 16
37
+
38
+ export type DirtyLightMeta = { lightPublicationVersion?: number; worldGeneration?: number }
39
+
40
+ /** What the coordinator needs from the world renderer. Getters are read lazily, the renderer replaces `workers` on reset. */
41
+ export type ClientLightHost = {
42
+ readonly version: string
43
+ readonly workers: Worker[]
44
+ readonly rendererLightCache: RendererLightCache
45
+ readonly worldRendererConfig: WorldRendererConfig
46
+ readonly worldSizeParams: { minY: number; worldHeight: number }
47
+ readonly playerStateReactive: PlayerStateReactive
48
+ readonly sectionsWaiting: Map<string, number>
49
+ readonly loadedChunks: Record<string, boolean>
50
+ /** Last stored light stamp per section, owned by the renderer's dirty throttle. */
51
+ readonly sectionDirtyLightMeta: Map<string, { lightPublicationVersion: number; worldGeneration?: number }>
52
+ getSectionHeight(): number
53
+ getWorkerNumber(pos: Vec3, updateAction?: boolean): number
54
+ setSectionDirty(pos: Vec3, value?: boolean, useChangeWorker?: boolean): void
55
+ dispatchDirtyImmediate(pos: Vec3, value: boolean, useChangeWorker: boolean): void
56
+ createLightOwnerWorker(onMessage: (data: any) => void): Worker
57
+ }
58
+
59
+ type OwnerGeometryEvent = {
60
+ type?: string
61
+ key?: string
62
+ worldGeneration?: number
63
+ lightPublicationVersion?: number
64
+ topologyRevision?: number
65
+ sessionEpoch?: number
66
+ columnIncarnation?: number
67
+ hadErrors?: boolean
68
+ meshMode?: 'owner' | 'legacyBootstrap'
69
+ geometry?: { hadErrors?: boolean }
70
+ }
71
+
72
+ const columnMatches = (key: string, x: number, z: number) => key.startsWith(`${x},`) && key.endsWith(`,${z}`)
73
+
74
+ /**
75
+ * Owns the client light owner session and every piece of version bookkeeping that keeps owner-lit meshes
76
+ * consistent: column incarnations, per-section topology revisions, outstanding covering remeshes and
77
+ * player edits waiting for their light. The world renderer only forwards events and stays unaware of the details.
78
+ */
79
+ export class ClientLightCoordinator {
80
+ session: ClientLightOwnerSession | null = null
81
+ failureReason: string | null = null
82
+ fence = { sessionEpoch: 1, rejectStaleOwnerMeshes: false }
83
+ readonly columnIncarnationByColumn = new Map<string, number>()
84
+ readonly topologyRevisionBySection = new Map<string, number>()
85
+ readonly pendingTopologyBySection = new Map<string, number>()
86
+ readonly pendingCoveringBySection = new Map<string, MeshSectionLightRequirement>()
87
+ readonly rejectedFinishedBySection = new Map<string, number>()
88
+ /** Stamp applied to the next dirty dispatch; set only while a publication is being turned into remeshes. */
89
+ nextDirtyLightMeta: DirtyLightMeta = {}
90
+ /** Dispatch the next dirty message ahead of the bulk queue. */
91
+ nextDirtyUrgent = false
92
+ /**
93
+ * Player edits whose covering light publication is still outstanding.
94
+ * `lightVersionAtEdit` is the owner version already on screen. Geometry at that
95
+ * version must not clear the edit: the publication for this edit is a newer one.
96
+ */
97
+ readonly interactiveSections = new Map<string, { lightVersionAtEdit: number; editGeneration: number; coveringVersion?: number }>()
98
+
99
+ constructor(private readonly host: ClientLightHost) {}
100
+
101
+ // --- session lifecycle -------------------------------------------------------------------------------------
102
+
103
+ hasOwner(): boolean {
104
+ return this.session?.isReady === true
105
+ }
106
+
107
+ isSessionLive(): boolean {
108
+ return this.session != null
109
+ }
110
+
111
+ getWorker(): Worker | null {
112
+ return this.session?.worker ?? null
113
+ }
114
+
115
+ getFailureReason(): string | null {
116
+ return this.failureReason ?? this.session?.lastFailureReason ?? null
117
+ }
118
+
119
+ setProbe(points: Array<[number, number, number]>) {
120
+ setClientLightProbePositions(points)
121
+ for (const worker of this.host.workers) worker.postMessage({ type: 'clientLightProbe', points })
122
+ }
123
+
124
+ syncTraceConfig() {
125
+ const message = {
126
+ type: 'clientLightTraceConfig' as const,
127
+ enabled: isClientLightTraceEnabled(),
128
+ armed: isClientLightTraceArmed()
129
+ }
130
+ for (const worker of this.host.workers) {
131
+ worker.postMessage(message)
132
+ }
133
+ this.session?.worker.postMessage(message)
134
+ }
135
+
136
+ maybeSpawn() {
137
+ if (this.session) return
138
+ const { worldRendererConfig, version } = this.host
139
+ if (!shouldSpawnClientLightOwner(worldRendererConfig, version)) {
140
+ if (worldRendererConfig.enableClientLightOwner === true) {
141
+ this.failureReason = clientLightOwnerVersionBlockReason(version)
142
+ }
143
+ return
144
+ }
145
+ this.fence = { sessionEpoch: this.fence.sessionEpoch, rejectStaleOwnerMeshes: false }
146
+ this.failureReason = null
147
+ this.session = new ClientLightOwnerSession(this.host.rendererLightCache, {
148
+ createWorker: onMessage => this.host.createLightOwnerWorker(onMessage),
149
+ worldMinY: this.host.worldSizeParams.minY,
150
+ worldHeight: this.host.worldSizeParams.worldHeight,
151
+ skyLightEnabled: skyLightEnabledFromRendererState(this.host.playerStateReactive),
152
+ onApplied: result => this.onPublication(result),
153
+ onFailed: reason => this.onFailed(reason),
154
+ onOwnerIdle: settledEditGeneration => this.releaseInteractiveEditsWithoutCoveringPublication(settledEditGeneration)
155
+ })
156
+ this.postAcceptFence()
157
+ this.syncTraceConfig()
158
+ }
159
+
160
+ terminate() {
161
+ if (this.session) {
162
+ this.revertToIncoming('owner-disabled')
163
+ }
164
+ this.session?.terminate()
165
+ this.session = null
166
+ this.nextDirtyLightMeta = {}
167
+ }
168
+
169
+ private onFailed(reason: string) {
170
+ this.failureReason = reason
171
+ this.revertToIncoming(reason)
172
+ }
173
+
174
+ private revertToIncoming(reason: string) {
175
+ this.fence = {
176
+ sessionEpoch: this.fence.sessionEpoch + 1,
177
+ rejectStaleOwnerMeshes: true
178
+ }
179
+ this.failureReason = reason
180
+ this.interactiveSections.clear()
181
+ this.session?.requiredLightBySection.clear()
182
+ const message = {
183
+ type: 'revertOwnerLightToIncoming' as const,
184
+ sessionEpoch: this.fence.sessionEpoch,
185
+ worldGeneration: this.session?.gate.acceptedGeneration ?? 1
186
+ }
187
+ for (const worker of this.host.workers) {
188
+ worker.postMessage(message)
189
+ worker.postMessage({
190
+ type: 'setOwnerAcceptFence',
191
+ sessionEpoch: this.fence.sessionEpoch,
192
+ rejectOwnerPublications: true
193
+ })
194
+ }
195
+ }
196
+
197
+ private postAcceptFence() {
198
+ for (const worker of this.host.workers) {
199
+ worker.postMessage({
200
+ type: 'setOwnerAcceptFence',
201
+ sessionEpoch: this.fence.sessionEpoch,
202
+ rejectOwnerPublications: this.fence.rejectStaleOwnerMeshes
203
+ })
204
+ }
205
+ }
206
+
207
+ /** Mesh workers were replaced: their topology revisions belong to the previous mesher lifetime. */
208
+ forgetRequiredTopology() {
209
+ for (const required of this.session?.requiredLightBySection.values() ?? []) {
210
+ delete required.topologyRevision
211
+ }
212
+ }
213
+
214
+ /** Drops everything tied to outstanding mesher requests (the workers that owed us answers are gone). */
215
+ clearPendingMesherState() {
216
+ this.pendingCoveringBySection.clear()
217
+ this.pendingTopologyBySection.clear()
218
+ this.rejectedFinishedBySection.clear()
219
+ this.interactiveSections.clear()
220
+ }
221
+
222
+ // --- publications ------------------------------------------------------------------------------------------
223
+
224
+ onPublication(result: OwnerPublicationApplyResult) {
225
+ const { workers } = this.host
226
+ if (result.workerMessage) {
227
+ const workerMessage = {
228
+ ...result.workerMessage,
229
+ sessionEpoch: this.fence.sessionEpoch
230
+ }
231
+ // A column's own worker needs its light even when nothing there is dirty; a missing section meshes as sky 15.
232
+ const targets = meshWorkerIndexesForDirtySections([...result.dirtyMeshSections, ...workerMessage.sections], workers.length, (sx, sy, sz) =>
233
+ this.host.getWorkerNumber(new Vec3(sx, sy, sz), true)
234
+ )
235
+ const indexes = targets.length ? targets : workers.map((_, index) => index)
236
+ for (const index of indexes) {
237
+ workers[index]?.postMessage(workerMessage)
238
+ }
239
+ }
240
+ this.nextDirtyLightMeta = {
241
+ lightPublicationVersion: result.lastVersion,
242
+ worldGeneration: result.acceptedGeneration
243
+ }
244
+ this.noteCoveringPublication(
245
+ result.lastVersion,
246
+ result.dirtyMeshSections.map(section => `${section.sx},${section.sy},${section.sz}`)
247
+ )
248
+ try {
249
+ if (this.session) {
250
+ raiseRequiredLightRevisions(
251
+ this.session.requiredLightBySection,
252
+ result.dirtyMeshSections,
253
+ result.lastVersion,
254
+ result.acceptedGeneration,
255
+ this.topologyRevisionBySection
256
+ )
257
+ }
258
+ for (const section of result.dirtyMeshSections) {
259
+ const key = `${section.sx},${section.sy},${section.sz}`
260
+ const required = this.session?.requiredLightForSection(key)
261
+ this.pendingCoveringBySection.set(
262
+ key,
263
+ required ?? {
264
+ requiredVersion: result.lastVersion,
265
+ worldGeneration: result.acceptedGeneration
266
+ }
267
+ )
268
+ const urgentCovering = this.sectionTouchesInteractiveWork(key)
269
+ this.nextDirtyUrgent = urgentCovering
270
+ try {
271
+ this.host.setSectionDirty(new Vec3(section.sx, section.sy, section.sz), true, true)
272
+ } finally {
273
+ this.nextDirtyUrgent = false
274
+ }
275
+ }
276
+ } finally {
277
+ this.nextDirtyLightMeta = {}
278
+ }
279
+ }
280
+
281
+ // --- mesh results ------------------------------------------------------------------------------------------
282
+
283
+ columnIncarnationFor(columnKey: string): number {
284
+ return this.columnIncarnationByColumn.get(columnKey) ?? 1
285
+ }
286
+
287
+ evaluateOwnerGeometry(data: OwnerGeometryEvent): { accepted: boolean; keepCovering: boolean } {
288
+ if (data.type !== 'geometry') return { accepted: true, keepCovering: false }
289
+ const ownerManaged = this.session?.isReady === true || this.fence.rejectStaleOwnerMeshes
290
+ if (!ownerManaged && this.session == null) return { accepted: true, keepCovering: false }
291
+ if (data.key) {
292
+ const versions = validateMeshResultVersions({ ...data, key: data.key })
293
+ if (!versions.ok && this.session?.isReady) return { accepted: false, keepCovering: false }
294
+ }
295
+ const required = data.key ? this.session?.requiredLightForSection(data.key) : undefined
296
+ const [x, , z] = (data.key ?? '').split(',').map(Number)
297
+ const accepted = shouldAcceptMeshGeometry(
298
+ {
299
+ worldGeneration: data.worldGeneration,
300
+ lightPublicationVersion: data.lightPublicationVersion,
301
+ topologyRevision: data.topologyRevision,
302
+ sessionEpoch: data.sessionEpoch,
303
+ columnIncarnation: data.columnIncarnation,
304
+ hadErrors: data.hadErrors ?? data.geometry?.hadErrors,
305
+ meshMode: data.meshMode
306
+ },
307
+ this.session?.gate ?? { acceptedGeneration: 1, lastVersion: 0 },
308
+ required,
309
+ {
310
+ ownerManaged,
311
+ sessionEpoch: this.fence.sessionEpoch,
312
+ columnIncarnation: Number.isFinite(x) && Number.isFinite(z) ? this.columnIncarnationFor(`${x},${z}`) : undefined
313
+ }
314
+ )
315
+ return {
316
+ accepted,
317
+ keepCovering: accepted && isOwnerGeometryLightStale(data, required)
318
+ }
319
+ }
320
+
321
+ acknowledgeAcceptedTopology(data: { key?: string; topologyRevision?: number }) {
322
+ if (!data.key || data.topologyRevision == null) return
323
+ const pending = this.pendingTopologyBySection.get(data.key)
324
+ if (pending != null && data.topologyRevision >= pending) {
325
+ this.pendingTopologyBySection.delete(data.key)
326
+ }
327
+ }
328
+
329
+ maybeDispatchCoveringRemesh(key: string, required?: MeshSectionLightRequirement | null) {
330
+ required ??= this.session?.requiredLightForSection(key)
331
+ if (required == null) return
332
+ const outstandingCount = this.host.sectionsWaiting.get(key) ?? 0
333
+ const pending = this.pendingCoveringBySection.get(key)
334
+ if (
335
+ !shouldDispatchCoveringRemesh({
336
+ required,
337
+ outstandingCount,
338
+ pendingCoveringVersion: pending?.requiredVersion,
339
+ pendingCoveringGeneration: pending?.worldGeneration
340
+ })
341
+ ) {
342
+ return
343
+ }
344
+ const replacement = coveringReplacementForReject({ accepted: false, required })
345
+ const [x, y, z] = key.split(',').map(Number)
346
+ if (replacement && Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z) && this.host.loadedChunks[`${x},${z}`]) {
347
+ this.pendingCoveringBySection.set(key, {
348
+ requiredVersion: replacement.lightPublicationVersion,
349
+ worldGeneration: replacement.worldGeneration,
350
+ topologyRevision: required.topologyRevision
351
+ })
352
+ this.host.sectionDirtyLightMeta.set(key, replacement)
353
+ this.nextDirtyLightMeta = replacement
354
+ this.nextDirtyUrgent = this.sectionTouchesInteractiveWork(key)
355
+ try {
356
+ this.host.dispatchDirtyImmediate(new Vec3(x, y, z), true, true)
357
+ } finally {
358
+ this.nextDirtyLightMeta = {}
359
+ this.nextDirtyUrgent = false
360
+ }
361
+ }
362
+ }
363
+
364
+ noteRejectedOwnerGeometry(key: string, required: MeshSectionLightRequirement | null | undefined, opts?: { expectLaterFinished?: boolean }) {
365
+ if (opts?.expectLaterFinished !== false) {
366
+ this.rejectedFinishedBySection.set(key, (this.rejectedFinishedBySection.get(key) ?? 0) + 1)
367
+ return
368
+ }
369
+ this.maybeDispatchCoveringRemesh(key, required)
370
+ }
371
+
372
+ /** Returns true when the geometry must be dropped. */
373
+ rejectStaleOwnerGeometry(data: MesherMainEvent & OwnerGeometryEvent): boolean {
374
+ if (data.type !== 'geometry') return false
375
+ const decision = this.evaluateOwnerGeometry(data)
376
+ if (isClientLightProbeSection(data.key)) {
377
+ console.log('[lightProbe:mesh]', {
378
+ time: Date.now(),
379
+ key: data.key,
380
+ lightPublicationVersion: data.lightPublicationVersion,
381
+ required: this.session?.requiredLightForSection(data.key),
382
+ accepted: decision.accepted,
383
+ keepCovering: decision.keepCovering
384
+ })
385
+ }
386
+ if (!decision.accepted) {
387
+ this.noteRejectedOwnerGeometry(data.key, this.session?.requiredLightForSection(data.key), {
388
+ expectLaterFinished: true
389
+ })
390
+ return true
391
+ }
392
+ this.acknowledgeAcceptedTopology(data)
393
+ if (decision.keepCovering) this.maybeDispatchCoveringRemesh(data.key)
394
+ else this.releaseInteractiveSectionIfLightCommitted(data)
395
+ return false
396
+ }
397
+
398
+ /** `sectionFinished` arrived: true when it answers a geometry we rejected (so the section is not finished yet). */
399
+ consumeRejectedFinish(key: string): boolean {
400
+ const rejectedFinishes = this.rejectedFinishedBySection.get(key) ?? 0
401
+ if (rejectedFinishes <= 0) return false
402
+ const nextRejected = rejectedFinishes - 1
403
+ if (nextRejected === 0) this.rejectedFinishedBySection.delete(key)
404
+ else this.rejectedFinishedBySection.set(key, nextRejected)
405
+ return true
406
+ }
407
+
408
+ forgetCovering(key: string) {
409
+ this.pendingCoveringBySection.delete(key)
410
+ }
411
+
412
+ // --- dirty dispatch ----------------------------------------------------------------------------------------
413
+
414
+ /** Version stamps for a dirty message of section `key`; the caller spreads them into the message. */
415
+ describeDirty(pos: Vec3, key: string) {
416
+ const lightMeta = this.nextDirtyLightMeta.lightPublicationVersion != null ? this.nextDirtyLightMeta : this.host.sectionDirtyLightMeta.get(key)
417
+ const ownerManaged = this.session != null || this.fence.rejectStaleOwnerMeshes
418
+ const requestId = ownerManaged || isClientLightTraceEnabled() ? nextClientLightRequestId() : undefined
419
+ const traceIds = isClientLightTraceEnabled() ? { ...currentClientLightTraceIds(), requestId } : undefined
420
+ const ownerVersions = ownerManaged
421
+ ? {
422
+ sessionEpoch: this.fence.sessionEpoch,
423
+ columnIncarnation: this.columnIncarnationFor(`${Math.floor(pos.x / CHUNK_SIZE) * CHUNK_SIZE},${Math.floor(pos.z / CHUNK_SIZE) * CHUNK_SIZE}`),
424
+ requestId,
425
+ topologyRevision: this.topologyRevisionBySection.get(key) ?? INITIAL_TOPOLOGY_REVISION,
426
+ neighborTopologyRevisions: this.snapshotNeighborTopology(key)
427
+ }
428
+ : {}
429
+ return { lightMeta, ownerVersions, traceIds }
430
+ }
431
+
432
+ /** Runs `fn` with the urgent flag temporarily set from a deferred dispatch. */
433
+ withDirtyUrgency(urgent: boolean, fn: () => void) {
434
+ const previousUrgent = this.nextDirtyUrgent
435
+ this.nextDirtyUrgent = urgent
436
+ try {
437
+ fn()
438
+ } finally {
439
+ this.nextDirtyUrgent = previousUrgent
440
+ }
441
+ }
442
+
443
+ // --- block edits and columns -------------------------------------------------------------------------------
444
+
445
+ /** A block changed: forward it to the owner, bump topology (also of touched neighbours) and track the player edit. */
446
+ noteBlockChange(pos: Vec3, stateId: number | undefined, neighborUpdates: boolean) {
447
+ const sectionHeight = this.host.getSectionHeight()
448
+ if (stateId !== undefined) {
449
+ this.session?.onBlockChange(pos.x, pos.y, pos.z, stateId)
450
+ }
451
+ this.bumpTopologyRevision(pos)
452
+ if (neighborUpdates) {
453
+ if ((pos.x & 15) === 0) this.bumpTopologyRevision(pos.offset(-CHUNK_SIZE, 0, 0))
454
+ if ((pos.x & 15) === 15) this.bumpTopologyRevision(pos.offset(CHUNK_SIZE, 0, 0))
455
+ if ((pos.y & (sectionHeight - 1)) === 0) this.bumpTopologyRevision(pos.offset(0, -sectionHeight, 0))
456
+ if ((pos.y & (sectionHeight - 1)) === sectionHeight - 1) this.bumpTopologyRevision(pos.offset(0, sectionHeight, 0))
457
+ if ((pos.z & 15) === 0) this.bumpTopologyRevision(pos.offset(0, 0, -CHUNK_SIZE))
458
+ if ((pos.z & 15) === 15) this.bumpTopologyRevision(pos.offset(0, 0, CHUNK_SIZE))
459
+ }
460
+ if (isClientLightTraceEnabled()) {
461
+ recordClientLightTrace({
462
+ phase: 'blockChange',
463
+ editSeq: nextClientLightEditSeq(),
464
+ sectionKey: this.sectionKeyFor(pos.x, pos.y, pos.z),
465
+ blockVersion: stateId
466
+ })
467
+ }
468
+ this.noteInteractiveSection(pos)
469
+ }
470
+
471
+ onColumnRemoved(x: number, z: number, opts?: { preserveClientLight?: boolean }) {
472
+ if (opts?.preserveClientLight && this.session?.isReady) {
473
+ this.session.onGeometryReload(x, z)
474
+ } else {
475
+ this.session?.onUnload(x, z)
476
+ }
477
+ const columnKey = `${x},${z}`
478
+ this.columnIncarnationByColumn.set(columnKey, this.columnIncarnationFor(columnKey) + 1)
479
+ for (const map of [this.topologyRevisionBySection, this.pendingCoveringBySection, this.pendingTopologyBySection, this.rejectedFinishedBySection]) {
480
+ for (const key of [...map.keys()]) {
481
+ if (columnMatches(key, x, z)) map.delete(key)
482
+ }
483
+ }
484
+ }
485
+
486
+ forgetInteractiveSection(key: string) {
487
+ this.interactiveSections.delete(key)
488
+ }
489
+
490
+ // --- internals ---------------------------------------------------------------------------------------------
491
+
492
+ private sectionKeyFor(x: number, y: number, z: number): string {
493
+ const sectionHeight = this.host.getSectionHeight()
494
+ return `${Math.floor(x / CHUNK_SIZE) * CHUNK_SIZE},${Math.floor(y / sectionHeight) * sectionHeight},${Math.floor(z / CHUNK_SIZE) * CHUNK_SIZE}`
495
+ }
496
+
497
+ private noteInteractiveSection(pos: Vec3) {
498
+ const key = this.sectionKeyFor(pos.x, pos.y, pos.z)
499
+ const requiredVersion = this.session?.requiredLightForSection(key)?.requiredVersion ?? 0
500
+ const gateVersion = this.session?.gate.lastVersion ?? 0
501
+ const prev = this.interactiveSections.get(key)
502
+ this.interactiveSections.set(key, {
503
+ lightVersionAtEdit: Math.max(prev?.lightVersionAtEdit ?? 0, requiredVersion, gateVersion),
504
+ editGeneration: this.session?.notePlayerEdit() ?? 0,
505
+ coveringVersion: undefined
506
+ })
507
+ }
508
+
509
+ sectionTouchesInteractiveWork(key: string): boolean {
510
+ if (this.interactiveSections.has(key)) return true
511
+ const [x, y, z] = key.split(',').map(Number)
512
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return false
513
+ const sectionHeight = this.host.getSectionHeight()
514
+ for (let dx = -16; dx <= 16; dx += 16) {
515
+ for (let dy = -sectionHeight; dy <= sectionHeight; dy += sectionHeight) {
516
+ for (let dz = -16; dz <= 16; dz += 16) {
517
+ if (this.interactiveSections.has(`${x + dx},${y + dy},${z + dz}`)) return true
518
+ }
519
+ }
520
+ }
521
+ return false
522
+ }
523
+
524
+ private noteCoveringPublication(version: number, dirtyKeys: Iterable<string>) {
525
+ const dirty = new Set(dirtyKeys)
526
+ for (const [key, edit] of this.interactiveSections) {
527
+ if (!dirty.has(key) || version <= edit.lightVersionAtEdit) continue
528
+ this.interactiveSections.set(key, {
529
+ lightVersionAtEdit: edit.lightVersionAtEdit,
530
+ editGeneration: edit.editGeneration,
531
+ coveringVersion: Math.max(edit.coveringVersion ?? 0, version)
532
+ })
533
+ }
534
+ }
535
+
536
+ private releaseInteractiveEditsWithoutCoveringPublication(settledEditGeneration: number) {
537
+ for (const [key, edit] of this.interactiveSections) {
538
+ if (edit.coveringVersion == null && edit.editGeneration <= settledEditGeneration) this.interactiveSections.delete(key)
539
+ }
540
+ }
541
+
542
+ private releaseInteractiveSectionIfLightCommitted(data: { key?: string; lightPublicationVersion?: number }) {
543
+ if (!data.key || data.lightPublicationVersion == null) return
544
+ const edit = this.interactiveSections.get(data.key)
545
+ if (edit?.coveringVersion == null || data.lightPublicationVersion < edit.coveringVersion) return
546
+ this.interactiveSections.delete(data.key)
547
+ }
548
+
549
+ private bumpTopologyRevision(pos: Vec3) {
550
+ const key = this.sectionKeyFor(pos.x, pos.y, pos.z)
551
+ const next = nextTopologyRevision(this.topologyRevisionBySection.get(key))
552
+ this.topologyRevisionBySection.set(key, next)
553
+ if (this.session != null) {
554
+ this.pendingTopologyBySection.set(key, next)
555
+ }
556
+ }
557
+
558
+ private snapshotNeighborTopology(key: string): Array<{ key: string; topologyRevision: number }> {
559
+ const [x, y, z] = key.split(',').map(Number)
560
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return []
561
+ const sectionHeight = this.host.getSectionHeight()
562
+ const out: Array<{ key: string; topologyRevision: number }> = []
563
+ for (let dx = -16; dx <= 16; dx += 16) {
564
+ for (let dy = -sectionHeight; dy <= sectionHeight; dy += sectionHeight) {
565
+ for (let dz = -16; dz <= 16; dz += 16) {
566
+ const neighborKey = `${x + dx},${y + dy},${z + dz}`
567
+ const topologyRevision = this.topologyRevisionBySection.get(neighborKey)
568
+ if (topologyRevision != null) out.push({ key: neighborKey, topologyRevision })
569
+ }
570
+ }
571
+ }
572
+ return out
573
+ }
574
+ }
@@ -0,0 +1,24 @@
1
+ //@ts-nocheck
2
+ import { describe, it, expect } from 'vitest'
3
+ import { setClientLightProbePositions, isClientLightProbeBlock, isClientLightProbeSection } from './clientLightProbe'
4
+
5
+ describe('client light probe selection', () => {
6
+ it('maps negative and boundary coordinates to section origins', () => {
7
+ setClientLightProbePositions([
8
+ [-1, 63, 16],
9
+ [16, 64, 0]
10
+ ])
11
+ expect(isClientLightProbeBlock(-1, 63, 16)).toBe(true)
12
+ expect(isClientLightProbeSection('-16,48,16')).toBe(true)
13
+ expect(isClientLightProbeSection('16,64,0')).toBe(true)
14
+ expect(isClientLightProbeBlock(0, 63, 16)).toBe(false)
15
+ setClientLightProbePositions([])
16
+ expect(isClientLightProbeSection('-16,48,16')).toBe(false)
17
+ })
18
+ it('rejects invalid coordinates without changing the selection', () => {
19
+ setClientLightProbePositions([[1, 2, 3]])
20
+ expect(() => setClientLightProbePositions([[1.5, 2, 3]])).toThrow()
21
+ expect(isClientLightProbeBlock(1, 2, 3)).toBe(true)
22
+ setClientLightProbePositions([])
23
+ })
24
+ })
@@ -0,0 +1,20 @@
1
+ //@ts-nocheck
2
+ // Temporary opt-in diagnostics. Each worker receives its own copy of the selection.
3
+ let positions = new Set<string>()
4
+ let sections = new Set<string>()
5
+
6
+ export function setClientLightProbePositions(points: Array<[number, number, number]>) {
7
+ if (!Array.isArray(points) || points.some(point => !Array.isArray(point) || point.length !== 3 || point.some(value => !Number.isInteger(value)))) {
8
+ throw new Error('Light probe positions must be integer [x, y, z] triples')
9
+ }
10
+ positions = new Set(points.map(point => point.join(',')))
11
+ sections = new Set(points.map(point => point.map(value => Math.floor(value / 16) * 16).join(',')))
12
+ }
13
+
14
+ export function isClientLightProbeBlock(x: number, y: number, z: number) {
15
+ return positions.has(`${x},${y},${z}`)
16
+ }
17
+
18
+ export function isClientLightProbeSection(key: string) {
19
+ return sections.has(key)
20
+ }