minecraft-renderer 0.1.107 → 0.1.109

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 (95) 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 +84 -84
  7. package/dist/minecraft-renderer.js.meta.json +1 -1
  8. package/dist/threeWorker.js +517 -517
  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/cloudCover.ts +22 -0
  51. package/src/three/cloudGeometry.ts +71 -0
  52. package/src/three/entities.ts +77 -11
  53. package/src/three/entity/EntityMesh.ts +1 -0
  54. package/src/three/entity/legWalkAnimation.test.ts +11 -2
  55. package/src/three/entity/legWalkAnimation.ts +1 -9
  56. package/src/three/globalBlockBuffer.ts +94 -2
  57. package/src/three/globalLegacyBuffer.ts +94 -2
  58. package/src/three/graphicsBackendBase.ts +2 -9
  59. package/src/three/modules/clouds.ts +98 -75
  60. package/src/three/tests/cloudGeometry.test.ts +87 -0
  61. package/src/three/tests/clouds.test.ts +65 -14
  62. package/src/three/tests/globalBlockBuffer.test.ts +98 -0
  63. package/src/three/tests/globalLegacyBuffer.test.ts +108 -0
  64. package/src/three/worldRendererThree.ts +75 -34
  65. package/src/wasm-mesher/bridge/render-from-wasm.ts +46 -1
  66. package/src/wasm-mesher/runtime-build/wasm_mesher.d.ts +31 -5
  67. package/src/wasm-mesher/runtime-build/wasm_mesher.js +180 -5
  68. package/src/wasm-mesher/runtime-build/wasm_mesher_bg.wasm +0 -0
  69. package/src/wasm-mesher/runtime-build/wasm_mesher_bg.wasm.d.ts +11 -0
  70. package/src/wasm-mesher/tests/mesherLightDependencies.test.ts +83 -0
  71. package/src/wasm-mesher/tests/mesherWasmColumnTickSelect.test.ts +39 -0
  72. package/src/wasm-mesher/tests/mesherWasmConversionCache.test.ts +30 -1
  73. package/src/wasm-mesher/tests/mesherWasmTickParseCache.test.ts +73 -0
  74. package/src/wasm-mesher/tests/mesherWasmTickSchedule.test.ts +51 -0
  75. package/src/wasm-mesher/tests/mesherWasmTopologyPostCache.test.ts +78 -0
  76. package/src/wasm-mesher/worker/clientLightDarkPublication.probe.test.ts +32 -0
  77. package/src/wasm-mesher/worker/mesherLightDependencies.ts +43 -0
  78. package/src/wasm-mesher/worker/mesherWasm.ownerLightSync.test.ts +198 -0
  79. package/src/wasm-mesher/worker/mesherWasm.ts +506 -96
  80. package/src/wasm-mesher/worker/mesherWasmChunkBuffer.ts +1 -0
  81. package/src/wasm-mesher/worker/mesherWasmColumnTickSelect.ts +47 -0
  82. package/src/wasm-mesher/worker/mesherWasmConversionCache.ts +16 -5
  83. package/src/wasm-mesher/worker/mesherWasmLightDirty.test.ts +17 -1
  84. package/src/wasm-mesher/worker/mesherWasmLightDirty.ts +10 -0
  85. package/src/wasm-mesher/worker/mesherWasmLightMerge.test.ts +208 -0
  86. package/src/wasm-mesher/worker/mesherWasmLightMerge.ts +228 -0
  87. package/src/wasm-mesher/worker/mesherWasmOwnerLight.test.ts +161 -0
  88. package/src/wasm-mesher/worker/mesherWasmOwnerLight.ts +112 -0
  89. package/src/wasm-mesher/worker/mesherWasmTickParseCache.ts +45 -0
  90. package/src/wasm-mesher/worker/mesherWasmTickSchedule.ts +44 -0
  91. package/src/wasm-mesher/worker/mesherWasmTopologyPostCache.ts +63 -0
  92. package/src/worldView/types.ts +1 -0
  93. package/src/worldView/worldView.geometryReload.test.ts +68 -0
  94. package/src/worldView/worldView.spiral.test.ts +1 -1
  95. package/src/worldView/worldView.ts +46 -1
