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,241 @@
1
+ //@ts-nocheck
2
+ import { describe, expect, test } from 'vitest'
3
+ import {
4
+ INITIAL_TOPOLOGY_REVISION,
5
+ nextTopologyRevision,
6
+ shouldAcceptVersionedMesh,
7
+ shouldDispatchCoveringRemesh,
8
+ snapshotMeshVersions,
9
+ validateMeshResultVersions,
10
+ validateOwnerPackedSection,
11
+ validateOwnerPublicationMessage
12
+ } from './clientLightVersions'
13
+
14
+ describe('shouldAcceptVersionedMesh', () => {
15
+ const required = { requiredVersion: 3, worldGeneration: 1, topologyRevision: 5 }
16
+
17
+ test('legacy bootstrap still accepts unversioned geometry', () => {
18
+ expect(shouldAcceptVersionedMesh({}, required, { ownerManaged: false })).toBe(true)
19
+ })
20
+
21
+ test('owner-managed path rejects unversioned geometry as current', () => {
22
+ expect(shouldAcceptVersionedMesh({}, required, { ownerManaged: true })).toBe(false)
23
+ expect(shouldAcceptVersionedMesh({ meshMode: 'legacyBootstrap' }, required, { ownerManaged: true })).toBe(false)
24
+ })
25
+
26
+ test('fast A→B→C drops B when C is required', () => {
27
+ expect(
28
+ shouldAcceptVersionedMesh({ worldGeneration: 1, lightPublicationVersion: 2, topologyRevision: 4, sessionEpoch: 1 }, required, {
29
+ ownerManaged: true,
30
+ sessionEpoch: 1
31
+ })
32
+ ).toBe(false)
33
+ expect(
34
+ shouldAcceptVersionedMesh({ worldGeneration: 1, lightPublicationVersion: 3, topologyRevision: 5, sessionEpoch: 1 }, required, {
35
+ ownerManaged: true,
36
+ sessionEpoch: 1
37
+ })
38
+ ).toBe(true)
39
+ })
40
+
41
+ test('late reply from a previous incarnation is dropped', () => {
42
+ expect(
43
+ shouldAcceptVersionedMesh({ worldGeneration: 1, lightPublicationVersion: 3, topologyRevision: 5, sessionEpoch: 1, columnIncarnation: 1 }, required, {
44
+ ownerManaged: true,
45
+ sessionEpoch: 1,
46
+ columnIncarnation: 2
47
+ })
48
+ ).toBe(false)
49
+ })
50
+
51
+ test('hadErrors is never a successful empty commit', () => {
52
+ expect(
53
+ shouldAcceptVersionedMesh({ hadErrors: true, worldGeneration: 1, lightPublicationVersion: 3, topologyRevision: 5 }, required, { ownerManaged: true })
54
+ ).toBe(false)
55
+ expect(
56
+ shouldAcceptVersionedMesh({ hadErrors: false, worldGeneration: 1, lightPublicationVersion: 3, topologyRevision: 5 }, required, {
57
+ ownerManaged: true
58
+ })
59
+ ).toBe(true)
60
+ })
61
+
62
+ test('stale topology with fresh light is rejected', () => {
63
+ expect(
64
+ shouldAcceptVersionedMesh({ worldGeneration: 1, lightPublicationVersion: 3, topologyRevision: 4, sessionEpoch: 1 }, required, {
65
+ ownerManaged: true,
66
+ sessionEpoch: 1
67
+ })
68
+ ).toBe(false)
69
+ })
70
+
71
+ test('fresh topology with stale light is accepted so the block can install this frame', () => {
72
+ expect(
73
+ shouldAcceptVersionedMesh({ worldGeneration: 1, lightPublicationVersion: 2, topologyRevision: 5, sessionEpoch: 1 }, required, {
74
+ ownerManaged: true,
75
+ sessionEpoch: 1
76
+ })
77
+ ).toBe(true)
78
+ })
79
+
80
+ test('required == null accepts versioned geometry', () => {
81
+ expect(
82
+ shouldAcceptVersionedMesh({ worldGeneration: 1, lightPublicationVersion: 1, topologyRevision: 1, sessionEpoch: 1 }, null, {
83
+ ownerManaged: true,
84
+ sessionEpoch: 1
85
+ })
86
+ ).toBe(true)
87
+ })
88
+
89
+ test('legacyBootstrap with a required revision is still rejected', () => {
90
+ expect(shouldAcceptVersionedMesh({ meshMode: 'legacyBootstrap', topologyRevision: 5 }, required, { ownerManaged: true })).toBe(false)
91
+ })
92
+
93
+ test('legacyBootstrap without required is accepted', () => {
94
+ expect(shouldAcceptVersionedMesh({ meshMode: 'legacyBootstrap' }, null, { ownerManaged: true })).toBe(true)
95
+ })
96
+
97
+ test('legacyBootstrap still has to match the column life', () => {
98
+ expect(
99
+ shouldAcceptVersionedMesh({ meshMode: 'legacyBootstrap', sessionEpoch: 1, columnIncarnation: 1 }, null, {
100
+ ownerManaged: true,
101
+ sessionEpoch: 2,
102
+ columnIncarnation: 2
103
+ })
104
+ ).toBe(false)
105
+ })
106
+
107
+ test('legacyBootstrap with a proven neighbor light version covers the requirement', () => {
108
+ expect(
109
+ shouldAcceptVersionedMesh(
110
+ { meshMode: 'legacyBootstrap', worldGeneration: 1, lightPublicationVersion: 10, sessionEpoch: 1, columnIncarnation: 1 },
111
+ { requiredVersion: 10, worldGeneration: 1 },
112
+ { ownerManaged: true, sessionEpoch: 1, columnIncarnation: 1 }
113
+ )
114
+ ).toBe(true)
115
+ expect(
116
+ shouldAcceptVersionedMesh(
117
+ { meshMode: 'legacyBootstrap', worldGeneration: 1, sessionEpoch: 1, columnIncarnation: 1 },
118
+ { requiredVersion: 10, worldGeneration: 1 },
119
+ { ownerManaged: true, sessionEpoch: 1, columnIncarnation: 1 }
120
+ )
121
+ ).toBe(false)
122
+ })
123
+
124
+ test('owner mesh with an older own-column light version stays rejected', () => {
125
+ expect(
126
+ shouldAcceptVersionedMesh(
127
+ { meshMode: 'owner', worldGeneration: 1, lightPublicationVersion: 9, sessionEpoch: 1, columnIncarnation: 1 },
128
+ { requiredVersion: 10, worldGeneration: 1 },
129
+ { ownerManaged: true, sessionEpoch: 1, columnIncarnation: 1 }
130
+ )
131
+ ).toBe(false)
132
+ })
133
+
134
+ test('without topology on the requirement, stale light still rejects (old gate)', () => {
135
+ expect(
136
+ shouldAcceptVersionedMesh(
137
+ { worldGeneration: 1, lightPublicationVersion: 2, topologyRevision: 5, sessionEpoch: 1 },
138
+ { requiredVersion: 3, worldGeneration: 1 },
139
+ { ownerManaged: true, sessionEpoch: 1 }
140
+ )
141
+ ).toBe(false)
142
+ })
143
+ })
144
+
145
+ describe('shouldDispatchCoveringRemesh', () => {
146
+ const required = { requiredVersion: 4, worldGeneration: 1 }
147
+
148
+ test('dispatches when nothing is outstanding', () => {
149
+ expect(shouldDispatchCoveringRemesh({ required, outstandingCount: 0 })).toBe(true)
150
+ })
151
+
152
+ test('does not add another remesh when pending already covers the requirement', () => {
153
+ expect(
154
+ shouldDispatchCoveringRemesh({
155
+ required,
156
+ outstandingCount: 1,
157
+ pendingCoveringVersion: 4,
158
+ pendingCoveringGeneration: 1
159
+ })
160
+ ).toBe(false)
161
+ })
162
+
163
+ test('dispatches when the outstanding remesh is behind the new requirement', () => {
164
+ expect(
165
+ shouldDispatchCoveringRemesh({
166
+ required,
167
+ outstandingCount: 2,
168
+ pendingCoveringVersion: 3,
169
+ pendingCoveringGeneration: 1
170
+ })
171
+ ).toBe(true)
172
+ })
173
+ })
174
+
175
+ describe('snapshotMeshVersions', () => {
176
+ test('freezes neighbor revisions with the task snapshot, not a later live map', () => {
177
+ const neighbors = [{ key: '16,64,0', topologyRevision: 2 }]
178
+ const snap = snapshotMeshVersions({
179
+ sessionEpoch: 1,
180
+ columnIncarnation: 3,
181
+ requestId: 9,
182
+ topologyRevision: 4,
183
+ lightPublicationVersion: 7,
184
+ worldGeneration: 1,
185
+ neighborTopologyRevisions: neighbors
186
+ })
187
+ neighbors[0]!.topologyRevision = 99
188
+ expect(snap.neighborTopologyRevisions[0]!.topologyRevision).toBe(2)
189
+ expect(snap.meshMode).toBe('owner')
190
+ })
191
+ })
192
+
193
+ describe('nextTopologyRevision', () => {
194
+ test('increments from zero', () => {
195
+ expect(nextTopologyRevision(undefined)).toBe(1)
196
+ expect(nextTopologyRevision(1)).toBe(2)
197
+ })
198
+
199
+ test('the initial revision is not the first edit', () => {
200
+ expect(INITIAL_TOPOLOGY_REVISION).toBe(0)
201
+ expect(nextTopologyRevision(undefined)).not.toBe(INITIAL_TOPOLOGY_REVISION)
202
+ expect(nextTopologyRevision(INITIAL_TOPOLOGY_REVISION)).toBe(1)
203
+ })
204
+ })
205
+
206
+ describe('payload validation', () => {
207
+ test('rejects NaN and non-integer revision ids', () => {
208
+ expect(validateMeshResultVersions({ sessionEpoch: 1.5 })).toEqual({ ok: false, reason: 'sessionEpoch' })
209
+ expect(validateMeshResultVersions({ requestId: -1 })).toEqual({ ok: false, reason: 'requestId' })
210
+ expect(validateMeshResultVersions({ key: '0,64,0', requestId: 3 })).toEqual({ ok: true })
211
+ })
212
+
213
+ test('rejects packed light that is not 2048 bytes per channel', () => {
214
+ expect(
215
+ validateOwnerPackedSection({
216
+ sx: 0,
217
+ sy: 64,
218
+ sz: 0,
219
+ blockLight: new Uint8Array(16)
220
+ })
221
+ ).toEqual({ ok: false, reason: 'blockLight' })
222
+ expect(
223
+ validateOwnerPackedSection({
224
+ sx: -16,
225
+ sy: 64,
226
+ sz: 32,
227
+ blockLight: new Uint8Array(2048)
228
+ })
229
+ ).toEqual({ ok: true })
230
+ })
231
+
232
+ test('rejects a foreign publication with bad section payload', () => {
233
+ expect(
234
+ validateOwnerPublicationMessage({
235
+ worldGeneration: 1,
236
+ publicationVersion: 2,
237
+ sections: [{ sx: 0, sy: 64, sz: 0, blockLight: new Uint8Array(8) }]
238
+ })
239
+ ).toEqual({ ok: false, reason: 'blockLight' })
240
+ })
241
+ })
@@ -0,0 +1,203 @@
1
+ //@ts-nocheck
2
+ /**
3
+ * P-02: explicit mesh versions, covering coalesce, and payload validation.
4
+ * Metadata only — never copies raw meshes or light arrays into logs.
5
+ */
6
+
7
+ export const OWNER_STATES_PER_SECTION = 4096
8
+ export const OWNER_PACKED_LIGHT_BYTES = 2048
9
+
10
+ export type MeshGeometryMode = 'owner' | 'legacyBootstrap'
11
+
12
+ export type MeshResultVersions = {
13
+ sessionEpoch: number
14
+ columnIncarnation: number
15
+ requestId: number
16
+ topologyRevision: number
17
+ lightPublicationVersion: number
18
+ worldGeneration: number
19
+ neighborTopologyRevisions: Array<{ key: string; topologyRevision: number }>
20
+ meshMode: MeshGeometryMode
21
+ }
22
+
23
+ export type MeshSectionLightRequirement = {
24
+ requiredVersion: number
25
+ worldGeneration: number
26
+ topologyRevision?: number
27
+ }
28
+
29
+ export function isUnversionedMesh(mesh: {
30
+ worldGeneration?: number
31
+ lightPublicationVersion?: number
32
+ topologyRevision?: number
33
+ sessionEpoch?: number
34
+ }): boolean {
35
+ return mesh.worldGeneration == null && mesh.lightPublicationVersion == null && mesh.topologyRevision == null && mesh.sessionEpoch == null
36
+ }
37
+
38
+ export function shouldAcceptVersionedMesh(
39
+ mesh: {
40
+ worldGeneration?: number
41
+ lightPublicationVersion?: number
42
+ topologyRevision?: number
43
+ sessionEpoch?: number
44
+ columnIncarnation?: number
45
+ hadErrors?: boolean
46
+ meshMode?: MeshGeometryMode
47
+ },
48
+ required: MeshSectionLightRequirement | null | undefined,
49
+ opts: { ownerManaged: boolean; sessionEpoch?: number; columnIncarnation?: number }
50
+ ): boolean {
51
+ if (mesh.hadErrors) return false
52
+ if (opts.ownerManaged) {
53
+ if (opts.sessionEpoch != null && mesh.sessionEpoch != null && mesh.sessionEpoch !== opts.sessionEpoch) return false
54
+ if (opts.columnIncarnation != null && mesh.columnIncarnation != null && mesh.columnIncarnation !== opts.columnIncarnation) {
55
+ return false
56
+ }
57
+ if (mesh.meshMode === 'legacyBootstrap') {
58
+ if (required == null) return true
59
+ if (mesh.lightPublicationVersion == null) return false
60
+ } else if (isUnversionedMesh(mesh)) {
61
+ return false
62
+ }
63
+ } else if (isUnversionedMesh(mesh)) {
64
+ return true
65
+ }
66
+ if (required == null) return true
67
+ if (mesh.worldGeneration != null && mesh.worldGeneration !== required.worldGeneration) return false
68
+ if (required.topologyRevision != null && mesh.topologyRevision != null && mesh.topologyRevision < required.topologyRevision) {
69
+ return false
70
+ }
71
+ const topologyFresh = required.topologyRevision != null && mesh.topologyRevision != null && mesh.topologyRevision >= required.topologyRevision
72
+ if (!topologyFresh && mesh.lightPublicationVersion != null && mesh.lightPublicationVersion < required.requiredVersion) {
73
+ return false
74
+ }
75
+ return true
76
+ }
77
+
78
+ export function isOwnerGeometryLightStale(mesh: { lightPublicationVersion?: number }, required: MeshSectionLightRequirement | null | undefined): boolean {
79
+ return required != null && mesh.lightPublicationVersion != null && mesh.lightPublicationVersion < required.requiredVersion
80
+ }
81
+
82
+ export function shouldDispatchCoveringRemesh(opts: {
83
+ required: MeshSectionLightRequirement
84
+ outstandingCount: number
85
+ pendingCoveringVersion?: number
86
+ pendingCoveringGeneration?: number
87
+ }): boolean {
88
+ if (opts.outstandingCount <= 0) return true
89
+ if (opts.pendingCoveringGeneration !== opts.required.worldGeneration) return true
90
+ if ((opts.pendingCoveringVersion ?? -1) < opts.required.requiredVersion) return true
91
+ return false
92
+ }
93
+
94
+ /** Stamped on a section that has not been edited. The first edit is always greater. */
95
+ export const INITIAL_TOPOLOGY_REVISION = 0
96
+
97
+ export function nextTopologyRevision(current: number | undefined): number {
98
+ return (current ?? INITIAL_TOPOLOGY_REVISION) + 1
99
+ }
100
+
101
+ export function snapshotMeshVersions(input: {
102
+ sessionEpoch: number
103
+ columnIncarnation: number
104
+ requestId: number
105
+ topologyRevision: number
106
+ lightPublicationVersion: number
107
+ worldGeneration: number
108
+ neighborTopologyRevisions: Array<{ key: string; topologyRevision: number }>
109
+ meshMode?: MeshGeometryMode
110
+ }): MeshResultVersions {
111
+ return {
112
+ sessionEpoch: input.sessionEpoch,
113
+ columnIncarnation: input.columnIncarnation,
114
+ requestId: input.requestId,
115
+ topologyRevision: input.topologyRevision,
116
+ lightPublicationVersion: input.lightPublicationVersion,
117
+ worldGeneration: input.worldGeneration,
118
+ neighborTopologyRevisions: input.neighborTopologyRevisions.map(entry => ({
119
+ key: entry.key,
120
+ topologyRevision: entry.topologyRevision
121
+ })),
122
+ meshMode: input.meshMode ?? 'owner'
123
+ }
124
+ }
125
+
126
+ export function isSafeIntegerId(value: unknown): value is number {
127
+ return typeof value === 'number' && Number.isInteger(value) && Number.isSafeInteger(value) && value >= 0
128
+ }
129
+
130
+ export function isSafeCoord(value: unknown): value is number {
131
+ return typeof value === 'number' && Number.isInteger(value) && Number.isSafeInteger(value)
132
+ }
133
+
134
+ export function validateSectionKey(key: unknown): boolean {
135
+ if (typeof key !== 'string') return false
136
+ const parts = key.split(',')
137
+ if (parts.length !== 3) return false
138
+ return parts.every(part => {
139
+ const n = Number(part)
140
+ return Number.isInteger(n) && Number.isFinite(n)
141
+ })
142
+ }
143
+
144
+ export function validateOwnerPackedSection(section: {
145
+ sx?: unknown
146
+ sy?: unknown
147
+ sz?: unknown
148
+ blockLight?: unknown
149
+ skyLight?: unknown
150
+ states?: unknown
151
+ }): { ok: true } | { ok: false; reason: string } {
152
+ if (!isSafeCoord(section.sx)) return { ok: false, reason: 'sx' }
153
+ if (!isSafeCoord(section.sy)) return { ok: false, reason: 'sy' }
154
+ if (!isSafeCoord(section.sz)) return { ok: false, reason: 'sz' }
155
+ if (section.blockLight != null) {
156
+ if (!(section.blockLight instanceof Uint8Array) || section.blockLight.length !== OWNER_PACKED_LIGHT_BYTES) {
157
+ return { ok: false, reason: 'blockLight' }
158
+ }
159
+ }
160
+ if (section.skyLight != null) {
161
+ if (!(section.skyLight instanceof Uint8Array) || section.skyLight.length !== OWNER_PACKED_LIGHT_BYTES) {
162
+ return { ok: false, reason: 'skyLight' }
163
+ }
164
+ }
165
+ if (section.states != null) {
166
+ if (!(section.states instanceof Uint16Array) || section.states.length !== OWNER_STATES_PER_SECTION) {
167
+ return { ok: false, reason: 'states' }
168
+ }
169
+ }
170
+ return { ok: true }
171
+ }
172
+
173
+ export function validateMeshResultVersions(data: {
174
+ sessionEpoch?: unknown
175
+ columnIncarnation?: unknown
176
+ requestId?: unknown
177
+ topologyRevision?: unknown
178
+ lightPublicationVersion?: unknown
179
+ worldGeneration?: unknown
180
+ key?: unknown
181
+ }): { ok: true } | { ok: false; reason: string } {
182
+ if (data.key != null && !validateSectionKey(data.key)) return { ok: false, reason: 'key' }
183
+ for (const field of ['sessionEpoch', 'columnIncarnation', 'requestId', 'topologyRevision', 'lightPublicationVersion', 'worldGeneration'] as const) {
184
+ if (data[field] == null) continue
185
+ if (!isSafeIntegerId(data[field])) return { ok: false, reason: field }
186
+ }
187
+ return { ok: true }
188
+ }
189
+
190
+ export function validateOwnerPublicationMessage(data: {
191
+ worldGeneration?: unknown
192
+ publicationVersion?: unknown
193
+ sections?: unknown
194
+ }): { ok: true } | { ok: false; reason: string } {
195
+ if (!isSafeIntegerId(data.worldGeneration)) return { ok: false, reason: 'worldGeneration' }
196
+ if (!isSafeIntegerId(data.publicationVersion)) return { ok: false, reason: 'publicationVersion' }
197
+ if (!Array.isArray(data.sections)) return { ok: false, reason: 'sections' }
198
+ for (const section of data.sections) {
199
+ const checked = validateOwnerPackedSection(section ?? {})
200
+ if (!checked.ok) return checked
201
+ }
202
+ return { ok: true }
203
+ }
@@ -0,0 +1,214 @@
1
+ //@ts-nocheck
2
+ import { describe, expect, it } from 'vitest'
3
+ import { faceNeighborKeys, pendingSectionGroups, selectReadySectionFlushes, selectReadySectionUpdates } from './pendingSectionFlush'
4
+
5
+ const SECTION_HEIGHT = 16
6
+ const MAX_BUFFER_MS = 500
7
+
8
+ type Scene = {
9
+ pending: Map<string, number>
10
+ outstanding: Set<string>
11
+ visible: Set<string>
12
+ }
13
+
14
+ function select(scene: Scene, now: number) {
15
+ return selectReadySectionUpdates({
16
+ pendingKeys: scene.pending.keys(),
17
+ startedAt: key => scene.pending.get(key),
18
+ now,
19
+ maxBufferMs: MAX_BUFFER_MS,
20
+ sectionHeight: SECTION_HEIGHT,
21
+ isOutstanding: key => scene.outstanding.has(key),
22
+ hasSectionObject: key => scene.visible.has(key)
23
+ })
24
+ }
25
+
26
+ /**
27
+ * The per-key policy this module replaced (worldRendererThree before the group
28
+ * flush): each key waited out its OWN deadline and was released on its own.
29
+ * Kept here so the regression it caused stays pinned by a test.
30
+ */
31
+ function selectPerKeyLegacy(scene: Scene, now: number) {
32
+ const ready: string[] = []
33
+ for (const key of scene.pending.keys()) {
34
+ const startedAt = scene.pending.get(key) ?? now
35
+ if (now - startedAt < MAX_BUFFER_MS) {
36
+ const busy = faceNeighborKeys(key, SECTION_HEIGHT).some(
37
+ neighbor => scene.outstanding.has(neighbor) && !scene.pending.has(neighbor) && scene.visible.has(neighbor)
38
+ )
39
+ if (busy) continue
40
+ }
41
+ ready.push(key)
42
+ }
43
+ return ready
44
+ }
45
+
46
+ describe('pendingSectionGroups', () => {
47
+ it('splits buffered sections into face-connected components', () => {
48
+ const groups = pendingSectionGroups(['0,64,0', '0,80,0', '160,64,160'], SECTION_HEIGHT)
49
+ expect(groups).toHaveLength(2)
50
+ expect(groups.find(g => g.length === 2)?.sort()).toEqual(['0,64,0', '0,80,0'])
51
+ expect(groups.find(g => g.length === 1)).toEqual(['160,64,160'])
52
+ })
53
+ })
54
+
55
+ describe('selectReadySectionUpdates', () => {
56
+ it('holds a section while a visible face neighbour is still being re-meshed', () => {
57
+ const scene: Scene = {
58
+ pending: new Map([['0,64,0', 1000]]),
59
+ outstanding: new Set(['16,64,0']),
60
+ visible: new Set(['16,64,0'])
61
+ }
62
+ expect(select(scene, 1050)).toEqual([])
63
+ })
64
+
65
+ it('flushes as soon as every neighbour of the group has arrived, without waiting the deadline', () => {
66
+ const scene: Scene = {
67
+ pending: new Map([
68
+ ['0,64,0', 1000],
69
+ ['16,64,0', 1040]
70
+ ]),
71
+ outstanding: new Set(),
72
+ visible: new Set(['0,64,0', '16,64,0'])
73
+ }
74
+ expect(select(scene, 1050).sort()).toEqual(['0,64,0', '16,64,0'])
75
+ })
76
+
77
+ it('does not wait on an air neighbour that has no geometry on screen', () => {
78
+ const scene: Scene = {
79
+ pending: new Map([['0,64,0', 1000]]),
80
+ outstanding: new Set(['0,80,0']),
81
+ visible: new Set(['0,64,0'])
82
+ }
83
+ expect(select(scene, 1010)).toEqual(['0,64,0'])
84
+ })
85
+
86
+ // The sky-flash regression: the dug section and its face neighbours are
87
+ // buffered at staggered times (they come back on different worker ticks).
88
+ // Releasing the earliest one alone shows the neighbour's still-culled faces
89
+ // as a hole, so the whole group must go out in the same frame.
90
+ it('releases a staggered group in one batch when the deadline expires', () => {
91
+ const scene: Scene = {
92
+ pending: new Map([
93
+ ['0,64,0', 1000],
94
+ ['16,64,0', 1120],
95
+ ['0,80,0', 1180]
96
+ ]),
97
+ outstanding: new Set(['32,64,0']),
98
+ visible: new Set(['0,64,0', '16,64,0', '0,80,0', '32,64,0'])
99
+ }
100
+ expect(select(scene, 1500).sort()).toEqual(['0,64,0', '0,80,0', '16,64,0'])
101
+ // The legacy policy installs 0,64,0 while 16,64,0 keeps the geometry it was
102
+ // meshed with before the edit — the faces between them stay culled on both
103
+ // sides for a frame, which is the hole seen as a sky flash.
104
+ expect(selectPerKeyLegacy(scene, 1500)).not.toContain('16,64,0')
105
+ expect(selectPerKeyLegacy(scene, 1500)).toContain('0,64,0')
106
+ })
107
+
108
+ it('measures the deadline from the oldest member so a group cannot be starved', () => {
109
+ const scene: Scene = {
110
+ pending: new Map([
111
+ ['0,64,0', 1000],
112
+ ['16,64,0', 1400]
113
+ ]),
114
+ outstanding: new Set(['32,64,0']),
115
+ visible: new Set(['0,64,0', '16,64,0', '32,64,0'])
116
+ }
117
+ expect(select(scene, 1499)).toEqual([])
118
+ expect(select(scene, 1500).sort()).toEqual(['0,64,0', '16,64,0'])
119
+ })
120
+
121
+ it('keeps unrelated groups independent', () => {
122
+ const scene: Scene = {
123
+ pending: new Map([
124
+ ['0,64,0', 1000],
125
+ ['160,64,160', 1000]
126
+ ]),
127
+ outstanding: new Set(['16,64,0']),
128
+ visible: new Set(['0,64,0', '16,64,0', '160,64,160'])
129
+ }
130
+ expect(select(scene, 1010)).toEqual(['160,64,160'])
131
+ })
132
+
133
+ it('labels complete groups vs deadline flushes', () => {
134
+ const complete: Scene = {
135
+ pending: new Map([['160,64,160', 1000]]),
136
+ outstanding: new Set(),
137
+ visible: new Set(['160,64,160'])
138
+ }
139
+ expect(
140
+ selectReadySectionFlushes({
141
+ pendingKeys: complete.pending.keys(),
142
+ startedAt: key => complete.pending.get(key),
143
+ now: 1010,
144
+ maxBufferMs: MAX_BUFFER_MS,
145
+ sectionHeight: SECTION_HEIGHT,
146
+ isOutstanding: key => complete.outstanding.has(key),
147
+ hasSectionObject: key => complete.visible.has(key)
148
+ })
149
+ ).toEqual([{ key: '160,64,160', reason: 'group-complete' }])
150
+
151
+ const expired: Scene = {
152
+ pending: new Map([
153
+ ['0,64,0', 1000],
154
+ ['16,64,0', 1120]
155
+ ]),
156
+ outstanding: new Set(['32,64,0']),
157
+ visible: new Set(['0,64,0', '16,64,0', '32,64,0'])
158
+ }
159
+ expect(
160
+ selectReadySectionFlushes({
161
+ pendingKeys: expired.pending.keys(),
162
+ startedAt: key => expired.pending.get(key),
163
+ now: 1500,
164
+ maxBufferMs: MAX_BUFFER_MS,
165
+ sectionHeight: SECTION_HEIGHT,
166
+ isOutstanding: key => expired.outstanding.has(key),
167
+ hasSectionObject: key => expired.visible.has(key)
168
+ }).map(item => item.reason)
169
+ ).toEqual(['deadline', 'deadline'])
170
+ })
171
+ })
172
+
173
+ describe('selectReadySectionFlushes topology vs covering', () => {
174
+ const flushQuery = (scene: Scene, now: number, topologyOutstanding?: Set<string>) =>
175
+ selectReadySectionFlushes({
176
+ pendingKeys: scene.pending.keys(),
177
+ startedAt: key => scene.pending.get(key),
178
+ now,
179
+ maxBufferMs: MAX_BUFFER_MS,
180
+ sectionHeight: SECTION_HEIGHT,
181
+ isOutstanding: key => scene.outstanding.has(key),
182
+ isTopologyOutstanding: topologyOutstanding ? key => topologyOutstanding.has(key) : undefined,
183
+ hasSectionObject: key => scene.visible.has(key)
184
+ })
185
+
186
+ it('holds a group while a visible neighbour is topology-outstanding', () => {
187
+ const scene: Scene = {
188
+ pending: new Map([['0,64,0', 1000]]),
189
+ outstanding: new Set(),
190
+ visible: new Set(['0,64,0', '16,64,0'])
191
+ }
192
+ expect(flushQuery(scene, 1050, new Set(['16,64,0'])).map(item => item.key)).toEqual([])
193
+ })
194
+
195
+ it('does not hold a group for covering-only outstanding neighbours', () => {
196
+ const scene: Scene = {
197
+ pending: new Map([['0,64,0', 1000]]),
198
+ outstanding: new Set(['16,64,0']),
199
+ visible: new Set(['0,64,0', '16,64,0'])
200
+ }
201
+ expect(flushQuery(scene, 1050, new Set()).map(item => item.key)).toEqual(['0,64,0'])
202
+ expect(flushQuery(scene, 1050, new Set())[0]?.reason).toBe('group-complete')
203
+ })
204
+
205
+ it('holds a singleton with a topology-outstanding neighbour until the deadline', () => {
206
+ const scene: Scene = {
207
+ pending: new Map([['0,64,0', 1000]]),
208
+ outstanding: new Set(['16,64,0']),
209
+ visible: new Set(['0,64,0', '16,64,0'])
210
+ }
211
+ expect(flushQuery(scene, 1499, new Set(['16,64,0']))).toEqual([])
212
+ expect(flushQuery(scene, 1500, new Set(['16,64,0']))).toEqual([{ key: '0,64,0', reason: 'deadline' }])
213
+ })
214
+ })