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/nbt/NbtVirtualFile.kt b/src/main/kotlin/nbt/NbtVirtualFile.kt index e95837dc7..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 @@ -89,6 +90,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 @@ -132,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 -> @@ -147,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/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..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,13 +41,18 @@ 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) - 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) + } } } } diff --git a/src/main/kotlin/region/RegionArchiveHandler.kt b/src/main/kotlin/region/RegionArchiveHandler.kt new file mode 100644 index 000000000..9924308ef --- /dev/null +++ b/src/main/kotlin/region/RegionArchiveHandler.kt @@ -0,0 +1,89 @@ +/* + * 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) + } + } + + 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) + return regionFile[x, z] + } + + override fun getInputStream(relativePath: String): InputStream { + val stream = resolveChunk(relativePath)?.read() + + if (stream == null) { + throw FileNotFoundException("Chunk entry is not initialized") + } else { + return stream + } + } + + 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..af41523fa --- /dev/null +++ b/src/main/kotlin/region/RegionFile.kt @@ -0,0 +1,154 @@ +/* + * 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 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, + 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 + 5) + val compressedPayload = LimitedInputStream(chunkReader, payloadLength - 1) + + return when (payloadCompressionAlgorithm) { + // 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..fc76e8e45 --- /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) + public 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 @@ + + + + +