From 1a57b66fc610139e509b06c7b8e116acce7feb1b Mon Sep 17 00:00:00 2001 From: Edgar Onghena Date: Wed, 9 Apr 2025 14:50:03 -0400 Subject: [PATCH 1/3] Add support for expanding `.mca` region files in the file tree --- .../kotlin/MinecraftTreeStructureProvider.kt | 47 ++++++ .../kotlin/region/RegionArchiveHandler.kt | 85 ++++++++++ src/main/kotlin/region/RegionFile.kt | 152 ++++++++++++++++++ src/main/kotlin/region/RegionFileSystem.kt | 51 ++++++ src/main/kotlin/region/RegionFileType.kt | 36 +++++ src/main/kotlin/region/RegionPsiFileNode.kt | 52 ++++++ src/main/resources/META-INF/plugin.xml | 6 + .../messages/MinecraftDevelopment.properties | 2 + 8 files changed, 431 insertions(+) create mode 100644 src/main/kotlin/MinecraftTreeStructureProvider.kt create mode 100644 src/main/kotlin/region/RegionArchiveHandler.kt create mode 100644 src/main/kotlin/region/RegionFile.kt create mode 100644 src/main/kotlin/region/RegionFileSystem.kt create mode 100644 src/main/kotlin/region/RegionFileType.kt create mode 100644 src/main/kotlin/region/RegionPsiFileNode.kt diff --git a/src/main/kotlin/MinecraftTreeStructureProvider.kt b/src/main/kotlin/MinecraftTreeStructureProvider.kt new file mode 100644 index 000000000..7f4cec770 --- /dev/null +++ b/src/main/kotlin/MinecraftTreeStructureProvider.kt @@ -0,0 +1,47 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2025 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev + +import com.demonwav.mcdev.region.RegionFileType +import com.demonwav.mcdev.region.RegionPsiFileNode +import com.intellij.ide.projectView.TreeStructureProvider +import com.intellij.ide.projectView.ViewSettings +import com.intellij.ide.projectView.impl.nodes.PsiFileNode +import com.intellij.ide.util.treeView.AbstractTreeNode + +private fun mapMcaNode(node: AbstractTreeNode<*>): AbstractTreeNode<*> { + if (node is PsiFileNode) { + val value = node.value + if (value?.fileType is RegionFileType) { + return RegionPsiFileNode(node.project, value, node.settings) + } + } + + return node +} + +class MinecraftTreeStructureProvider : TreeStructureProvider { + override fun modify( + parent: AbstractTreeNode<*>, + children: MutableCollection>, + settings: ViewSettings? + ) = children.mapTo(ArrayList(children.size), ::mapMcaNode) +} diff --git a/src/main/kotlin/region/RegionArchiveHandler.kt b/src/main/kotlin/region/RegionArchiveHandler.kt new file mode 100644 index 000000000..142a469a0 --- /dev/null +++ b/src/main/kotlin/region/RegionArchiveHandler.kt @@ -0,0 +1,85 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2025 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.region + +import com.intellij.openapi.vfs.impl.ArchiveHandler +import java.io.FileNotFoundException +import java.io.InputStream + +private val COORDINATE_FILE_NAME_REGEX = Regex("""^[a-z]\.(?-?\d+)\.(?-?\d+)\.\w+${'$'}""") + +/** + * Parses the coordinates of a region/chunk file (e.g. `r.1.-1.mca`), if representable by Int + */ +private fun parseFileNameCoordinates(fileName: CharSequence): Pair? = COORDINATE_FILE_NAME_REGEX + .matchAt(fileName, 0) + ?.let { matchResult -> + val (x, z) = matchResult.destructured + return try { + Pair(x.toInt(), z.toInt()) + } catch (e: NumberFormatException) { + // This may happen if the file name contains a number that's too big for an Int + null + } + } + +class RegionArchiveHandler(path: String) : ArchiveHandler(path) { + private val regionFile = RegionFile(file) + + // Absolute region coordinates. May be null if the file has a nonstandard name. + private val regionXZ = parseFileNameCoordinates(path.substringAfterLast('/')) + + override fun createEntriesMap() = mutableMapOf().apply { + val root = createRootEntry() + this[""] = root + + for (chunk in regionFile) { + val name = if (regionXZ != null) { + val x = chunk.x + regionXZ.first * 32 + val z = chunk.z + regionXZ.second * 32 + "c.$x.$z.nbt" + } else { + val x = chunk.x + val z = chunk.z + "c.~$x.~$z.nbt" + } + + this[name] = EntryInfo(name, false, chunk.payloadLength.toLong(), chunk.timestamp, root) + } + } + + override fun getInputStream(relativePath: String): InputStream { + var (x, z) = parseFileNameCoordinates(relativePath.substringAfterLast('/')) + ?: throw FileNotFoundException("Illegal name for region file entry: $relativePath") + + x = x.mod(32) + z = z.mod(32) + val entry = regionFile[x, z]?.read() + + if (entry == null) { + throw FileNotFoundException("Chunk entry is not initialized") + } else { + return entry + } + } + + override fun contentsToByteArray(relativePath: String) = getInputStream(relativePath).readBytes() +} diff --git a/src/main/kotlin/region/RegionFile.kt b/src/main/kotlin/region/RegionFile.kt new file mode 100644 index 000000000..67307e049 --- /dev/null +++ b/src/main/kotlin/region/RegionFile.kt @@ -0,0 +1,152 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2025 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.region + +import com.intellij.util.io.LimitedInputStream +import java.io.* +import java.nio.ByteBuffer +import java.util.zip.GZIPInputStream +import java.util.zip.Inflater +import java.util.zip.InflaterInputStream +import net.jpountz.lz4.LZ4BlockInputStream + +private const val SECTOR_SIZE = 4096 +private const val CHUNKS_PER_REGION = 32 * 32 + +private fun xzToIndex(x: Int, z: Int) = x + z * 32 +private fun indexToXz(index: Int) = (index.mod(32) to index.div(32)) + +/** + * Helper class to read anvil region files (https://minecraft.wiki/w/Region_file_format) + */ +class RegionFile(private val filePath: File) : AutoCloseable { + private val file = RandomAccessFile(filePath, "r") + private val totalSectorCount = file.length() / SECTOR_SIZE + private val chunkIndex = arrayOfNulls(CHUNKS_PER_REGION).apply { + if (totalSectorCount < 2) { + // Invalid format + return@apply + } + + val firstTwoSectors = ByteBuffer + .wrap(ByteArray(2 * SECTOR_SIZE).apply { + file.readFully(this) + }) + .asIntBuffer() + + for (i in 0 until CHUNKS_PER_REGION) { + val offsetAndSize = firstTwoSectors.get(i).toUInt() + val entry = ChunkIndexEntry.decode(offsetAndSize, firstTwoSectors.get(CHUNKS_PER_REGION + i)) + if (entry.sectorOffset == 0 || entry.sectorCount == 0) { + continue + } + + this[i] = entry + } + } + + operator fun get(relativeChunkX: Int, relativeChunkZ: Int): Chunk? { + if (!(relativeChunkX in 0..32 && relativeChunkZ in 0..32)) { + throw IndexOutOfBoundsException("Chunk coordinates ($relativeChunkX, $relativeChunkZ) out of bounds for region file") + } + + return chunkIndex[xzToIndex(relativeChunkX, relativeChunkZ)]?.let { entry -> + Chunk( + relativeChunkX, + relativeChunkZ, + entry + ) + } + } + + operator fun iterator(): Iterator = chunkIndex + .asSequence() + .mapIndexed { idx, e -> indexToXz(idx) to e } + .filter { it.second != null } + .map { (xz, e) -> Chunk(xz.first, xz.second, e!!) } + .iterator() + + override fun close() { + file.close() + } + + internal data class ChunkIndexEntry(val sectorOffset: Int, val sectorCount: Int, val timestamp: Int) { + companion object { + fun decode(offsetAndSize: UInt, timestamp: Int): ChunkIndexEntry { + val sectorOffset = offsetAndSize.shr(8).toInt() + val sectorSize = offsetAndSize.and(0b11111111u).toInt() + return ChunkIndexEntry(sectorOffset, sectorSize, timestamp) + } + } + } + + inner class Chunk( + val x: Int, + val z: Int, + val timestamp: Long, + private val sectorOffset: Int, + private val sectorCount: Int, + ) { + private val firstByte get() = (sectorOffset.toLong() * SECTOR_SIZE.toLong()) + val payloadLength: Int + get() { + file.seek(firstByte) + return file.readInt() + } + + internal constructor(x: Int, z: Int, chunkIndexEntry: ChunkIndexEntry) : this( + x, + z, + chunkIndexEntry.timestamp.toLong(), + chunkIndexEntry.sectorOffset, + chunkIndexEntry.sectorCount, + ) + + fun read(): InputStream? { + val payloadLength = payloadLength + if (payloadLength > sectorCount * SECTOR_SIZE || (sectorOffset + sectorCount) > totalSectorCount) { + return null + } + + if (payloadLength == 0) { + return InputStream.nullInputStream() + } + + val chunkReader = BufferedInputStream(FileInputStream(filePath)) + chunkReader.skip(firstByte + 4) + val payloadCompression = chunkReader.readNBytes(1)[0] + val compressedPayload = LimitedInputStream(chunkReader, payloadLength - 1) + + return when (payloadCompression.toInt()) { + // GZip + 1 -> GZIPInputStream(compressedPayload) + // ZLib + 2 -> InflaterInputStream(compressedPayload, Inflater()) + // Uncompressed + 3 -> compressedPayload + // LZ4 + 4 -> LZ4BlockInputStream(compressedPayload) + // Custom and/or yet-to-exist algorithm + else -> null + } + } + } +} diff --git a/src/main/kotlin/region/RegionFileSystem.kt b/src/main/kotlin/region/RegionFileSystem.kt new file mode 100644 index 000000000..6f034fd68 --- /dev/null +++ b/src/main/kotlin/region/RegionFileSystem.kt @@ -0,0 +1,51 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2025 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.region + +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.openapi.vfs.newvfs.ArchiveFileSystem +import com.intellij.openapi.vfs.newvfs.VfsImplUtil +import com.intellij.util.io.URLUtil + +private const val PROTOCOL = "mcdev-region" + +class RegionFileSystem : ArchiveFileSystem() { + @Suppress("CompanionObjectInExtension") // False-positive: this is a getter, not a field + companion object { + @JvmStatic + val INSTANCE get() = VirtualFileManager.getInstance().getFileSystem(PROTOCOL) as RegionFileSystem + } + + override fun getProtocol() = PROTOCOL + override fun isReadOnly() = true + override fun isCorrectFileType(local: VirtualFile) = local.fileType is RegionFileType + + override fun extractRootPath(path: String) = extractLocalPath(path) + URLUtil.JAR_SEPARATOR + override fun extractLocalPath(archivePath: String) = archivePath.substringBeforeLast(URLUtil.JAR_SEPARATOR) + override fun composeRootPath(localPath: String) = "$localPath${URLUtil.JAR_SEPARATOR}" + + override fun findFileByPath(path: String): VirtualFile? = VfsImplUtil.findFileByPath(this, path) + override fun refresh(asynchronous: Boolean) = VfsImplUtil.refresh(this, asynchronous) + override fun refreshAndFindFileByPath(path: String) = VfsImplUtil.refreshAndFindFileByPath(this, path) + override fun findFileByPathIfCached(path: String) = VfsImplUtil.findFileByPathIfCached(this, path) + override fun getHandler(entryFile: VirtualFile) = VfsImplUtil.getHandler(this, entryFile, ::RegionArchiveHandler) +} diff --git a/src/main/kotlin/region/RegionFileType.kt b/src/main/kotlin/region/RegionFileType.kt new file mode 100644 index 000000000..c2d3b8ddb --- /dev/null +++ b/src/main/kotlin/region/RegionFileType.kt @@ -0,0 +1,36 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2025 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.region + +import com.demonwav.mcdev.asset.MCDevBundle +import com.intellij.icons.AllIcons +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.vfs.VirtualFile + +object RegionFileType : FileType { + override fun getDefaultExtension() = "mca" + override fun getIcon() = AllIcons.FileTypes.Archive + override fun getCharset(file: VirtualFile, content: ByteArray) = null + override fun getName() = "MCA" + override fun getDescription() = MCDevBundle("region.file_type.description") + override fun isBinary() = true + override fun isReadOnly() = true +} diff --git a/src/main/kotlin/region/RegionPsiFileNode.kt b/src/main/kotlin/region/RegionPsiFileNode.kt new file mode 100644 index 000000000..67db5f5cc --- /dev/null +++ b/src/main/kotlin/region/RegionPsiFileNode.kt @@ -0,0 +1,52 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2025 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.region + +import com.intellij.ide.projectView.ViewSettings +import com.intellij.ide.projectView.impl.nodes.PsiFileNode +import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.openapi.project.Project +import com.intellij.psi.* +import com.intellij.util.containers.ContainerUtil + +class RegionPsiFileNode( + project: Project?, + value: PsiFile, + viewSettings: ViewSettings?, +) : PsiFileNode(project, value, viewSettings) { + override fun getChildrenImpl(): MutableCollection> { + val rootDirectory = virtualFile?.let { RegionFileSystem.INSTANCE.getRootByLocal(it) } + val project = project + if (project != null && rootDirectory != null) { + val psiRootDirectory = PsiManager.getInstance(project).findDirectory(rootDirectory) + if (psiRootDirectory != null) { + return psiRootDirectory + .children + .asSequence() + .mapNotNull { it as? PsiFile } + .map { PsiFileNode(it.project, it, settings) } + .toMutableList() + } + } + + return ContainerUtil.emptyList() + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index febf4e3fc..ba36bcbbd 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -339,6 +339,7 @@ + @@ -412,6 +413,11 @@ + + + + + Date: Wed, 9 Apr 2025 14:57:20 -0400 Subject: [PATCH 2/3] Make the NBT editor properly handle read-only files --- src/main/kotlin/nbt/NbtVirtualFile.kt | 4 ++++ src/main/kotlin/nbt/editor/NbtFileEditorProvider.kt | 2 +- src/main/kotlin/nbt/editor/NbtToolbar.kt | 11 +++++++---- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/nbt/NbtVirtualFile.kt b/src/main/kotlin/nbt/NbtVirtualFile.kt index e95837dc7..b318fbc27 100644 --- a/src/main/kotlin/nbt/NbtVirtualFile.kt +++ b/src/main/kotlin/nbt/NbtVirtualFile.kt @@ -89,6 +89,10 @@ class NbtVirtualFile( override fun isTooLargeForIntelligence() = ThreeState.NO fun writeFile(requester: Any) { + if (!isWritable) { + throw IllegalStateException("Backing file is not writable") + } + runReadActionAsync { val nbttFile = PsiManager.getInstance(project).findFile(this) as? NbttFile diff --git a/src/main/kotlin/nbt/editor/NbtFileEditorProvider.kt b/src/main/kotlin/nbt/editor/NbtFileEditorProvider.kt index a663a1dd6..3d5c4b826 100644 --- a/src/main/kotlin/nbt/editor/NbtFileEditorProvider.kt +++ b/src/main/kotlin/nbt/editor/NbtFileEditorProvider.kt @@ -97,7 +97,7 @@ private class NbtFileEditor( AnActionListener.TOPIC, object : AnActionListener { override fun afterActionPerformed(action: AnAction, event: AnActionEvent, result: AnActionResult) { - if (action !is SaveAllAction) { + if (action !is SaveAllAction || !file.isWritable) { return } diff --git a/src/main/kotlin/nbt/editor/NbtToolbar.kt b/src/main/kotlin/nbt/editor/NbtToolbar.kt index 0c4a9568e..ecea808e6 100644 --- a/src/main/kotlin/nbt/editor/NbtToolbar.kt +++ b/src/main/kotlin/nbt/editor/NbtToolbar.kt @@ -44,10 +44,13 @@ class NbtToolbar(nbtFile: NbtVirtualFile) { comboBox(EnumComboBoxModel(CompressionSelection::class.java)) .bindItem(::compressionSelection) .enabled(nbtFile.isWritable && nbtFile.parseSuccessful) - button(MCDevBundle("nbt.compression.save.button")) { - panel.apply() - runWriteTaskLater { - nbtFile.writeFile(this) + + if (nbtFile.isWritable) { + button(MCDevBundle("nbt.compression.save.button")) { + panel.apply() + runWriteTaskLater { + nbtFile.writeFile(this) + } } } } From 2db581910430e927de6f47b68eb4d1c616db6801 Mon Sep 17 00:00:00 2001 From: Edgar Onghena Date: Wed, 9 Apr 2025 16:35:08 -0400 Subject: [PATCH 3/3] Show the correct compression algorithm when viewing a chunk from a region file --- src/main/kotlin/nbt/NbtVirtualFile.kt | 20 +++++++++++++++++++ .../nbt/editor/CompressionComboBoxModel.kt | 12 +++++++++++ .../kotlin/nbt/editor/CompressionSelection.kt | 4 +++- src/main/kotlin/nbt/editor/NbtToolbar.kt | 8 +++++--- .../kotlin/region/RegionArchiveHandler.kt | 12 +++++++---- src/main/kotlin/region/RegionFile.kt | 18 +++++++++-------- src/main/kotlin/region/RegionFileSystem.kt | 2 +- .../messages/MinecraftDevelopment.properties | 2 ++ 8 files changed, 61 insertions(+), 17 deletions(-) create mode 100644 src/main/kotlin/nbt/editor/CompressionComboBoxModel.kt diff --git a/src/main/kotlin/nbt/NbtVirtualFile.kt b/src/main/kotlin/nbt/NbtVirtualFile.kt index b318fbc27..d3ec6a907 100644 --- a/src/main/kotlin/nbt/NbtVirtualFile.kt +++ b/src/main/kotlin/nbt/NbtVirtualFile.kt @@ -25,6 +25,7 @@ import com.demonwav.mcdev.nbt.editor.CompressionSelection import com.demonwav.mcdev.nbt.editor.NbtToolbar import com.demonwav.mcdev.nbt.lang.NbttFile import com.demonwav.mcdev.nbt.lang.NbttLanguage +import com.demonwav.mcdev.region.RegionFileSystem import com.demonwav.mcdev.util.loggerForTopLevel import com.demonwav.mcdev.util.runReadActionAsync import com.demonwav.mcdev.util.runWriteTaskLater @@ -136,6 +137,7 @@ class NbtVirtualFile( val filteredStream = when (toolbar.selection) { CompressionSelection.GZIP -> GZIPOutputStream(this.parent.getOutputStream(requester)) CompressionSelection.UNCOMPRESSED -> this.parent.getOutputStream(requester) + else -> throw NotImplementedError("Region-only compression algorithms are not supported for standalone NBT files") } DataOutputStream(filteredStream).use { stream -> @@ -151,4 +153,22 @@ class NbtVirtualFile( } } } + + // If the NBT file is part of a region file, this will be non-null and represent the file's compression algorithm + val compressionInRegionFile: CompressionSelection? by lazy { + val compressionAlgorithm = (backingFile.fileSystem as? RegionFileSystem) + ?.getHandler(backingFile) + ?.resolveChunk(backingFile.name) + ?.payloadCompressionAlgorithm + + when (compressionAlgorithm) { + null -> null + 1 -> CompressionSelection.GZIP + 2 -> CompressionSelection.ZLIB + 3 -> CompressionSelection.UNCOMPRESSED + 4 -> CompressionSelection.LZ4 + // We shouldn't be able to open NBT files if the compression algorithm is unsupported anyway + else -> CompressionSelection.UNCOMPRESSED + } + } } diff --git a/src/main/kotlin/nbt/editor/CompressionComboBoxModel.kt b/src/main/kotlin/nbt/editor/CompressionComboBoxModel.kt new file mode 100644 index 000000000..07edf1a3a --- /dev/null +++ b/src/main/kotlin/nbt/editor/CompressionComboBoxModel.kt @@ -0,0 +1,12 @@ +package com.demonwav.mcdev.nbt.editor + +import com.intellij.ui.CollectionComboBoxModel + +private fun makeItems(isInRegionFile: Boolean) = if (isInRegionFile) { + CompressionSelection.entries.toList() +} else { + CompressionSelection.entries.asSequence().filter { !it.regionFileOnly }.toList() +} + +class CompressionComboBoxModel(isInRegionFile: Boolean) : + CollectionComboBoxModel(makeItems(isInRegionFile)) diff --git a/src/main/kotlin/nbt/editor/CompressionSelection.kt b/src/main/kotlin/nbt/editor/CompressionSelection.kt index 5945f0db4..ed0cd02b5 100644 --- a/src/main/kotlin/nbt/editor/CompressionSelection.kt +++ b/src/main/kotlin/nbt/editor/CompressionSelection.kt @@ -22,9 +22,11 @@ package com.demonwav.mcdev.nbt.editor import com.demonwav.mcdev.asset.MCDevBundle -enum class CompressionSelection(private val selectionNameFunc: () -> String) { +enum class CompressionSelection(private val selectionNameFunc: () -> String, val regionFileOnly: Boolean = false) { GZIP({ MCDevBundle("nbt.compression.gzip") }), UNCOMPRESSED({ MCDevBundle("nbt.compression.uncompressed") }), + ZLIB({ MCDevBundle("nbt.compression.zlib") }, regionFileOnly = true), + LZ4({ MCDevBundle("nbt.compression.lz4") }, regionFileOnly = true), ; override fun toString(): String = selectionNameFunc() diff --git a/src/main/kotlin/nbt/editor/NbtToolbar.kt b/src/main/kotlin/nbt/editor/NbtToolbar.kt index ecea808e6..7ddb39051 100644 --- a/src/main/kotlin/nbt/editor/NbtToolbar.kt +++ b/src/main/kotlin/nbt/editor/NbtToolbar.kt @@ -24,14 +24,14 @@ import com.demonwav.mcdev.asset.MCDevBundle import com.demonwav.mcdev.nbt.NbtVirtualFile import com.demonwav.mcdev.util.runWriteTaskLater import com.intellij.openapi.ui.DialogPanel -import com.intellij.ui.EnumComboBoxModel import com.intellij.ui.dsl.builder.bindItem import com.intellij.ui.dsl.builder.panel class NbtToolbar(nbtFile: NbtVirtualFile) { private var compressionSelection: CompressionSelection? = - if (nbtFile.isCompressed) CompressionSelection.GZIP else CompressionSelection.UNCOMPRESSED + nbtFile.compressionInRegionFile + ?: if (nbtFile.isCompressed) CompressionSelection.GZIP else CompressionSelection.UNCOMPRESSED val selection: CompressionSelection get() = compressionSelection!! @@ -41,7 +41,9 @@ class NbtToolbar(nbtFile: NbtVirtualFile) { init { panel = panel { row(MCDevBundle("nbt.compression.file_type.label")) { - comboBox(EnumComboBoxModel(CompressionSelection::class.java)) + val isInRegionFile = nbtFile.compressionInRegionFile != null + + comboBox(CompressionComboBoxModel(isInRegionFile)) .bindItem(::compressionSelection) .enabled(nbtFile.isWritable && nbtFile.parseSuccessful) diff --git a/src/main/kotlin/region/RegionArchiveHandler.kt b/src/main/kotlin/region/RegionArchiveHandler.kt index 142a469a0..9924308ef 100644 --- a/src/main/kotlin/region/RegionArchiveHandler.kt +++ b/src/main/kotlin/region/RegionArchiveHandler.kt @@ -66,18 +66,22 @@ class RegionArchiveHandler(path: String) : ArchiveHandler(path) { } } - override fun getInputStream(relativePath: String): InputStream { + fun resolveChunk(relativePath: String): RegionFile.Chunk? { var (x, z) = parseFileNameCoordinates(relativePath.substringAfterLast('/')) ?: throw FileNotFoundException("Illegal name for region file entry: $relativePath") x = x.mod(32) z = z.mod(32) - val entry = regionFile[x, z]?.read() + return regionFile[x, z] + } + + override fun getInputStream(relativePath: String): InputStream { + val stream = resolveChunk(relativePath)?.read() - if (entry == null) { + if (stream == null) { throw FileNotFoundException("Chunk entry is not initialized") } else { - return entry + return stream } } diff --git a/src/main/kotlin/region/RegionFile.kt b/src/main/kotlin/region/RegionFile.kt index 67307e049..af41523fa 100644 --- a/src/main/kotlin/region/RegionFile.kt +++ b/src/main/kotlin/region/RegionFile.kt @@ -106,11 +106,14 @@ class RegionFile(private val filePath: File) : AutoCloseable { private val sectorCount: Int, ) { private val firstByte get() = (sectorOffset.toLong() * SECTOR_SIZE.toLong()) - val payloadLength: Int - get() { - file.seek(firstByte) - return file.readInt() - } + val payloadLength by lazy { + file.seek(firstByte) + file.readInt() + } + val payloadCompressionAlgorithm by lazy { + file.seek(firstByte + 4) + file.readByte().toInt() + } internal constructor(x: Int, z: Int, chunkIndexEntry: ChunkIndexEntry) : this( x, @@ -131,11 +134,10 @@ class RegionFile(private val filePath: File) : AutoCloseable { } val chunkReader = BufferedInputStream(FileInputStream(filePath)) - chunkReader.skip(firstByte + 4) - val payloadCompression = chunkReader.readNBytes(1)[0] + chunkReader.skip(firstByte + 5) val compressedPayload = LimitedInputStream(chunkReader, payloadLength - 1) - return when (payloadCompression.toInt()) { + return when (payloadCompressionAlgorithm) { // GZip 1 -> GZIPInputStream(compressedPayload) // ZLib diff --git a/src/main/kotlin/region/RegionFileSystem.kt b/src/main/kotlin/region/RegionFileSystem.kt index 6f034fd68..fc76e8e45 100644 --- a/src/main/kotlin/region/RegionFileSystem.kt +++ b/src/main/kotlin/region/RegionFileSystem.kt @@ -47,5 +47,5 @@ class RegionFileSystem : ArchiveFileSystem() { override fun refresh(asynchronous: Boolean) = VfsImplUtil.refresh(this, asynchronous) override fun refreshAndFindFileByPath(path: String) = VfsImplUtil.refreshAndFindFileByPath(this, path) override fun findFileByPathIfCached(path: String) = VfsImplUtil.findFileByPathIfCached(this, path) - override fun getHandler(entryFile: VirtualFile) = VfsImplUtil.getHandler(this, entryFile, ::RegionArchiveHandler) + public override fun getHandler(entryFile: VirtualFile) = VfsImplUtil.getHandler(this, entryFile, ::RegionArchiveHandler) } diff --git a/src/main/resources/messages/MinecraftDevelopment.properties b/src/main/resources/messages/MinecraftDevelopment.properties index f530d6171..6f9047e6c 100644 --- a/src/main/resources/messages/MinecraftDevelopment.properties +++ b/src/main/resources/messages/MinecraftDevelopment.properties @@ -201,6 +201,8 @@ inspection.entity_data_param.fix=Replace other entity class with this entity cla nbt.compression.gzip=GZipped nbt.compression.uncompressed=Uncompressed +nbt.compression.zlib=ZLib +nbt.compression.lz4=LZ4 (block format) nbt.compression.file_type.label=Compression: nbt.compression.save.button=Save