@@ -0,0 +1,161 @@
1
+ //@ts-nocheck
2
+ import { describe, expect, it } from 'vitest'
3
+ import { createEmptyLightCache, isLightSectionPresent, worldSectionMaskBit, type ParsedUpdateLight } from './mesherWasmLightMerge'
4
+ import {
5
+ applyOwnerPublicationToColumnCaches,
6
+ applyPackedOwnerSectionsToLightCache,
7
+ applyRawLightPacketToCaches,
8
+ detachDisplayOnOwnerTakeover,
9
+ ownerDeltaSectionWorldYs,
10
+ revertDisplayToIncoming
11
+ } from './mesherWasmOwnerLight'
12
+ import { packUnpackedLightSection } from '../../mesher-shared/lightNibblePack'
13
+
14
+ describe('applyPackedOwnerSectionsToLightCache', () => {
15
+ it('unpacks owner packed channels into the worker column cache without touching other sections', () => {
16
+ const cache = createEmptyLightCache(16)
17
+ const unpacked = new Uint8Array(4096).fill(0)
18
+ unpacked[0] = 9
19
+ const packed = packUnpackedLightSection(unpacked)
20
+ const next = applyPackedOwnerSectionsToLightCache(cache, [{ sx: 0, sy: 64, sz: 0, blockLight: packed, skyLight: packed }], 0, 0, 0, 16)
21
+ expect(next.blockLight[4 * 4096]).toBe(9)
22
+ expect(next.skyLight[4 * 4096]).toBe(9)
23
+ expect(isLightSectionPresent(next.blockPresent, worldSectionMaskBit(4))).toBe(true)
24
+ expect(next.blockLight[0]).toBe(0)
25
+ expect(isLightSectionPresent(next.blockPresent, worldSectionMaskBit(0))).toBe(false)
26
+ })
27
+
28
+ it('writes a section delta in place without cloning the full column arrays', () => {
29
+ const cache = createEmptyLightCache(16)
30
+ cache.blockLight[0] = 4
31
+ cache.skyLight[0] = 7
32
+ const blockRef = cache.blockLight
33
+ const skyRef = cache.skyLight
34
+ const unpacked = new Uint8Array(4096).fill(0)
35
+ unpacked[0] = 9
36
+ const packed = packUnpackedLightSection(unpacked)
37
+ const next = applyPackedOwnerSectionsToLightCache(cache, [{ sx: 0, sy: 64, sz: 0, blockLight: packed, skyLight: packed }], 0, 0, 0, 16)
38
+ expect(next.blockLight).toBe(blockRef)
39
+ expect(next.skyLight).toBe(skyRef)
40
+ expect(next.blockLight[0]).toBe(4)
41
+ expect(next.skyLight[0]).toBe(7)
42
+ expect(next.blockLight[4 * 4096]).toBe(9)
43
+ })
44
+
45
+ it('lists only the published section Y origins for a column', () => {
46
+ expect(
47
+ ownerDeltaSectionWorldYs(
48
+ [
49
+ { sx: 0, sy: 64, sz: 0 },
50
+ { sx: 16, sy: 64, sz: 0 },
51
+ { sx: 0, sy: 80, sz: 0 }
52
+ ],
53
+ 0,
54
+ 0
55
+ )
56
+ ).toEqual([64, 80])
57
+ })
58
+ })
59
+
60
+ describe('applyRawLightPacketToCaches', () => {
61
+ function parsedBlock(sectionIndex: number, value: number): ParsedUpdateLight {
62
+ const blockLight = new Uint8Array(16 * 4096)
63
+ blockLight.fill(value, sectionIndex * 4096, (sectionIndex + 1) * 4096)
64
+ const blockLightMask = new Uint32Array(2)
65
+ blockLightMask[0] = 1 << (sectionIndex + 1)
66
+ return {
67
+ x: 0,
68
+ z: 0,
69
+ trustEdges: true,
70
+ numSections: 16,
71
+ skyLight: new Uint8Array(16 * 4096),
72
+ blockLight,
73
+ skyLightMask: new Uint32Array(2),
74
+ emptySkyLightMask: new Uint32Array(2),
75
+ blockLightMask,
76
+ emptyBlockLightMask: new Uint32Array(2)
77
+ }
78
+ }
79
+
80
+ it('writes incoming server light into the display cache when the owner does not own the column', () => {
81
+ const result = applyRawLightPacketToCaches({
82
+ ownerOwnsColumn: false,
83
+ incoming: undefined,
84
+ display: undefined,
85
+ parsed: parsedBlock(4, 9)
86
+ })
87
+ expect(result.dirtyDisplay).toBe(true)
88
+ expect(result.display?.blockLight[4 * 4096]).toBe(9)
89
+ expect(result.incoming.blockLight[4 * 4096]).toBe(9)
90
+ })
91
+
92
+ it('keeps incoming server light separate and does not overwrite owner display cache', () => {
93
+ const display = createEmptyLightCache(16)
94
+ display.blockLight[4 * 4096] = 3
95
+ const result = applyRawLightPacketToCaches({
96
+ ownerOwnsColumn: true,
97
+ incoming: undefined,
98
+ display,
99
+ parsed: parsedBlock(4, 9)
100
+ })
101
+ expect(result.dirtyDisplay).toBe(false)
102
+ expect(result.display?.blockLight[4 * 4096]).toBe(3)
103
+ expect(result.incoming.blockLight[4 * 4096]).toBe(9)
104
+ })
105
+ })
106
+
107
+ describe('owner takeover incoming/display split', () => {
108
+ it('clones display once so an in-place owner write does not change incoming server light', () => {
109
+ const incoming = createEmptyLightCache(16)
110
+ incoming.blockLight[4 * 4096] = 9
111
+ const aliased = detachDisplayOnOwnerTakeover({ incoming, display: incoming })
112
+ expect(aliased.display).not.toBe(aliased.incoming)
113
+
114
+ const unpacked = new Uint8Array(4096).fill(0)
115
+ unpacked[0] = 3
116
+ const packed = packUnpackedLightSection(unpacked)
117
+ const applied = applyOwnerPublicationToColumnCaches({
118
+ incoming: aliased.incoming,
119
+ display: aliased.display,
120
+ sections: [{ sx: 0, sy: 64, sz: 0, blockLight: packed }],
121
+ worldMinY: 0,
122
+ columnWorldX: 0,
123
+ columnWorldZ: 0,
124
+ numSections: 16
125
+ })
126
+ expect(applied.incoming?.blockLight[4 * 4096]).toBe(9)
127
+ expect(applied.display.blockLight[4 * 4096]).toBe(3)
128
+ expect(applied.display.blockLight).not.toBe(applied.incoming?.blockLight)
129
+ })
130
+
131
+ it('does not clone the full column on a second owner delta', () => {
132
+ const incoming = createEmptyLightCache(16)
133
+ incoming.blockLight[4 * 4096] = 9
134
+ const first = detachDisplayOnOwnerTakeover({ incoming, display: incoming })
135
+ const displayRef = first.display!.blockLight
136
+ const unpacked = new Uint8Array(4096).fill(0)
137
+ unpacked[0] = 3
138
+ const packed = packUnpackedLightSection(unpacked)
139
+ const second = applyOwnerPublicationToColumnCaches({
140
+ incoming: first.incoming,
141
+ display: first.display,
142
+ sections: [{ sx: 0, sy: 64, sz: 0, blockLight: packed }],
143
+ worldMinY: 0,
144
+ columnWorldX: 0,
145
+ columnWorldZ: 0,
146
+ numSections: 16
147
+ })
148
+ expect(second.display.blockLight).toBe(displayRef)
149
+ })
150
+
151
+ it('reverts display to last server incoming without zeroing omitted sections', () => {
152
+ const incoming = createEmptyLightCache(16)
153
+ incoming.blockLight[4 * 4096] = 9
154
+ const display = createEmptyLightCache(16)
155
+ display.blockLight[4 * 4096] = 3
156
+ const reverted = revertDisplayToIncoming({ incoming, display })
157
+ expect(reverted.display?.blockLight[4 * 4096]).toBe(9)
158
+ expect(reverted.incoming?.blockLight[4 * 4096]).toBe(9)
159
+ expect(isLightSectionPresent(reverted.incoming!.blockPresent, worldSectionMaskBit(0))).toBe(false)
160
+ })
161
+ })
@@ -0,0 +1,112 @@
1
+ //@ts-nocheck
2
+ import {
3
+ cloneLightCache,
4
+ createEmptyLightCache,
5
+ maskBitSet,
6
+ mergeUpdateLight,
7
+ worldSectionMaskBit,
8
+ type ParsedUpdateLight,
9
+ type UpdateLightColumnCache
10
+ } from './mesherWasmLightMerge'
11
+ import { unpackPackedLightSection } from '../../mesher-shared/lightNibblePack'
12
+
13
+ export type OwnerPackedSection = {
14
+ sx: number
15
+ sy: number
16
+ sz: number
17
+ blockLight: Uint8Array
18
+ skyLight?: Uint8Array
19
+ }
20
+
21
+ /** World-origin Y of published sections that belong to this column. */
22
+ export function ownerDeltaSectionWorldYs(sections: Array<{ sx: number; sy: number; sz: number }>, columnWorldX: number, columnWorldZ: number): number[] {
23
+ const colSx = Math.floor(columnWorldX / 16) * 16
24
+ const colSz = Math.floor(columnWorldZ / 16) * 16
25
+ const ys: number[] = []
26
+ const seen = new Set<number>()
27
+ for (const section of sections) {
28
+ if (section.sx !== colSx || section.sz !== colSz) continue
29
+ if (seen.has(section.sy)) continue
30
+ seen.add(section.sy)
31
+ ys.push(section.sy)
32
+ }
33
+ return ys
34
+ }
35
+
36
+ /** Write owner packed 2048-byte channels into the worker's unpacked column cache. */
37
+ export function applyPackedOwnerSectionsToLightCache(
38
+ cache: UpdateLightColumnCache | undefined,
39
+ sections: OwnerPackedSection[],
40
+ worldMinY: number,
41
+ columnWorldX: number,
42
+ columnWorldZ: number,
43
+ numSections: number
44
+ ): UpdateLightColumnCache {
45
+ const next = cache ?? createEmptyLightCache(numSections)
46
+ const colSx = Math.floor(columnWorldX / 16) * 16
47
+ const colSz = Math.floor(columnWorldZ / 16) * 16
48
+ for (const section of sections) {
49
+ if (section.sx !== colSx || section.sz !== colSz) continue
50
+ const sectionIndex = Math.floor((section.sy - worldMinY) / 16)
51
+ if (sectionIndex < 0 || sectionIndex >= next.numSections) continue
52
+ const start = sectionIndex * 4096
53
+ const block = unpackPackedLightSection(section.blockLight)
54
+ next.blockLight.set(block, start)
55
+ maskBitSet(next.blockPresent, worldSectionMaskBit(sectionIndex))
56
+ if (section.skyLight) {
57
+ const sky = unpackPackedLightSection(section.skyLight)
58
+ next.skyLight.set(sky, start)
59
+ maskBitSet(next.skyPresent, worldSectionMaskBit(sectionIndex))
60
+ }
61
+ }
62
+ return next
63
+ }
64
+
65
+ export function applyRawLightPacketToCaches(opts: {
66
+ ownerOwnsColumn: boolean
67
+ incoming: UpdateLightColumnCache | undefined
68
+ display: UpdateLightColumnCache | undefined
69
+ parsed: ParsedUpdateLight
70
+ }): { incoming: UpdateLightColumnCache; display: UpdateLightColumnCache | undefined; dirtyDisplay: boolean } {
71
+ const incoming = mergeUpdateLight(opts.incoming, opts.parsed)
72
+ if (opts.ownerOwnsColumn) {
73
+ return { incoming, display: opts.display, dirtyDisplay: false }
74
+ }
75
+ return { incoming, display: incoming, dirtyDisplay: true }
76
+ }
77
+
78
+ /** First owner takeover: clone incoming onto display once. Later deltas write display in place. */
79
+ export function detachDisplayOnOwnerTakeover(opts: { incoming: UpdateLightColumnCache | undefined; display: UpdateLightColumnCache | undefined }): {
80
+ incoming: UpdateLightColumnCache | undefined
81
+ display: UpdateLightColumnCache | undefined
82
+ } {
83
+ if (!opts.incoming) return { incoming: undefined, display: opts.display }
84
+ if (opts.display == null || opts.display === opts.incoming) {
85
+ return { incoming: opts.incoming, display: cloneLightCache(opts.incoming) }
86
+ }
87
+ return { incoming: opts.incoming, display: opts.display }
88
+ }
89
+
90
+ export function applyOwnerPublicationToColumnCaches(opts: {
91
+ incoming: UpdateLightColumnCache | undefined
92
+ display: UpdateLightColumnCache | undefined
93
+ sections: OwnerPackedSection[]
94
+ worldMinY: number
95
+ columnWorldX: number
96
+ columnWorldZ: number
97
+ numSections: number
98
+ }): { incoming: UpdateLightColumnCache | undefined; display: UpdateLightColumnCache } {
99
+ const detached = detachDisplayOnOwnerTakeover({ incoming: opts.incoming, display: opts.display })
100
+ return {
101
+ incoming: detached.incoming,
102
+ display: applyPackedOwnerSectionsToLightCache(detached.display, opts.sections, opts.worldMinY, opts.columnWorldX, opts.columnWorldZ, opts.numSections)
103
+ }
104
+ }
105
+
106
+ export function revertDisplayToIncoming(opts: { incoming: UpdateLightColumnCache | undefined; display: UpdateLightColumnCache | undefined }): {
107
+ incoming: UpdateLightColumnCache | undefined
108
+ display: UpdateLightColumnCache | undefined
109
+ } {
110
+ if (!opts.incoming) return { incoming: undefined, display: opts.display }
111
+ return { incoming: opts.incoming, display: cloneLightCache(opts.incoming) }
112
+ }
@@ -0,0 +1,45 @@
1
+ //@ts-nocheck
2
+ /**
3
+ * Tick-scoped cache for WASM column parses in the two-step mesh path.
4
+ *
5
+ * `getOrConvertColumn` only covers the JS walk. The three WASM converters
6
+ * (`convertRawMapChunkToWasm` / `convertParsedV17ToWasm` / `convertParsedV16ToWasm`)
7
+ * used to re-parse the same neighbour for every dirty target column in a tick.
8
+ * This map lives for one `processColumnTick` and is dropped in `finally`.
9
+ *
10
+ * Same contract as `getOrConvertColumn`: cached values are `set()` sources /
11
+ * WASM inputs only. Consumers must not mutate them, so we do not copy.
12
+ */
13
+
14
+ let cache: Map<string, unknown> | null = null
15
+ let hits = 0
16
+ let misses = 0
17
+
18
+ export function beginTickWasmParseCache(): void {
19
+ cache = new Map()
20
+ hits = 0
21
+ misses = 0
22
+ }
23
+
24
+ export function endTickWasmParseCache(): void {
25
+ cache = null
26
+ }
27
+
28
+ export function getTickWasmParseStats(): { hits: number; misses: number } {
29
+ return { hits, misses }
30
+ }
31
+
32
+ export function getOrConvertTickWasmParse<T>(key: string, convert: () => T | null): { result: T | null; hit: boolean } {
33
+ if (!cache) {
34
+ misses++
35
+ return { result: convert(), hit: false }
36
+ }
37
+ if (cache.has(key)) {
38
+ hits++
39
+ return { result: cache.get(key) as T, hit: true }
40
+ }
41
+ const result = convert()
42
+ misses++
43
+ if (result != null) cache.set(key, result)
44
+ return { result, hit: false }
45
+ }
@@ -0,0 +1,44 @@
1
+ //@ts-nocheck
2
+ /**
3
+ * Mesh ticks. Bulk work is polled on an interval. An urgent dirty kicks the
4
+ * same runner on the next turn, and a kick during an in-progress tick reruns
5
+ * after that tick yields instead of waiting for the next poll.
6
+ */
7
+
8
+ export const BULK_MESH_INTERVAL_MS = 50
9
+
10
+ export function createMeshTickScheduler(run: () => Promise<void> | void) {
11
+ let running = false
12
+ let rerun = false
13
+ let timer: ReturnType<typeof setTimeout> | null = null
14
+
15
+ const pump = async () => {
16
+ timer = null
17
+ if (running) {
18
+ rerun = true
19
+ return
20
+ }
21
+ running = true
22
+ try {
23
+ do {
24
+ rerun = false
25
+ await run()
26
+ } while (rerun)
27
+ } finally {
28
+ running = false
29
+ }
30
+ }
31
+
32
+ return {
33
+ kick() {
34
+ if (running) {
35
+ rerun = true
36
+ return
37
+ }
38
+ if (timer != null) return
39
+ timer = setTimeout(() => {
40
+ void pump()
41
+ }, 0)
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,63 @@
1
+ //@ts-nocheck
2
+ /**
3
+ * Topology-keyed cache for the per-section visGraph + block-entity post-pass.
4
+ *
5
+ * Wave-2 covering remeshes carry the same `topologyRevision` as wave-1, so the
6
+ * 4096-cell walk can be reused. A new revision always recomputes. Legacy /
7
+ * unversioned jobs (no revision) never cache.
8
+ */
9
+
10
+ export const TOPOLOGY_POST_CACHE_LIMIT = 1024
11
+
12
+ export type TopologyPostPayload = {
13
+ visibilitySet: unknown
14
+ signs: Record<string, unknown>
15
+ heads: Record<string, unknown>
16
+ banners: Record<string, unknown>
17
+ }
18
+
19
+ type CacheEntry = {
20
+ topologyRevision: number
21
+ payload: TopologyPostPayload
22
+ }
23
+
24
+ const cache = new Map<string, CacheEntry>()
25
+
26
+ export function getOrComputeTopologyPost<T extends TopologyPostPayload>(
27
+ key: string,
28
+ topologyRevision: number | undefined,
29
+ compute: () => T
30
+ ): { payload: T; hit: boolean } {
31
+ if (topologyRevision == null) {
32
+ return { payload: compute(), hit: false }
33
+ }
34
+ const existing = cache.get(key)
35
+ if (existing && existing.topologyRevision === topologyRevision) {
36
+ cache.delete(key)
37
+ cache.set(key, existing)
38
+ return { payload: existing.payload as T, hit: true }
39
+ }
40
+ const payload = compute()
41
+ cache.set(key, { topologyRevision, payload })
42
+ evictIfNeeded()
43
+ return { payload, hit: false }
44
+ }
45
+
46
+ export function clearTopologyPostCacheColumn(x: number, z: number): void {
47
+ for (const key of [...cache.keys()]) {
48
+ const [sx, , sz] = key.split(',').map(Number)
49
+ if (sx === x && sz === z) cache.delete(key)
50
+ }
51
+ }
52
+
53
+ export function clearTopologyPostCache(): void {
54
+ cache.clear()
55
+ }
56
+
57
+ function evictIfNeeded(): void {
58
+ while (cache.size > TOPOLOGY_POST_CACHE_LIMIT) {
59
+ const oldest = cache.keys().next().value
60
+ if (oldest == null) break
61
+ cache.delete(oldest)
62
+ }
63
+ }
@@ -37,6 +37,7 @@ export interface LoadChunkData {
37
37
  export interface UnloadChunkData {
38
38
  x: number
39
39
  z: number
40
+ preserveClientLight?: boolean
40
41
  }
41
42
 
42
43
  /**
@@ -0,0 +1,68 @@
1
+ //@ts-nocheck
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { WorldView } from './worldView'
4
+
5
+ const column = () => ({ toJson: () => '{}', minY: 0, worldHeight: 256 })
6
+ function setup(getColumnAt = vi.fn(async () => column())) {
7
+ const view = new WorldView({ getColumnAt, setBlockStateId: vi.fn() }, 4)
8
+ view.loadedChunks = { '0,0': true, '16,0': true }
9
+ return { view, getColumnAt }
10
+ }
11
+
12
+ describe('geometry-only column reload', () => {
13
+ it('retains membership and requests light preservation', async () => {
14
+ const { view } = setup()
15
+ const unload = vi.fn()
16
+ const load = vi.fn()
17
+ view.on('unloadChunk', unload)
18
+ view.on('loadChunk', load)
19
+ await view.reloadColumnGeometry()
20
+ expect(view.loadedChunks).toEqual({ '0,0': true, '16,0': true })
21
+ expect(unload.mock.calls.map(([data]) => data)).toEqual([
22
+ { x: 0, z: 0, preserveClientLight: true },
23
+ { x: 16, z: 0, preserveClientLight: true }
24
+ ])
25
+ expect(load).toHaveBeenCalledTimes(2)
26
+ })
27
+
28
+ it('does not resurrect a column unloaded during an asynchronous fetch', async () => {
29
+ let resolve!: (value: ReturnType<typeof column>) => void
30
+ const pending = new Promise<ReturnType<typeof column>>(r => {
31
+ resolve = r
32
+ })
33
+ const { view } = setup(vi.fn(() => pending))
34
+ view.loadedChunks = { '0,0': true }
35
+ const load = vi.fn()
36
+ view.on('loadChunk', load)
37
+ const reload = view.reloadColumnGeometry()
38
+ expect(view.reloadColumnGeometry()).toBe(reload)
39
+ view.unloadChunk({ x: 0, z: 0 })
40
+ resolve(column())
41
+ await reload
42
+ expect(load).not.toHaveBeenCalled()
43
+ expect(view.loadedChunks['0,0']).toBeUndefined()
44
+ })
45
+
46
+ it('cleans up failed columns and continues loading the rest', async () => {
47
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
48
+ try {
49
+ const get = vi.fn().mockRejectedValueOnce(new Error('fetch failed')).mockResolvedValue(column())
50
+ const { view } = setup(get)
51
+ const load = vi.fn()
52
+ view.on('loadChunk', load)
53
+ await view.reloadColumnGeometry()
54
+ expect(view.loadedChunks).toEqual({ '16,0': true })
55
+ expect(load).toHaveBeenCalledTimes(1)
56
+ expect(warn).toHaveBeenCalledOnce()
57
+ } finally {
58
+ warn.mockRestore()
59
+ }
60
+ })
61
+
62
+ it('truly unloads columns outside the current range or missing from the world', async () => {
63
+ const { view } = setup(vi.fn(async () => null) as any)
64
+ view.loadedChunks['160,0'] = true
65
+ await view.reloadColumnGeometry()
66
+ expect(view.loadedChunks).toEqual({})
67
+ })
68
+ })
@@ -11,7 +11,7 @@ describe('WorldView._loadChunks spiral guard', () => {
11
11
  }
12
12
  const view = new WorldView(world, 8, new Vec3(0, 64, 0))
13
13
  view.addWaitTime = 0
14
- view.loadChunk = vi.fn(async () => {})
14
+ view.loadChunk = vi.fn(async () => true)
15
15
 
16
16
  const positions = [new Vec3(0, 0, 0)]
17
17
  const spiralA = view._loadChunks(positions, new Vec3(0, 64, 0))
@@ -115,6 +115,8 @@ export class WorldView extends (EventEmitter as new () => TypedEmitter<WorldView
115
115
  gotPanicLastTime = false
116
116
  panicChunksReload = () => {}
117
117
  loadedChunks: Record<ChunkPosKey, boolean> = {}
118
+ private columnUnloadEpoch = new Map<string, number>()
119
+ private geometryReload?: Promise<void>
118
120
  inLoading = false
119
121
  chunkReceiveTimes: number[] = []
120
122
  lastChunkReceiveTime = 0
@@ -276,7 +278,9 @@ export class WorldView extends (EventEmitter as new () => TypedEmitter<WorldView
276
278
  /**
277
279
  * Load a chunk at the given position.
278
280
  */
279
- async loadChunk(pos: { x: number; z: number; y?: number }, isLightUpdate = false, reason = 'spiral'): Promise<void> {
281
+ async loadChunk(pos: { x: number; z: number; y?: number }, isLightUpdate = false, reason = 'spiral'): Promise<boolean> {
282
+ const key = `${pos.x},${pos.z}`
283
+ const epoch = this.columnUnloadEpoch.get(key) ?? 0
280
284
  const [botX, botZ] = chunkPos(this.lastPos)
281
285
  const dx = Math.abs(botX - Math.floor(pos.x / 16))
282
286
  const dz = Math.abs(botZ - Math.floor(pos.z / 16))
@@ -284,6 +288,14 @@ export class WorldView extends (EventEmitter as new () => TypedEmitter<WorldView
284
288
  if (dx <= this.viewDistance && dz <= this.viewDistance) {
285
289
  const column = await this.world.getColumnAt(pos.y !== undefined ? (pos as Vec3) : new Vec3(pos.x, 0, pos.z))
286
290
 
291
+ // An asynchronous fetch must not resurrect a truly unloaded column.
292
+ const [currentX, currentZ] = chunkPos(this.lastPos)
293
+ if (
294
+ (this.columnUnloadEpoch.get(key) ?? 0) !== epoch ||
295
+ Math.abs(currentX - Math.floor(pos.x / 16)) > this.viewDistance ||
296
+ Math.abs(currentZ - Math.floor(pos.z / 16)) > this.viewDistance
297
+ )
298
+ return false
287
299
  if (column) {
288
300
  const chunk = column.toJson()
289
301
  const worldConfig: WorldSizeParams = {
@@ -307,10 +319,41 @@ export class WorldView extends (EventEmitter as new () => TypedEmitter<WorldView
307
319
  reason,
308
320
  time: Date.now()
309
321
  })
322
+ return true
310
323
  } else if (this.isPlayground) {
311
324
  this.emit('markAsLoaded', { x: pos.x, z: pos.z })
312
325
  }
313
326
  }
327
+ return false
328
+ }
329
+
330
+ reloadColumnGeometry(): Promise<void> {
331
+ this.geometryReload ??= this.runColumnGeometryReload().finally(() => {
332
+ this.geometryReload = undefined
333
+ })
334
+ return this.geometryReload
335
+ }
336
+
337
+ private async runColumnGeometryReload(): Promise<void> {
338
+ const coords = Object.keys(this.loadedChunks)
339
+ .filter(key => this.loadedChunks[key])
340
+ .map(key => key.split(',').map(Number) as [number, number])
341
+ coords.sort(([ax, az], [bx, bz]) => (ax - this.lastPos.x) ** 2 + (az - this.lastPos.z) ** 2 - (bx - this.lastPos.x) ** 2 - (bz - this.lastPos.z) ** 2)
342
+ for (const [x, z] of coords) this.emit('unloadChunk', { x, z, preserveClientLight: true })
343
+ for (const [x, z] of coords) {
344
+ const key = `${x},${z}`
345
+ if (!this.loadedChunks[key]) continue
346
+ const epoch = this.columnUnloadEpoch.get(key) ?? 0
347
+ let reloaded = false
348
+ try {
349
+ reloaded = await this.loadChunk({ x, z }, false, 'geometry-reload')
350
+ } catch (error) {
351
+ console.warn(`Failed to reload column geometry ${key}`, error)
352
+ }
353
+ if (!reloaded && this.loadedChunks[key] && (this.columnUnloadEpoch.get(key) ?? 0) === epoch) {
354
+ this.unloadChunk({ x, z })
355
+ }
356
+ }
314
357
  }
315
358
 
316
359
  /**
@@ -338,6 +381,8 @@ export class WorldView extends (EventEmitter as new () => TypedEmitter<WorldView
338
381
  * Unload a specific chunk.
339
382
  */
340
383
  unloadChunk(pos: { x: number; z: number }): void {
384
+ const key = `${pos.x},${pos.z}`
385
+ this.columnUnloadEpoch.set(key, (this.columnUnloadEpoch.get(key) ?? 0) + 1)
341
386
  this.emit('unloadChunk', { x: pos.x, z: pos.z })
342
387
  delete this.loadedChunks[`${pos.x},${pos.z}`]
343
388
  delete this.debugChunksInfo[`${pos.x},${pos.z}`]