Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
0d6a092
feat: 添加seed和生成建筑获取
Mine-diamond Feb 4, 2026
3257a92
feat: 添加micanbt库
Mine-diamond Feb 4, 2026
ecdb8bc
feat: 重构部分tag获取逻辑
Mine-diamond Feb 4, 2026
fddaa42
feat: 重构难度/难度锁定获取逻辑
Mine-diamond Feb 4, 2026
7bc1d25
feat: 重构playerTag的获取方式
Mine-diamond Feb 4, 2026
96066e8
feat: 添加writeWorldDat()方法
Mine-diamond Feb 4, 2026
8968c67
feat: 重构游戏难度的获取方式
Mine-diamond Feb 4, 2026
f5a0eef
feat: 优化代码
Mine-diamond Feb 4, 2026
cccca62
feat: 优化代码
Mine-diamond Feb 4, 2026
a64708b
feat: 优化代码
Mine-diamond Feb 5, 2026
6afed3e
feat: jump to micanbt version 0.3.1
Mine-diamond Feb 6, 2026
85fd2ad
feat: 添加新版本放大化世界获取支持
Mine-diamond Feb 6, 2026
b49f239
feat: 添加注释
Mine-diamond Feb 6, 2026
3c8e445
feat: 添加版权信息
Mine-diamond Feb 6, 2026
c7fb88a
feat: 优化实现
Mine-diamond Feb 6, 2026
8c8d231
feat: 优化实现
Mine-diamond Feb 6, 2026
39c84cf
fix: 修复bug
Mine-diamond Feb 6, 2026
d058eda
feat: 优化注释
Mine-diamond Feb 6, 2026
2084b87
feat: 优化实现,优化注释
Mine-diamond Feb 7, 2026
8dba584
Merge branch 'main' into new-level-dat-format
Mine-diamond Feb 7, 2026
c44b680
fix: 修复命名错误
Mine-diamond Feb 7, 2026
b95c078
fix: 修复bug
Mine-diamond Feb 7, 2026
192437e
feat: 更改依赖顺序
Mine-diamond Feb 7, 2026
d7ba7aa
feat: 优化实现
Mine-diamond Feb 9, 2026
81c3e9f
feat: 移动关于位置
Mine-diamond Feb 9, 2026
2ef363e
Merge branch 'main' into new-level-dat-format
Mine-diamond Feb 9, 2026
84145bf
feat: 优化实现
Mine-diamond Feb 9, 2026
6fda6dc
feat: 更新micanbt支持
Mine-diamond Feb 26, 2026
470070c
feat: 使用 atAny 简化代码
Mine-diamond Feb 26, 2026
fc893bf
feat: 优化代码
Mine-diamond Feb 26, 2026
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
206 changes: 120 additions & 86 deletions HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public WorldManagePage(World world, Profile profile, String instanceId) {
updateSessionLockChannel();

try {
this.world.reloadLevelDat();
this.world.reloadWorldData();
} catch (IOException e) {
LOG.warning("Can not load world level.dat of world: " + this.world.getFile(), e);
this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, event -> closePageForLoadingFail());
Expand Down Expand Up @@ -106,7 +106,7 @@ public WorldManagePage(World world, Profile profile, String instanceId) {
public void refresh() {
updateSessionLockChannel();
try {
world.reloadLevelDat();
world.reloadWorldData();
} catch (IOException e) {
LOG.warning("Can not load world level.dat of world: " + world.getFile(), e);
closePageForLoadingFail();
Expand Down
5 changes: 5 additions & 0 deletions HMCL/src/main/resources/assets/about/deps.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@
"subtitle" : "Copyright © 2013-2021 Steveice10.\nLicensed under the MIT License.",
"externalLink" : "https://github.com/GeyserMC/OpenNBT"
},
{
"title" : "MicaNBT",
"subtitle" : "Copyright © 2013-2021 Steveice10, 2026 Mine-diamond.\nLicensed under the MIT License.",
"externalLink" : "https://github.com/Mine-diamond/MicaNBT"
},
{
"title" : "minecraft-jfx-skin",
"subtitle" : "Copyright © 2016 InfinityStudio.\nLicensed under the GPL 3.",
Expand Down
1 change: 1 addition & 0 deletions HMCLCore/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ dependencies {
api(libs.chardet)
api(libs.jna)
api(libs.pci.ids)
api(libs.micanbt)

compileOnlyApi(libs.jetbrains.annotations)

Expand Down
182 changes: 115 additions & 67 deletions HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,36 +17,42 @@
*/
package org.jackhuang.hmcl.game;

import com.github.steveice10.opennbt.NBTIO;
import com.github.steveice10.opennbt.tag.builtin.*;
import javafx.scene.image.Image;
import org.jackhuang.hmcl.util.io.*;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.Nullable;
import tech.minediamond.micanbt.NBT.NBT;
import tech.minediamond.micanbt.NBT.NBTCompressType;
import tech.minediamond.micanbt.path.NBTFinder;
import tech.minediamond.micanbt.tag.*;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.List;
import java.util.UUID;
import java.util.stream.Stream;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;

import static org.jackhuang.hmcl.util.logging.Logger.LOG;

public final class World {

private final Path file;
private String fileName;
private CompoundTag levelData;
private Image icon;

private CompoundTag levelData;
private Path levelDataPath;
private CompoundTag worldGenSettingsData;
private CompoundTag unifiedWorldGenSettingsData;
private Path worldGenSettingsDataPath;
private CompoundTag playerData;
private Path playerDataPath;

public World(Path file) throws IOException {
this.file = file;
Expand All @@ -68,22 +74,16 @@ public String getFileName() {
}

public String getWorldName() {
CompoundTag data = levelData.get("Data");
StringTag levelNameTag = data.get("LevelName");
return levelNameTag.getValue();
return ((StringTag) levelData.at("Data.LevelName")).getClonedValue();
}

public void setWorldName(String worldName) throws IOException {
if (levelData.get("Data") instanceof CompoundTag data && data.get("LevelName") instanceof StringTag levelNameTag) {
if (levelData.at("Data.LevelName") instanceof StringTag levelNameTag) {
levelNameTag.setValue(worldName);
writeLevelDat(levelData);
writeLevelData();
}
}

public Path getLevelDatFile() {
return file.resolve("level.dat");
}

public Path getSessionLockFile() {
return file.resolve("session.lock");
}
Expand All @@ -92,49 +92,52 @@ public CompoundTag getLevelData() {
return levelData;
}

public @Nullable CompoundTag getUnifiedWorldGenSettingsData() {
return unifiedWorldGenSettingsData;
}

public @Nullable CompoundTag getPlayerData() {
return playerData;
}

public long getLastPlayed() {
CompoundTag data = levelData.get("Data");
LongTag lastPlayedTag = data.get("LastPlayed");
return lastPlayedTag.getValue();
return ((LongTag) levelData.at("Data.LastPlayed")).getClonedValue();
}

public @Nullable GameVersionNumber getGameVersion() {
if (levelData.get("Data") instanceof CompoundTag data &&
data.get("Version") instanceof CompoundTag versionTag &&
versionTag.get("Name") instanceof StringTag nameTag) {
return GameVersionNumber.asGameVersion(nameTag.getValue());
if (levelData.at("Data.Version.Name") instanceof StringTag nameTag) {
return GameVersionNumber.asGameVersion(nameTag.getClonedValue());
}
return null;
}

public @Nullable Long getSeed() {
CompoundTag data = levelData.get("Data");
if (data.get("WorldGenSettings") instanceof CompoundTag worldGenSettingsTag && worldGenSettingsTag.get("seed") instanceof LongTag seedTag) { //Valid after 1.16
return seedTag.getValue();
} else if (data.get("RandomSeed") instanceof LongTag seedTag) { //Valid before 1.16
return seedTag.getValue();
// Valid after 1.16(20w20a)
if (NBTFinder.get(unifiedWorldGenSettingsData, "seed") instanceof LongTag seedTag) {
return seedTag.getClonedValue();
}
// Valid before 1.16(20w20a)
else if (levelData.at("Data.RandomSeed") instanceof LongTag seedTag) {
return seedTag.getClonedValue();
}
return null;
}

public boolean isLargeBiomes() {
CompoundTag data = levelData.get("Data");
if (data.get("generatorName") instanceof StringTag generatorNameTag) { //Valid before 1.16
return "largeBiomes".equals(generatorNameTag.getValue());
} else {
if (data.get("WorldGenSettings") instanceof CompoundTag worldGenSettingsTag
&& worldGenSettingsTag.get("dimensions") instanceof CompoundTag dimensionsTag
&& dimensionsTag.get("minecraft:overworld") instanceof CompoundTag overworldTag
&& overworldTag.get("generator") instanceof CompoundTag generatorTag) {
if (generatorTag.get("biome_source") instanceof CompoundTag biomeSourceTag
&& biomeSourceTag.get("large_biomes") instanceof ByteTag largeBiomesTag) { //Valid between 1.16 and 1.16.2
return largeBiomesTag.getValue() == (byte) 1;
} else if (generatorTag.get("settings") instanceof StringTag settingsTag) { //Valid after 1.16.2
return "minecraft:large_biomes".equals(settingsTag.getValue());
}
}
return false;
// Valid before 1.16(20w20a)
if (levelData.at("Data.generatorName") instanceof StringTag generatorNameTag) {
return "largeBiomes".equals(generatorNameTag.getClonedValue());
}
// Valid between 1.16(20w20a) and 1.18(21w37a)
else if (NBTFinder.get(unifiedWorldGenSettingsData, "dimensions.minecraft:overworld.generator.biome_source.large_biomes") instanceof ByteTag largeBiomesTag) {
return largeBiomesTag.getClonedValue() == (byte) 1;
}
// Valid after 1.18(21w37a)
// Note: In versions 1.16(20w20a) and 1.18(21w37a), the settings tag exists but does not indicate large biomes information
else if (NBTFinder.get(unifiedWorldGenSettingsData, "dimensions.minecraft:overworld.generator.settings") instanceof StringTag settingsTag) {
return "minecraft:large_biomes".equals(settingsTag.getClonedValue());
}
return false;
}

public Image getIcon() {
Expand Down Expand Up @@ -166,8 +169,8 @@ private void loadFromDirectory() throws IOException {
if (!Files.exists(levelDat)) {
throw new IOException("Not a valid world directory since level.dat or special_level.dat cannot be found.");
}
loadAndCheckLevelDat(levelDat);
this.levelDataPath = levelDat;
loadAndCheckWorldData();

Path iconFile = file.resolve("icon.png");
if (Files.isRegularFile(iconFile)) {
Expand All @@ -189,7 +192,7 @@ private void loadFromZipImpl(Path root) throws IOException {
if (!Files.exists(levelDat)) {
throw new IOException("Not a valid world zip file since level.dat or special_level.dat cannot be found.");
}
loadAndCheckLevelDat(levelDat);
loadAndCheckLevelData(levelDat);

Path iconFile = root.resolve("icon.png");
if (Files.isRegularFile(iconFile)) {
Expand Down Expand Up @@ -221,9 +224,14 @@ private void loadFromZip() throws IOException {
}
}

private void loadAndCheckLevelDat(Path levelDat) throws IOException {
this.levelData = parseLevelDat(levelDat);
CompoundTag data = levelData.get("Data");
private void loadAndCheckWorldData() throws IOException {
loadAndCheckLevelData(levelDataPath);
loadOtherData();
}

private void loadAndCheckLevelData(Path levelDat) throws IOException {
this.levelData = NBT.read(levelDat);
CompoundTag data = (CompoundTag) levelData.get("Data");
if (data == null)
throw new IOException("level.dat missing Data");

Expand All @@ -234,22 +242,57 @@ private void loadAndCheckLevelDat(Path levelDat) throws IOException {
throw new IOException("level.dat missing LastPlayed");
}

public void reloadLevelDat() throws IOException {
if (levelDataPath != null) {
loadAndCheckLevelDat(this.levelDataPath);
private void loadOtherData() throws IOException {
Path worldGenSettingsDatPath = file.resolve("data/minecraft/world_gen_settings.dat");
if (levelData.at("Data.WorldGenSettings") instanceof CompoundTag worldGenSettingsTag) {
this.worldGenSettingsDataPath = null;
this.worldGenSettingsData = worldGenSettingsTag;
this.unifiedWorldGenSettingsData = worldGenSettingsData;
} else if (Files.exists(worldGenSettingsDatPath)) {
this.worldGenSettingsDataPath = worldGenSettingsDatPath;
this.worldGenSettingsData = NBT.read(worldGenSettingsDatPath);
this.unifiedWorldGenSettingsData = (CompoundTag) worldGenSettingsData.get("data");
} else {
this.worldGenSettingsDataPath = null;
this.worldGenSettingsData = null;
this.unifiedWorldGenSettingsData = null;
}

if (levelData.at("Data.Player") instanceof CompoundTag playerTag) {
this.playerData = playerTag;
this.playerDataPath = null;
} else if (levelData.at("Data.singleplayer_uuid") instanceof IntArrayTag uuidTag) {
int[] uuidValue = uuidTag.getClonedValue();
if (uuidValue.length == 4) {
long mostSigBits = ((long) uuidValue[0] << 32) | (uuidValue[1] & 0xFFFFFFFFL);
long leastSigBits = ((long) uuidValue[2] << 32) | (uuidValue[3] & 0xFFFFFFFFL);
String playerUUID = new UUID(mostSigBits, leastSigBits).toString();
Path playerDatPath = file.resolve("players/data/" + playerUUID + ".dat");
if (Files.exists(playerDatPath)) {
this.playerData = NBT.read(playerDatPath);
this.playerDataPath = playerDatPath;
}
}
} else {
this.playerData = null;
this.playerDataPath = null;
}
}

public void reloadWorldData() throws IOException {
loadAndCheckWorldData();
}

// The rename method is used to rename temporary world object during installation and copying,
// so there is no need to modify the `file` field.
public void rename(String newName) throws IOException {
if (!Files.isDirectory(file))
throw new IOException("Not a valid world directory");

// Change the name recorded in level.dat
CompoundTag data = levelData.get("Data");
CompoundTag data = (CompoundTag) levelData.get("Data");
data.put(new StringTag("LevelName", newName));
writeLevelDat(levelData);
writeLevelData();

// then change the folder's name
Files.move(file, file.resolveSibling(newName));
Expand Down Expand Up @@ -345,25 +388,30 @@ public FileChannel lock() throws WorldLockedException {
}
}

public void writeLevelDat(CompoundTag nbt) throws IOException {
public void writeWorldData() throws IOException {
if (!Files.isDirectory(file))
throw new IOException("Not a valid world directory");

FileUtils.saveSafely(getLevelDatFile(), os -> {
try (OutputStream gos = new GZIPOutputStream(os)) {
NBTIO.writeTag(gos, nbt);
}
});
}
writeLevelData();

private static CompoundTag parseLevelDat(Path path) throws IOException {
try (InputStream is = new GZIPInputStream(Files.newInputStream(path))) {
Tag nbt = NBTIO.readTag(is);
if (nbt instanceof CompoundTag compoundTag)
return compoundTag;
else
throw new IOException("level.dat malformed");
if (worldGenSettingsDataPath != null) {
writeTag(worldGenSettingsData, worldGenSettingsDataPath);
}

if (playerDataPath != null) {
writeTag(playerData, playerDataPath);
}
}

public void writeLevelData() throws IOException {
writeTag(levelData, levelDataPath);
}

public void writeTag(CompoundTag nbt, Path path) throws IOException {
if (!Files.isDirectory(file))
throw new IOException("Not a valid world directory");

FileUtils.saveSafely(path, os -> NBT.toStream(nbt, os).compressType(NBTCompressType.GZIP).write());
}

private static boolean isLocked(Path sessionLockFile) {
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ xz = "1.11"
fx-gson = "5.0.0"
constant-pool-scanner = "1.2"
opennbt = "1.5"
micanbt = "0.5.0"
nanohttpd = "2.3.1"
jsoup = "1.21.2"
chardet = "2.5.0"
Expand Down Expand Up @@ -39,6 +40,7 @@ xz = { module = "org.tukaani:xz", version.ref = "xz" }
fx-gson = { module = "org.hildan.fxgson:fx-gson", version.ref = "fx-gson" }
constant-pool-scanner = { module = "org.jenkins-ci:constant-pool-scanner", version.ref = "constant-pool-scanner" }
opennbt = { module = "com.github.steveice10:opennbt", version.ref = "opennbt" }
micanbt = {module = "tech.minediamond:micanbt", version.ref = "micanbt"}
nanohttpd = { module = "org.nanohttpd:nanohttpd", version.ref = "nanohttpd" }
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
chardet = { module = "org.glavo:chardet", version.ref = "chardet" }
Expand Down