diff --git a/World Manager for Minecraft/ItemDetailColumnViews.swift b/World Manager for Minecraft/ItemDetailColumnViews.swift index f99d719..426b2b2 100644 --- a/World Manager for Minecraft/ItemDetailColumnViews.swift +++ b/World Manager for Minecraft/ItemDetailColumnViews.swift @@ -110,6 +110,16 @@ struct ItemDetailView: View { summaryGrid } + if !worldSettingsRows.isEmpty { + recordSection(title: "World Settings") { + VStack(alignment: .leading, spacing: 14) { + ForEach(worldSettingsRows, id: \.title) { row in + detailValueRow(title: row.title, value: row.value) + } + } + } + } + if let healthMessages, !healthMessages.isEmpty { recordSection(title: "Compatibility") { VStack(alignment: .leading, spacing: 10) { @@ -230,6 +240,15 @@ struct ItemDetailView: View { detailRow(title: "Folder ID", value: item.folderID) detailRow(title: "Type", value: item.contentType.rawValue) detailRow(title: "Collection Folder", value: item.collectionRootURL.lastPathComponent) + if let spawn = item.worldMetadata?.spawn { + detailValueRow(title: "Spawn", value: spawn) + } + if let storageVersion = item.worldMetadata?.storageVersion { + detailValueRow(title: "Storage Version", value: storageVersion) + } + if let networkVersion = item.worldMetadata?.networkVersion { + detailValueRow(title: "Network Version", value: networkVersion) + } } } } @@ -279,6 +298,26 @@ struct ItemDetailView: View { detailValueRow(title: item.displayDateLabel, value: displayDateText) detailValueRow(title: "Created", value: createdDateText) + if let gameMode = item.worldMetadata?.gameMode { + detailValueRow(title: "Game Mode", value: gameMode) + } + + if let difficulty = item.worldMetadata?.difficulty { + detailValueRow(title: "Difficulty", value: difficulty) + } + + if let seed = item.worldMetadata?.seed { + detailValueRow(title: "Seed", value: seed) + } + + if let lastOpenedWithVersion = item.worldMetadata?.lastOpenedWithVersion { + detailValueRow(title: "Last Opened With", value: lastOpenedWithVersion) + } + + if let inventoryVersion = item.worldMetadata?.inventoryVersion { + detailValueRow(title: "Inventory Version", value: inventoryVersion) + } + if item.contentType == .world { detailValueRow( title: "Pack References", @@ -289,10 +328,30 @@ struct ItemDetailView: View { if item.contentType == .behaviorPack || item.contentType == .resourcePack { detailValueRow(title: "UUID", value: item.packUUID ?? "Unavailable") detailValueRow(title: "Version", value: item.packVersion ?? "Unavailable") + if let minimumEngineVersion = item.packMetadataDetails?.minimumEngineVersion { + detailValueRow(title: "Minimum Engine", value: minimumEngineVersion) + } } } } + private var worldSettingsRows: [(title: String, value: String)] { + guard let metadata = item.worldMetadata else { + return [] + } + + return [ + booleanRow("Cheats Enabled", metadata.cheatsEnabled), + booleanRow("Commands Enabled", metadata.commandsEnabled), + booleanRow("Education Features", metadata.educationFeaturesEnabled), + booleanRow("Coordinates Shown", metadata.coordinatesShown), + booleanRow("Keep Inventory", metadata.keepInventory), + booleanRow("Mob Griefing", metadata.mobGriefingEnabled), + booleanRow("Daylight Cycle", metadata.daylightCycleEnabled), + booleanRow("Weather Cycle", metadata.weatherCycleEnabled) + ].compactMap { $0 } + } + private var healthMessages: [String]? { var messages: [String] = [] @@ -537,6 +596,14 @@ struct ItemDetailView: View { } } + private func booleanRow(_ title: String, _ value: Bool?) -> (title: String, value: String)? { + guard let value else { + return nil + } + + return (title, value ? "Yes" : "No") + } + private var sizeText: String { if let sizeBytes = item.sizeBytes { return ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file) diff --git a/World Manager for Minecraft/Models/MinecraftContentItem.swift b/World Manager for Minecraft/Models/MinecraftContentItem.swift index 2bcc55c..3f8e647 100644 --- a/World Manager for Minecraft/Models/MinecraftContentItem.swift +++ b/World Manager for Minecraft/Models/MinecraftContentItem.swift @@ -93,6 +93,30 @@ struct ContentPackReference: Identifiable, Hashable, Sendable, Codable { } } +struct WorldMetadata: Hashable, Sendable, Codable { + var gameMode: String? + var difficulty: String? + var seed: String? + var lastPlayedDate: Date? + var lastOpenedWithVersion: String? + var inventoryVersion: String? + var cheatsEnabled: Bool? + var commandsEnabled: Bool? + var educationFeaturesEnabled: Bool? + var coordinatesShown: Bool? + var keepInventory: Bool? + var mobGriefingEnabled: Bool? + var daylightCycleEnabled: Bool? + var weatherCycleEnabled: Bool? + var spawn: String? + var storageVersion: String? + var networkVersion: String? +} + +struct PackMetadataDetails: Hashable, Sendable, Codable { + var minimumEngineVersion: String? +} + struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable { let id: URL let folderURL: URL @@ -106,7 +130,9 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable { var sizeBytes: Int64? var packUUID: String? var packVersion: String? + var packMetadataDetails: PackMetadataDetails? var packReferences: [ContentPackReference] + var worldMetadata: WorldMetadata? var metadataLoaded: Bool var sizeLoaded: Bool @@ -122,7 +148,9 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable { sizeBytes: Int64? = nil, packUUID: String? = nil, packVersion: String? = nil, + packMetadataDetails: PackMetadataDetails? = nil, packReferences: [ContentPackReference] = [], + worldMetadata: WorldMetadata? = nil, metadataLoaded: Bool = false, sizeLoaded: Bool = false ) { @@ -138,7 +166,9 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable { self.sizeBytes = sizeBytes self.packUUID = packUUID?.lowercased() self.packVersion = packVersion + self.packMetadataDetails = packMetadataDetails self.packReferences = packReferences + self.worldMetadata = worldMetadata self.metadataLoaded = metadataLoaded self.sizeLoaded = sizeLoaded } @@ -156,14 +186,19 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable { } nonisolated var searchText: String { - let values = [ + var values: [String] = [ displayName, folderName, folderURL.path, - contentType.rawValue, - packReferences.map(\.name).joined(separator: " "), - packReferences.compactMap(\.uuid).joined(separator: " ") + contentType.rawValue ] + values.append(worldMetadata?.gameMode ?? "") + values.append(worldMetadata?.difficulty ?? "") + values.append(worldMetadata?.seed ?? "") + values.append(worldMetadata?.lastOpenedWithVersion ?? "") + values.append(packMetadataDetails?.minimumEngineVersion ?? "") + values.append(packReferences.map(\.name).joined(separator: " ")) + values.append(packReferences.compactMap(\.uuid).joined(separator: " ")) return values .filter { !$0.isEmpty } diff --git a/World Manager for Minecraft/Services/BedrockLevelMetadataDecoder.swift b/World Manager for Minecraft/Services/BedrockLevelMetadataDecoder.swift new file mode 100644 index 0000000..14017e6 --- /dev/null +++ b/World Manager for Minecraft/Services/BedrockLevelMetadataDecoder.swift @@ -0,0 +1,352 @@ +import Foundation + +enum BedrockLevelMetadataDecoder { + nonisolated static func decode(fromLevelDatAt url: URL) -> WorldMetadata? { + guard let data = try? Data(contentsOf: url) else { + return nil + } + + return decode(fromLevelDatData: data) + } + + nonisolated static func decode(fromLevelDatData data: Data) -> WorldMetadata? { + guard data.count > 8 else { + return nil + } + + let payloadLength = Int(readInt32LE(from: data, offset: 4)) + let payloadStart = 8 + let payloadEnd = min(data.count, payloadStart + max(0, payloadLength)) + guard payloadEnd > payloadStart else { + return nil + } + + let payload = data.subdata(in: payloadStart.. WorldMetadata { + let gameModeCode = root["GameType"]?.intValue + let difficultyCode = root["Difficulty"]?.intValue + let spawn = formattedSpawn(from: root) + let lastPlayedRaw = root["LastPlayed"]?.longValue + let lastPlayedDate = lastPlayedRaw.map(dateFromBedrockTimestamp(_:)) + + return WorldMetadata( + gameMode: gameModeCode.map(gameModeName(for:)), + difficulty: difficultyCode.map(difficultyName(for:)), + seed: root["RandomSeed"]?.stringifiedScalar, + lastPlayedDate: lastPlayedDate, + lastOpenedWithVersion: versionString(from: root["lastOpenedWithVersion"] ?? root["MinimumCompatibleClientVersion"]), + inventoryVersion: versionString(from: root["InventoryVersion"]), + cheatsEnabled: root["cheatsEnabled"]?.boolValue, + commandsEnabled: root["commandsEnabled"]?.boolValue, + educationFeaturesEnabled: root["educationFeaturesEnabled"]?.boolValue ?? root["eduLevel"]?.boolValue, + coordinatesShown: root["showcoordinates"]?.boolValue, + keepInventory: root["keepinventory"]?.boolValue, + mobGriefingEnabled: root["mobgriefing"]?.boolValue, + daylightCycleEnabled: root["dodaylightcycle"]?.boolValue, + weatherCycleEnabled: root["doweathercycle"]?.boolValue, + spawn: spawn, + storageVersion: root["StorageVersion"]?.stringifiedScalar, + networkVersion: root["NetworkVersion"]?.stringifiedScalar + ) + } + + nonisolated private static func gameModeName(for value: Int) -> String { + switch value { + case 0: + return "Survival" + case 1: + return "Creative" + case 2: + return "Adventure" + case 3: + return "Spectator" + default: + return String(value) + } + } + + nonisolated private static func difficultyName(for value: Int) -> String { + switch value { + case 0: + return "Peaceful" + case 1: + return "Easy" + case 2: + return "Normal" + case 3: + return "Hard" + default: + return String(value) + } + } + + nonisolated private static func formattedSpawn(from root: [String: NBTValue]) -> String? { + guard + let x = root["SpawnX"]?.intValue, + let y = root["SpawnY"]?.intValue, + let z = root["SpawnZ"]?.intValue + else { + return nil + } + + return "\(x), \(y), \(z)" + } + + nonisolated private static func versionString(from value: NBTValue?) -> String? { + guard let value else { + return nil + } + + switch value { + case .string(let string): + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + case .list(_, let values): + let components = values.compactMap(\.intValue).map(String.init) + return components.isEmpty ? nil : components.joined(separator: ".") + case .intArray(let values): + return values.isEmpty ? nil : values.map(String.init).joined(separator: ".") + case .byteArray(let values): + return values.isEmpty ? nil : values.map(String.init).joined(separator: ".") + default: + return value.stringifiedScalar + } + } + + nonisolated private static func dateFromBedrockTimestamp(_ value: Int64) -> Date { + if value > 1_000_000_000_000 { + return Date(timeIntervalSince1970: TimeInterval(value) / 1000) + } + + return Date(timeIntervalSince1970: TimeInterval(value)) + } + + nonisolated private static func readInt32LE(from data: Data, offset: Int) -> Int32 { + let range = offset..<(offset + 4) + return data.subdata(in: range).withUnsafeBytes { buffer in + Int32(littleEndian: buffer.loadUnaligned(as: Int32.self)) + } + } +} + +private enum NBTValue: Hashable, Sendable { + case byte(Int8) + case short(Int16) + case int(Int32) + case long(Int64) + case float(Float) + case double(Double) + case byteArray([Int8]) + case string(String) + case list(UInt8, [NBTValue]) + case compound([String: NBTValue]) + case intArray([Int32]) + case longArray([Int64]) + + nonisolated var intValue: Int? { + switch self { + case .byte(let value): + return Int(value) + case .short(let value): + return Int(value) + case .int(let value): + return Int(value) + case .long(let value): + return Int(value) + default: + return nil + } + } + + nonisolated var longValue: Int64? { + switch self { + case .byte(let value): + return Int64(value) + case .short(let value): + return Int64(value) + case .int(let value): + return Int64(value) + case .long(let value): + return value + default: + return nil + } + } + + nonisolated var boolValue: Bool? { + intValue.map { $0 != 0 } + } + + nonisolated var stringifiedScalar: String? { + switch self { + case .string(let value): + return value + case .byte(let value): + return String(value) + case .short(let value): + return String(value) + case .int(let value): + return String(value) + case .long(let value): + return String(value) + default: + return nil + } + } +} + +private struct BedrockNBTDecoder { + private let data: Data + private var offset = 0 + + nonisolated init(data: Data) { + self.data = data + } + + nonisolated mutating func decodeRootCompound() throws -> [String: NBTValue] { + let rootType = try readUInt8() + guard rootType == 10 else { + throw BedrockNBTError.invalidRootTag + } + + _ = try readString() + return try readCompoundPayload() + } + + nonisolated private mutating func readCompoundPayload() throws -> [String: NBTValue] { + var result: [String: NBTValue] = [:] + + while true { + let tagType = try readUInt8() + if tagType == 0 { + return result + } + + let name = try readString() + result[name] = try readTagPayload(type: tagType) + } + } + + nonisolated private mutating func readTagPayload(type: UInt8) throws -> NBTValue { + switch type { + case 1: + return .byte(try readInt8()) + case 2: + return .short(try readInt16()) + case 3: + return .int(try readInt32()) + case 4: + return .long(try readInt64()) + case 5: + return .float(try readFloat()) + case 6: + return .double(try readDouble()) + case 7: + let count = try readCount() + return .byteArray(try (0.. Int { + let count = try readInt32() + guard count >= 0 else { + throw BedrockNBTError.invalidLength + } + return Int(count) + } + + nonisolated private mutating func readUInt8() throws -> UInt8 { + guard offset + 1 <= data.count else { + throw BedrockNBTError.outOfBounds + } + + defer { offset += 1 } + return data[offset] + } + + nonisolated private mutating func readInt8() throws -> Int8 { + Int8(bitPattern: try readUInt8()) + } + + nonisolated private mutating func readInt16() throws -> Int16 { + try readScalar(Int16.self) + } + + nonisolated private mutating func readInt32() throws -> Int32 { + try readScalar(Int32.self) + } + + nonisolated private mutating func readInt64() throws -> Int64 { + try readScalar(Int64.self) + } + + nonisolated private mutating func readFloat() throws -> Float { + let raw = try readScalar(UInt32.self) + return Float(bitPattern: raw) + } + + nonisolated private mutating func readDouble() throws -> Double { + let raw = try readScalar(UInt64.self) + return Double(bitPattern: raw) + } + + nonisolated private mutating func readString() throws -> String { + let length = Int(try readUInt16()) + guard offset + length <= data.count else { + throw BedrockNBTError.outOfBounds + } + + let stringData = data.subdata(in: offset..<(offset + length)) + offset += length + return String(decoding: stringData, as: UTF8.self) + } + + nonisolated private mutating func readUInt16() throws -> UInt16 { + try readScalar(UInt16.self) + } + + nonisolated private mutating func readScalar(_ type: T.Type) throws -> T { + let byteCount = MemoryLayout.size + guard offset + byteCount <= data.count else { + throw BedrockNBTError.outOfBounds + } + + let value = data.subdata(in: offset..<(offset + byteCount)).withUnsafeBytes { buffer in + T(littleEndian: buffer.loadUnaligned(as: T.self)) + } + offset += byteCount + return value + } +} + +private enum BedrockNBTError: Error { + case invalidRootTag + case invalidLength + case outOfBounds + case unsupportedTag(UInt8) +} diff --git a/World Manager for Minecraft/Services/WorldScanner.swift b/World Manager for Minecraft/Services/WorldScanner.swift index dda0a7a..dcd3cd0 100644 --- a/World Manager for Minecraft/Services/WorldScanner.swift +++ b/World Manager for Minecraft/Services/WorldScanner.swift @@ -93,11 +93,15 @@ enum WorldScanner { enrichedItem.displayName = displayName(for: item, fileManager: fileManager) let sourceIconURL = iconURL(for: item, fileManager: fileManager) enrichedItem.iconURL = await ImageCacheStore.shared.cachedImageURL(for: sourceIconURL) - enrichedItem.lastPlayedDate = lastPlayedDate(for: item, fileManager: fileManager) + enrichedItem.worldMetadata = worldMetadata(for: item, fileManager: fileManager) + enrichedItem.lastPlayedDate = lastPlayedDate(for: item, fileManager: fileManager, worldMetadata: enrichedItem.worldMetadata) enrichedItem.modifiedDate = modifiedDate(for: item.folderURL) if let manifestMetadata = manifestMetadata(in: item.folderURL, fileManager: fileManager) { enrichedItem.packUUID = manifestMetadata.uuid enrichedItem.packVersion = manifestMetadata.version + enrichedItem.packMetadataDetails = PackMetadataDetails( + minimumEngineVersion: manifestMetadata.minimumEngineVersion + ) if !manifestMetadata.name.isEmpty { enrichedItem.displayName = manifestMetadata.name } @@ -259,16 +263,17 @@ enum WorldScanner { return nil } - nonisolated private static func lastPlayedDate(for item: MinecraftContentItem, fileManager: FileManager) -> Date? { + nonisolated private static func lastPlayedDate( + for item: MinecraftContentItem, + fileManager: FileManager, + worldMetadata: WorldMetadata? + ) -> Date? { guard item.contentType == .world else { return nil } - // Bedrock's level.dat requires format-specific parsing to distinguish a true - // last-played timestamp from general save metadata. Until that is implemented - // reliably, prefer surfacing the filesystem modified date only. _ = fileManager - return nil + return worldMetadata?.lastPlayedDate } nonisolated private static func modifiedDate(for directoryURL: URL) -> Date? { @@ -438,6 +443,19 @@ enum WorldScanner { ) } + nonisolated private static func worldMetadata(for item: MinecraftContentItem, fileManager: FileManager) -> WorldMetadata? { + guard item.contentType == .world else { + return nil + } + + let levelDatURL = item.folderURL.appendingPathComponent("level.dat") + guard fileManager.fileExists(atPath: levelDatURL.path) else { + return nil + } + + return BedrockLevelMetadataDecoder.decode(fromLevelDatAt: levelDatURL) + } + nonisolated private static func resolvedPackReference( uuid: String, type: MinecraftContentType, @@ -494,7 +512,8 @@ enum WorldScanner { return ManifestMetadata( name: name, uuid: (header["uuid"] as? String)?.lowercased(), - version: versionString(from: header["version"]) + version: versionString(from: header["version"]), + minimumEngineVersion: versionString(from: header["min_engine_version"]) ) } @@ -526,6 +545,7 @@ private struct ManifestMetadata { let name: String let uuid: String? let version: String? + let minimumEngineVersion: String? } private actor PackReferenceIndexStore { diff --git a/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift b/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift index d9ba3b6..460a824 100644 --- a/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift +++ b/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift @@ -158,6 +158,102 @@ struct World_Manager_for_MinecraftTests { #expect(enrichedWorld.packReferences.first?.version == "1.0.0") } + @Test func worldScannerDecodesBedrockLevelMetadata() async throws { + let fileManager = FileManager.default + let sourceURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let worldURL = sourceURL.appendingPathComponent("minecraftWorlds/WorldA", isDirectory: true) + defer { try? fileManager.removeItem(at: sourceURL) } + + try fileManager.createDirectory(at: worldURL, withIntermediateDirectories: true) + let lastPlayedMilliseconds: Int64 = 1_695_427_200_000 + let levelDat = makeBedrockLevelDat( + root: .compound([ + "GameType": .int(1), + "Difficulty": .int(1), + "RandomSeed": .long(664_021_225), + "LastPlayed": .long(lastPlayedMilliseconds), + "lastOpenedWithVersion": .list(.int, [.int(1), .int(20), .int(13)]), + "InventoryVersion": .list(.int, [.int(1), .int(20), .int(13)]), + "cheatsEnabled": .byte(1), + "commandsEnabled": .byte(1), + "educationFeaturesEnabled": .byte(1), + "showcoordinates": .byte(1), + "keepinventory": .byte(1), + "mobgriefing": .byte(0), + "dodaylightcycle": .byte(0), + "doweathercycle": .byte(1), + "SpawnX": .int(0), + "SpawnY": .int(32767), + "SpawnZ": .int(0), + "StorageVersion": .int(10), + "NetworkVersion": .int(594) + ]), + storageVersion: 10 + ) + try levelDat.write(to: worldURL.appendingPathComponent("level.dat")) + + let world = MinecraftContentItem( + folderURL: worldURL, + folderName: "WorldA", + contentType: .world, + collectionRootURL: sourceURL.appendingPathComponent("minecraftWorlds", isDirectory: true) + ) + + let enrichedWorld = await WorldScanner.enrich(item: world) + + #expect(enrichedWorld.worldMetadata?.gameMode == "Creative") + #expect(enrichedWorld.worldMetadata?.difficulty == "Easy") + #expect(enrichedWorld.worldMetadata?.seed == "664021225") + #expect(enrichedWorld.lastPlayedDate == Date(timeIntervalSince1970: 1_695_427_200)) + #expect(enrichedWorld.worldMetadata?.lastOpenedWithVersion == "1.20.13") + #expect(enrichedWorld.worldMetadata?.inventoryVersion == "1.20.13") + #expect(enrichedWorld.worldMetadata?.cheatsEnabled == true) + #expect(enrichedWorld.worldMetadata?.commandsEnabled == true) + #expect(enrichedWorld.worldMetadata?.educationFeaturesEnabled == true) + #expect(enrichedWorld.worldMetadata?.coordinatesShown == true) + #expect(enrichedWorld.worldMetadata?.keepInventory == true) + #expect(enrichedWorld.worldMetadata?.mobGriefingEnabled == false) + #expect(enrichedWorld.worldMetadata?.daylightCycleEnabled == false) + #expect(enrichedWorld.worldMetadata?.weatherCycleEnabled == true) + #expect(enrichedWorld.worldMetadata?.spawn == "0, 32767, 0") + #expect(enrichedWorld.worldMetadata?.storageVersion == "10") + #expect(enrichedWorld.worldMetadata?.networkVersion == "594") + } + + @Test func worldScannerReadsPackMinimumEngineVersion() async throws { + let fileManager = FileManager.default + let sourceURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let packURL = sourceURL.appendingPathComponent("behavior_packs/PackA", isDirectory: true) + defer { try? fileManager.removeItem(at: sourceURL) } + + try fileManager.createDirectory(at: packURL, withIntermediateDirectories: true) + let manifest = """ + { + "header": { + "name": "Pack A", + "uuid": "056e5d6e-6135-4daf-844f-5b775b019e56", + "version": [0, 1, 0], + "min_engine_version": [1, 19, 50] + } + } + """ + try manifest.write(to: packURL.appendingPathComponent("manifest.json"), atomically: true, encoding: .utf8) + + let pack = MinecraftContentItem( + folderURL: packURL, + folderName: "PackA", + contentType: .behaviorPack, + collectionRootURL: sourceURL.appendingPathComponent("behavior_packs", isDirectory: true) + ) + + let enrichedPack = await WorldScanner.enrich(item: pack) + + #expect(enrichedPack.displayName == "Pack A") + #expect(enrichedPack.packUUID == "056e5d6e-6135-4daf-844f-5b775b019e56") + #expect(enrichedPack.packVersion == "0.1.0") + #expect(enrichedPack.packMetadataDetails?.minimumEngineVersion == "1.19.50") + } + @Test func sourcePersistenceStoreRoundTripsCachedSource() async throws { let fileManager = FileManager.default let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -219,3 +315,103 @@ struct World_Manager_for_MinecraftTests { } } + +private enum TestNBTTagType: UInt8 { + case end = 0 + case byte = 1 + case short = 2 + case int = 3 + case long = 4 + case string = 8 + case list = 9 + case compound = 10 +} + +private enum TestNBTValue { + case byte(Int8) + case short(Int16) + case int(Int32) + case long(Int64) + case string(String) + case list(TestNBTTagType, [TestNBTValue]) + case compound([String: TestNBTValue]) + + var tagType: TestNBTTagType { + switch self { + case .byte: + return .byte + case .short: + return .short + case .int: + return .int + case .long: + return .long + case .string: + return .string + case .list: + return .list + case .compound: + return .compound + } + } +} + +private func makeBedrockLevelDat(root: TestNBTValue, storageVersion: Int32) -> Data { + var payload = Data() + payload.append(TestNBTTagType.compound.rawValue) + appendLE(UInt16(0), to: &payload) + appendTagPayload(root, to: &payload) + + var data = Data() + appendLE(storageVersion, to: &data) + appendLE(Int32(payload.count), to: &data) + data.append(payload) + return data +} + +private func appendNamedTag(name: String, value: TestNBTValue, to data: inout Data) { + data.append(value.tagType.rawValue) + appendString(name, to: &data) + appendTagPayload(value, to: &data) +} + +private func appendTagPayload(_ value: TestNBTValue, to data: inout Data) { + switch value { + case .byte(let value): + data.append(UInt8(bitPattern: value)) + case .short(let value): + appendLE(value, to: &data) + case .int(let value): + appendLE(value, to: &data) + case .long(let value): + appendLE(value, to: &data) + case .string(let string): + appendString(string, to: &data) + case .list(let itemType, let values): + data.append(itemType.rawValue) + appendLE(Int32(values.count), to: &data) + for value in values { + appendTagPayload(value, to: &data) + } + case .compound(let values): + for key in values.keys.sorted() { + if let value = values[key] { + appendNamedTag(name: key, value: value, to: &data) + } + } + data.append(TestNBTTagType.end.rawValue) + } +} + +private func appendString(_ string: String, to data: inout Data) { + let utf8 = Data(string.utf8) + appendLE(UInt16(utf8.count), to: &data) + data.append(utf8) +} + +private func appendLE(_ value: T, to data: inout Data) { + var value = value.littleEndian + withUnsafeBytes(of: &value) { bytes in + data.append(contentsOf: bytes) + } +}