Skip to content

Commit c49c2fb

Browse files
committed
feat: add support for packed and expanded subchunk formats with unified storage model
1 parent bc0b64b commit c49c2fb

File tree

8 files changed

+357
-91
lines changed

8 files changed

+357
-91
lines changed

Sources/CoreBedrock/Error/CBError.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ public enum CBError: Error, Equatable, LocalizedError {
1212
case unhandledLevelDBKey(String)
1313
case failedSaveImage(URL)
1414
case invalidDataLength(Int)
15+
case invalidSubChunkVersion(Int)
1516
}

Sources/CoreBedrock/World/Biome/Data3DParser.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ struct Data3DParser {
1111
self.binaryReader = CBBinaryReader(data: data)
1212
}
1313

14-
private func lightParseBiomeSection(chunkY: Int8) throws -> MCBiomeStorage.SubChunkBiomeSection? {
14+
private func lightParseBiomeSection(chunkY: Int8) throws -> PackedBiomeHeightColumn.PackedBiomeSection? {
1515
let header = try binaryReader.readUInt8()
1616
if header == 0xFF {
1717
// skip chunks have not been loaded
@@ -46,7 +46,7 @@ struct Data3DParser {
4646
)
4747
}
4848

49-
func lightParse(dimension: MCDimension) throws -> MCBiomeStorage {
49+
func lightParse(dimension: MCDimension) throws -> PackedBiomeHeightColumn {
5050
let chunkYRange = dimension.chunkYRange
5151
let minChunkY = chunkYRange.lowerBound
5252
let maxChunkY = chunkYRange.upperBound
@@ -58,7 +58,7 @@ struct Data3DParser {
5858

5959
let heightBytes = try binaryReader.readBytes(heightBytesCount)
6060

61-
var biomeSections: [MCBiomeStorage.SubChunkBiomeSection] = []
61+
var biomeSections: [PackedBiomeHeightColumn.PackedBiomeSection] = []
6262
for chunkY in minChunkY...maxChunkY {
6363
if let biomeSection = try lightParseBiomeSection(chunkY: chunkY) {
6464
biomeSections.append(biomeSection)

Sources/CoreBedrock/World/Biome/MCBiomeStorage.swift renamed to Sources/CoreBedrock/World/Biome/PackedBiomeHeightColumn.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44

55
import Foundation
66

7-
struct MCBiomeStorage {
7+
struct PackedBiomeHeightColumn {
88
let heightBytes: [UInt8] // 1 block = 2 bytes
9-
let biomeSections: [SubChunkBiomeSection]
9+
let biomeSections: [PackedBiomeSection]
1010

1111
@inline(__always)
1212
func highestBlockY(atLocalX localX: Int, localZ: Int) -> UInt16? {
@@ -30,7 +30,7 @@ struct MCBiomeStorage {
3030
return subChunkBiome.paletteValue(localX: localX, localY: localY, localZ: localZ)
3131
}
3232

33-
struct SubChunkBiomeSection: PaletteReadable {
33+
struct PackedBiomeSection: PackedPaletteReadable {
3434
let chunkY: Int8
3535
let bitWidth: Int
3636
let palette: [Int32]
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
//
2+
// Created by yechentide on 2025/11/20
3+
//
4+
5+
import Foundation
6+
7+
public struct ExpandedSubChunk {
8+
public let version: Int
9+
public let chunkY: Int8
10+
11+
public var blockLayer: ExpandedBlockLayer
12+
public var liquidLayer: ExpandedBlockLayer?
13+
14+
public init(version: Int, chunkY: Int8, blockLayer: ExpandedBlockLayer, liquidLayer: ExpandedBlockLayer? = nil) {
15+
self.version = version
16+
self.chunkY = chunkY
17+
self.blockLayer = blockLayer
18+
self.liquidLayer = liquidLayer
19+
}
20+
21+
public init(packed: PackedSubChunk) {
22+
self.version = packed.version
23+
self.chunkY = packed.chunkY
24+
self.blockLayer = ExpandedBlockLayer(packedLayer: packed.blockLayer)
25+
26+
guard let packedLiquidLayer = packed.liquidLayer,
27+
!packedLiquidLayer.palette.isEmpty,
28+
!packedLiquidLayer.indicesBytes.isEmpty
29+
else {
30+
self.liquidLayer = nil
31+
return
32+
}
33+
34+
self.liquidLayer = ExpandedBlockLayer(packedLayer: packedLiquidLayer)
35+
}
36+
37+
public func toData() throws -> Data {
38+
let writer = CBBinaryWriter()
39+
40+
// Determine layer count
41+
let hasLiquid = self.liquidLayer != nil
42+
&& !(self.liquidLayer?.palette.isEmpty ?? true)
43+
&& !(self.liquidLayer?.indices.isEmpty ?? true)
44+
let layerCount: UInt8 = hasLiquid ? 2 : 1
45+
46+
// Write header
47+
try writer.write(UInt8(self.version))
48+
try writer.write(layerCount)
49+
try writer.write(self.chunkY)
50+
51+
// Write block layer
52+
try Self.writeLayer(self.blockLayer, to: writer)
53+
54+
// Write liquid layer if present
55+
if hasLiquid, let liquid = liquidLayer {
56+
try Self.writeLayer(liquid, to: writer)
57+
}
58+
59+
return writer.data
60+
}
61+
62+
private static func writeLayer(_ layer: ExpandedBlockLayer, to writer: CBBinaryWriter) throws {
63+
// Compute bitWidth from max palette index
64+
let maxIndex = layer.indices.max() ?? 0
65+
let bitWidth = max(1, min(CBBinaryReader.wordBitSize, maxIndex.bitWidth))
66+
67+
guard bitWidth >= 1, bitWidth <= CBBinaryReader.wordBitSize else {
68+
throw CBStreamError.invalidFormat("Invalid bitWidth: \(bitWidth)")
69+
}
70+
71+
// Pack indices into bytes
72+
let indicesBytes = try Self.packIndices(layer.indices, bitWidth: bitWidth, palette: layer.palette)
73+
74+
// Write type byte
75+
let typeByte = UInt8((bitWidth << 1) | 0x01)
76+
try writer.write(typeByte)
77+
78+
// Write packed indices
79+
try writer.write(indicesBytes)
80+
81+
// Write palette count
82+
try writer.write(UInt32(layer.palette.count))
83+
84+
// Write each block tag inline (CBTagReader expects inline tags, not separate buffers)
85+
let tagWriter = CBTagWriter()
86+
for block in layer.palette {
87+
try tagWriter.write(tag: block)
88+
}
89+
let tagData = tagWriter.toData()
90+
try writer.write([UInt8](tagData))
91+
}
92+
93+
private static func packIndices(
94+
_ indices: [UInt16],
95+
bitWidth: Int,
96+
palette: [CompoundTag]
97+
) throws -> [UInt8] {
98+
guard indices.count == MCSubChunk.totalBlockCount else {
99+
throw CBStreamError.argumentError("Indices count must be \(MCSubChunk.totalBlockCount)")
100+
}
101+
102+
let valuesPerWord = CBBinaryReader.wordBitSize / bitWidth
103+
let wordCount = (MCSubChunk.totalBlockCount + valuesPerWord - 1) / valuesPerWord
104+
var indicesBytes = [UInt8](repeating: 0, count: wordCount * 4)
105+
106+
let mask: UInt32 = (1 << bitWidth) - 1
107+
108+
for linear in 0..<MCSubChunk.totalBlockCount {
109+
let paletteIndex = Int(indices[linear])
110+
111+
// Validate palette index
112+
guard paletteIndex < palette.count else {
113+
throw CBStreamError.argumentOutOfRange(
114+
"paletteIndex",
115+
"Index \(paletteIndex) out of bounds for palette of size \(palette.count)"
116+
)
117+
}
118+
119+
let wordIndex = linear / valuesPerWord
120+
let indexInWord = linear % valuesPerWord
121+
let bitOffset = indexInWord * bitWidth
122+
let byteOffset = wordIndex * 4
123+
124+
// Read existing word (little-endian)
125+
var word: UInt32 = 0
126+
for i in 0..<4 {
127+
word |= UInt32(indicesBytes[byteOffset + i]) << (i * 8)
128+
}
129+
130+
// Clear and set bits
131+
let clearMask = ~(mask << bitOffset)
132+
word = (word & clearMask) | (UInt32(paletteIndex) << bitOffset)
133+
134+
// Write word back (little-endian)
135+
for i in 0..<4 {
136+
indicesBytes[byteOffset + i] = UInt8((word >> (i * 8)) & 0xFF)
137+
}
138+
}
139+
140+
return indicesBytes
141+
}
142+
}
143+
144+
public struct ExpandedBlockLayer {
145+
public private(set) var palette: [CompoundTag]
146+
public private(set) var indices: [UInt16]
147+
private var nameCache: [String: [Int]]
148+
149+
public init(packedLayer: PackedBlockLayer) {
150+
self.palette = packedLayer.palette
151+
self.nameCache = [:]
152+
153+
if let indices = packedLayer.unpackPaletteIndices() {
154+
self.indices = indices
155+
} else {
156+
self.indices = [UInt16](repeating: 0, count: MCSubChunk.totalBlockCount)
157+
}
158+
}
159+
160+
public func block(at localX: Int, localY: Int, localZ: Int) -> CompoundTag? {
161+
guard let linearIndex = MCSubChunk.linearIndex(localX, localY, localZ) else {
162+
return nil
163+
}
164+
165+
let paletteIndex = Int(indices[linearIndex])
166+
guard paletteIndex < self.palette.count else {
167+
return nil
168+
}
169+
170+
return self.palette[paletteIndex]
171+
}
172+
173+
public mutating func place(at localX: Int, localY: Int, localZ: Int, block: CompoundTag) {
174+
guard let linearIndex = MCSubChunk.linearIndex(localX, localY, localZ) else { return }
175+
176+
let paletteIndex = self.ensurePaletteIndex(for: block)
177+
guard paletteIndex <= UInt16.max else { return }
178+
179+
self.indices[linearIndex] = UInt16(paletteIndex)
180+
}
181+
182+
// MARK: - Private Helpers
183+
184+
private mutating func rebuildCacheIfNeeded() {
185+
guard self.nameCache.isEmpty else { return }
186+
187+
var cache: [String: [Int]] = [:]
188+
for (index, block) in self.palette.enumerated() {
189+
guard let nameTag = block["name"] as? StringTag else { continue }
190+
191+
let name = nameTag.value
192+
cache[name, default: []].append(index)
193+
}
194+
self.nameCache = cache
195+
}
196+
197+
private mutating func ensurePaletteIndex(for block: CompoundTag) -> Int {
198+
self.rebuildCacheIfNeeded()
199+
200+
// Try to find existing block by name first
201+
if let nameTag = block["name"] as? StringTag {
202+
let name = nameTag.value
203+
if let candidateIndices = nameCache[name] {
204+
// Only iterate the cached indices for this name
205+
for paletteIndex in candidateIndices where self.palette[paletteIndex] == block {
206+
return paletteIndex
207+
}
208+
}
209+
210+
// Not found, add to palette
211+
let newIndex = self.palette.count
212+
self.palette.append(block)
213+
self.nameCache[name, default: []].append(newIndex)
214+
return newIndex
215+
}
216+
217+
// No name, just append
218+
let newIndex = self.palette.count
219+
self.palette.append(block)
220+
return newIndex
221+
}
222+
}

Sources/CoreBedrock/World/Block/MCSubChunkStorage.swift

Lines changed: 0 additions & 67 deletions
This file was deleted.

0 commit comments

Comments
 (0)