More tweaks for UI and behavior

This commit is contained in:
John Burwell 2026-05-26 11:54:38 -05:00
parent cf872d5fd3
commit 2886d14178
5 changed files with 681 additions and 11 deletions

View File

@ -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)

View File

@ -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 }

View File

@ -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..<payloadEnd)
var decoder = BedrockNBTDecoder(data: payload)
guard let root = try? decoder.decodeRootCompound() else {
return nil
}
return worldMetadata(from: root)
}
nonisolated private static func worldMetadata(from root: [String: NBTValue]) -> 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..<count).map { _ in try readInt8() })
case 8:
return .string(try readString())
case 9:
let listType = try readUInt8()
let count = try readCount()
let values = try (0..<count).map { _ in try readTagPayload(type: listType) }
return .list(listType, values)
case 10:
return .compound(try readCompoundPayload())
case 11:
let count = try readCount()
return .intArray(try (0..<count).map { _ in try readInt32() })
case 12:
let count = try readCount()
return .longArray(try (0..<count).map { _ in try readInt64() })
default:
throw BedrockNBTError.unsupportedTag(type)
}
}
nonisolated private mutating func readCount() throws -> 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<T: FixedWidthInteger>(_ type: T.Type) throws -> T {
let byteCount = MemoryLayout<T>.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)
}

View File

@ -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 {

View File

@ -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<T: FixedWidthInteger>(_ value: T, to data: inout Data) {
var value = value.littleEndian
withUnsafeBytes(of: &value) { bytes in
data.append(contentsOf: bytes)
}
}