Skip to content

Feature: region files support #2457

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/main/kotlin/MinecraftTreeStructureProvider.kt
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

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<AbstractTreeNode<*>>,
settings: ViewSettings?
) = children.mapTo(ArrayList(children.size), ::mapMcaNode)
}
24 changes: 24 additions & 0 deletions src/main/kotlin/nbt/NbtVirtualFile.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 ->
Expand All @@ -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
}
}
}
12 changes: 12 additions & 0 deletions src/main/kotlin/nbt/editor/CompressionComboBoxModel.kt
Original file line number Diff line number Diff line change
@@ -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<CompressionSelection?>(makeItems(isInRegionFile))
4 changes: 3 additions & 1 deletion src/main/kotlin/nbt/editor/CompressionSelection.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion src/main/kotlin/nbt/editor/NbtFileEditorProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
19 changes: 12 additions & 7 deletions src/main/kotlin/nbt/editor/NbtToolbar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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!!
Expand All @@ -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)
}
}
}
}
Expand Down
89 changes: 89 additions & 0 deletions src/main/kotlin/region/RegionArchiveHandler.kt
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

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]\.(?<x>-?\d+)\.(?<z>-?\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<Int, Int>? = 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<String, EntryInfo>().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()
}
Loading