[Enhancement] 原理图管理优化 (#6094)

Co-authored-by: Calboot <calboot39@outlook.com>
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: CalbootOnceMore <calboot43@outlook.com>
This commit is contained in:
authored and GitHub committed 2026-09-17 21:15:11 +08:00
1 parent 13910c24b7
commit 13ad50208f
35 files changed
+1426 -425

No files matched your search

@@ -98,5 +98,9 @@ public abstract class MDListCell<T> extends ListCell<T> {
});
}
protected void onClicked(Runnable action) {
FXUtils.onClicked(ripplerContainer, action);
}
protected abstract void updateControl(T item, boolean empty);
}
@@ -158,6 +158,10 @@ public class DownloadListPage extends Control implements DecoratorPage {
FXUtils.runInFX(() -> selectedInstance.set(instanceId));
}
public DownloadPage.DownloadCallback getCallback() {
return callback;
}
private void search(String userGameVersion, RemoteAddonRepository.Category category, int pageOffset, String searchFilter, RemoteAddonRepository.SortType sort) {
retrySearch = null;
setLoading(true);
@@ -207,6 +207,14 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
FXUtils.openFolder(gameInstance.getRunDirectory().resolve(sub));
}
private void onBrowseSchematics() {
HMCLGameInstance gameInstance = requireGameInstance();
if (gameInstance == null) {
return;
}
FXUtils.openFolder(gameInstance.getSchematicsDirectory());
}
private void redownloadAssetIndex() {
HMCLGameInstance gameInstance = requireGameInstance();
if (gameInstance != null) {
@@ -358,7 +366,7 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
new IconedMenuItem(SVG.EXTENSION, i18n("folder.mod"), () -> control.onBrowse("mods"), browsePopup),
new IconedMenuItem(SVG.TEXTURE, i18n("folder.resourcepacks"), () -> control.onBrowse("resourcepacks"), browsePopup),
new IconedMenuItem(SVG.PUBLIC, i18n("folder.saves"), () -> control.onBrowse("saves"), browsePopup),
new IconedMenuItem(SVG.SCHEMA, i18n("folder.schematics"), () -> control.onBrowse("schematics"), browsePopup),
new IconedMenuItem(SVG.SCHEMA, i18n("folder.schematics"), control::onBrowseSchematics, browsePopup),
new IconedMenuItem(SVG.WB_SUNNY, i18n("folder.shaderpacks"), () -> control.onBrowse("shaderpacks"), browsePopup),
new IconedMenuItem(SVG.SCREENSHOT_MONITOR, i18n("folder.screenshots"), () -> control.onBrowse("screenshots"), browsePopup),
new IconedMenuItem(SVG.SETTINGS, i18n("folder.config"), () -> control.onBrowse("config"), browsePopup),
@@ -35,6 +35,10 @@ public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.MOD, CurseForgeRemoteAddonRepository.MODS, ModrinthRemoteAddonRepository.MODS);
}
public static DownloadListPage ofModrinthMod(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.MOD, null, ModrinthRemoteAddonRepository.MODS);
}
public static DownloadListPage ofModPack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.MODPACK, CurseForgeRemoteAddonRepository.MODPACKS, ModrinthRemoteAddonRepository.MODPACKS);
}
@@ -95,7 +95,6 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
private ModManager modManager;
private @Nullable HMCLGameInstance gameInstance;
private String gameVersion;
final EnumSet<ModLoaderType> supportedLoaders = EnumSet.noneOf(ModLoaderType.class);
@@ -137,8 +136,6 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
return;
}
this.gameVersion = gameInstance.getVersion().toString();
loadMods(gameInstance.getModManager());
}
@@ -164,7 +161,8 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
return;
}
updateSupportedLoaders(modManager);
supportedLoaders.clear();
supportedLoaders.addAll(modManager.getSupportedLoaders());
if (exception == null) {
getItems().setAll(list);
@@ -176,51 +174,6 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
}, Schedulers.javafx());
}
private void updateSupportedLoaders(ModManager modManager) {
supportedLoaders.clear();
GameComponentAnalyzer analyzer = modManager.getComponentAnalyzer();
if (analyzer == null) {
Collections.addAll(supportedLoaders, ModLoaderType.values());
return;
}
for (GameComponentType type : GameComponentType.MOD_LOADERS) {
if (type.isModLoader() && analyzer.has(type)) {
ModLoaderType modLoaderType = type.getModLoaderType();
if (modLoaderType != null) {
supportedLoaders.add(modLoaderType);
if (modLoaderType == ModLoaderType.CLEANROOM)
supportedLoaders.add(ModLoaderType.FORGE);
}
}
}
if (analyzer.has(GameComponentType.NEO_FORGE) && "1.20.1".equals(gameVersion)) {
supportedLoaders.add(ModLoaderType.FORGE);
}
if (analyzer.has(GameComponentType.QUILT)) {
supportedLoaders.add(ModLoaderType.FABRIC);
}
if (analyzer.has(GameComponentType.LEGACY_FABRIC)) {
supportedLoaders.add(ModLoaderType.FABRIC);
}
if (analyzer.has(GameComponentType.FABRIC) && modManager.hasMod("kilt", ModLoaderType.FABRIC)) {
supportedLoaders.add(ModLoaderType.FORGE);
supportedLoaders.add(ModLoaderType.NEO_FORGE);
}
// Sinytra Connector
if (analyzer.has(GameComponentType.NEO_FORGE) && (modManager.hasMod("connector", ModLoaderType.NEO_FORGE) || modManager.hasMod("connectormod", ModLoaderType.NEO_FORGE))
|| "1.20.1".equals(gameVersion) && analyzer.has(GameComponentType.FORGE) && modManager.hasMod("connectormod", ModLoaderType.FORGE)) {
supportedLoaders.add(ModLoaderType.FABRIC);
}
}
public void add() {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("mods.add.title"));
@@ -20,32 +20,48 @@ package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXListView;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.Skin;
import javafx.scene.control.Tooltip;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.PixelWriter;
import javafx.scene.image.WritableImage;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import javafx.scene.text.Text;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.mod.LocalMod;
import org.jackhuang.hmcl.addon.mod.LocalModFile;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.schematic.LitematicFile;
import org.jackhuang.hmcl.schematic.Schematic;
import org.jackhuang.hmcl.schematic.SchematicType;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.FileNameSet;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.ui.nbt.NBTEditorPage;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.SynchronizedExceptionalLazy;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -55,10 +71,12 @@ import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.*;
import java.util.Objects;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.ui.FXUtils.*;
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -67,6 +85,23 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
*/
public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
private static final SynchronizedExceptionalLazy<RemoteAddon> litematicaLazy = new SynchronizedExceptionalLazy<>(() -> {
try {
return ModrinthRemoteAddonRepository.MODS.getAddonById(DownloadProviders.getDownloadProvider(), "litematica");
} catch (IOException e) {
LOG.warning("Failed to fetch litematica", e);
throw e;
}
});
private static final SynchronizedExceptionalLazy<RemoteAddon> forgematicaLazy = new SynchronizedExceptionalLazy<>(() -> {
try {
return ModrinthRemoteAddonRepository.MODS.getAddonById(DownloadProviders.getDownloadProvider(), "forgematica");
} catch (IOException e) {
LOG.warning("Failed to fetch forgematica", e);
throw e;
}
});
private static String translateAuthorName(String author) {
if (I18n.isUseChinese() && "hsds".equals(author)) {
return "黑山大叔";
@@ -74,9 +109,22 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
return author;
}
private static String translateType(SchematicType type) {
return switch (type) {
case SCHEM -> i18n("schematics.info.type.schem");
case NBT_STRUCTURE -> i18n("schematics.info.type.nbt_structure");
case LITEMATIC -> i18n("schematics.info.type.litematic");
};
}
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private @Nullable HMCLGameInstance gameInstance;
private Path schematicsDirectory;
private DirItem currentDirectory;
private final ObjectProperty<DirItem> currentDirectory = new SimpleObjectProperty<>(this, "currentDirectory", null);
private final BooleanProperty isRootProperty = new SimpleBooleanProperty(this, "isRoot", true);
private final ObjectProperty<RemoteAddon> downloadTarget = new SimpleObjectProperty<>(this, "downloadTarget");
/// Creates a schematics list that reloads when `instanceContext` changes.
///
@@ -84,7 +132,7 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
public SchematicsPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
Objects.requireNonNull(instanceContext, "instanceContext");
FXUtils.applyDragListener(this,
file -> currentDirectory != null && Files.isRegularFile(file) && FileUtils.getName(file).endsWith(".litematic"),
file -> currentDirectoryProperty().get() != null && Schematic.isFileSchematic(file),
this::addFiles
);
@@ -97,53 +145,120 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
@Override
protected Skin<?> createDefaultSkin() {
return new SchematicsPageSkin();
return new SchematicsPageSkin(this);
}
public void loadInstance(HMCLGameInstance.Optional instance) {
HMCLGameInstance gameInstance = instance.instance();
this.schematicsDirectory = gameInstance != null ? gameInstance.getSchematicsDirectory() : null;
this.gameInstance = instance.instance();
if (gameInstance == null) {
this.schematicsDirectory = null;
return;
}
this.schematicsDirectory = gameInstance.getSchematicsDirectory();
refresh();
}
private ObjectProperty<DirItem> currentDirectoryProperty() {
return currentDirectory;
}
public BooleanProperty isRootProperty() {
return isRootProperty;
}
public void navigateBack() {
var d = currentDirectoryProperty().get();
if (d == null) return;
if (d.parent != null) navigateTo(d.parent);
}
public void downloadLitematica() {
if (downloadTarget.get() != null && gameInstance != null) {
var modDownloads = HMCLLocalizedDownloadListPage.ofModrinthMod(org.jackhuang.hmcl.ui.download.DownloadPage.FOR_MOD, false);
modDownloads.loadInstance(HMCLGameInstance.Optional.of(gameInstance));
Controllers.navigate(new DownloadPage(
modDownloads,
downloadTarget.get(),
modDownloads.getInstanceOptional(),
modDownloads.getCallback())
);
}
}
public void refresh() {
Path schematicsDirectory = this.schematicsDirectory;
if (schematicsDirectory == null) return;
HMCLGameInstance currentGameInstance = this.gameInstance;
if (currentGameInstance == null) return;
setLoading(true);
Task.supplyAsync(() -> loadAll(schematicsDirectory, null))
.whenComplete(Schedulers.javafx(), (result, exception) -> {
setLoading(false);
if (exception == null) {
DirItem target = result;
if (currentDirectory != null) {
loop:
for (int i = 0; i < currentDirectory.relativePath.size(); i++) {
String dirName = currentDirectory.relativePath.get(i);
for (Item child : target.children) {
if (child instanceof DirItem && child.getName().equals(dirName)) {
target = (DirItem) child;
continue loop;
}
}
break;
}
}
navigateTo(target);
} else {
LOG.warning("Failed to load schematics", exception);
DirItem currentDir = currentDirectoryProperty().get();
Task.supplyAsync(Schedulers.io(), () -> {
DirItem target = loadRoot(schematicsDirectory);
if (currentDir != null) {
loop:
for (String dirName : currentDir.relativePath) {
try {
target.preLoad();
} catch (IOException e) {
LOG.warning("Failed to preload sub-directories in " + target, e);
break;
}
}).start();
for (var dirChild : target.dirChildren) {
if (dirChild.getName().equals(dirName)) {
target = dirChild;
continue loop;
}
}
break;
}
}
return target;
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (!Objects.equals(currentGameInstance, this.gameInstance)) return;
if (exception == null) {
navigateTo(result);
} else {
LOG.warning("Failed to load schematics", exception);
}
setLoading(false);
}).start();
downloadTarget.set(null);
Task.supplyAsync(Schedulers.io(), () -> {
var modManager = currentGameInstance.getModManager();
modManager.analyze();
var analyzer = modManager.getComponentAnalyzer();
if (analyzer == null) return null;
var modIds = modManager.getLocalFiles().stream()
.map(LocalModFile::getMod)
.map(LocalMod::getId)
.collect(Collectors.toSet());
if (modIds.contains("litematica") || modIds.contains("forgematica")) return null;
var gameVersionNumber = GameVersionNumber.asGameVersion(modManager.getGameVersion());
if (!gameVersionNumber.isAtLeast("1.12", "17w31a"))
return null;
if ((analyzer.has(GameComponentType.FORGE) || analyzer.has(GameComponentType.NEO_FORGE))
&& gameVersionNumber.isAtLeast("1.16.4", "20w45a"))
return forgematicaLazy.get();
return litematicaLazy.get();
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (!Objects.equals(currentGameInstance, gameInstance)) return;
if (exception == null) {
downloadTarget.set(result);
} else {
LOG.warning("Failed to fetch litematica", exception);
}
}).start();
}
public void addFiles(List<Path> files) {
if (currentDirectory == null)
if (currentDirectoryProperty().get() == null)
return;
Path dir = currentDirectory.path;
Path dir = currentDirectoryProperty().get().getPath();
try {
// Can be executed in the background, but be careful that users can call loadVersion during this time
Files.createDirectories(dir);
@@ -162,7 +277,7 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("schematics.add.title"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(
i18n("extension.schematic"), "*.litematic"));
i18n("extension.schematic"), "*.litematic", "*.nbt", "*.schematic", "*.schem"));
List<Path> files = Controllers.showOpenMultipleDialog(fileChooser);
if (files != null && !files.isEmpty()) {
addFiles(files);
@@ -170,10 +285,9 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
}
public void onCreateDirectory() {
if (currentDirectory == null)
return;
if (currentDirectoryProperty().get() == null) return;
Path parent = currentDirectory.path;
Path parent = currentDirectoryProperty().get().getPath();
FileNameSet existingPaths;
try {
@@ -208,37 +322,29 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
new Validator(i18n("schematics.create_directory.failed.already_exists"), existingPaths::notContains));
}
private DirItem loadAll(Path dir, @Nullable DirItem parent) {
DirItem item = new DirItem(dir, parent);
public void onRevealSchematicsFolder() {
var d = currentDirectoryProperty().get();
var p = d != null ? d.getPath() : schematicsDirectory;
if (p != null) FXUtils.openFolder(p);
}
try (Stream<Path> stream = Files.list(dir)) {
for (Path path : Lang.toIterable(stream)) {
if (Files.isDirectory(path)) {
item.children.add(loadAll(path, item));
} else if (path.getFileName().toString().endsWith(".litematic") && Files.isRegularFile(path)) {
try {
item.children.add(new LitematicFileItem(LitematicFile.load(path)));
} catch (IOException e) {
LOG.warning("Failed to load litematic file: " + path, e);
}
}
}
} catch (NoSuchFileException ignored) {
} catch (IOException e) {
LOG.warning("Failed to load schematics in " + dir, e);
}
item.children.sort(Comparator.naturalOrder());
private DirItem loadRoot(Path dir) {
var item = new DirItem(dir, null);
item.load();
return item;
}
private void navigateTo(DirItem item) {
currentDirectory = item;
getItems().clear();
if (item.parent != null) {
getItems().add(new BackItem(item.parent));
}
getItems().addAll(item.children);
if (currentDirectoryProperty().get() == item) return;
currentDirectoryProperty().set(item);
isRootProperty().set(item.parent == null);
setLoading(true);
Task.runAsync(item::load).whenComplete(Schedulers.javafx(), exception -> {
if (currentDirectoryProperty().get() == item) {
getItems().setAll(item.children);
setLoading(false);
}
}).start();
}
abstract sealed class Item implements Comparable<Item> {
@@ -280,12 +386,28 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
}
}
private final class BackItem extends Item {
private final class DirItem extends Item {
final ReentrantLock lock = new ReentrantLock();
private final DirItem parent;
final Path path;
final @Nullable DirItem parent;
final List<Item> children = new ArrayList<>();
final List<DirItem> dirChildren = new ArrayList<>();
final List<String> relativePath;
int size = 0;
boolean preLoaded = false;
boolean loaded = false;
BackItem(DirItem parent) {
DirItem(Path path, @Nullable DirItem parent) {
this.path = path;
this.parent = parent;
if (parent != null) {
this.relativePath = new ArrayList<>(parent.relativePath);
relativePath.add(FileUtils.getName(path));
} else {
this.relativePath = Collections.emptyList();
}
}
@Override
@@ -293,65 +415,6 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
return 0;
}
@Override
Path getPath() {
return null;
}
@Override
String getName() {
return "..";
}
@Override
String getDescription() {
return i18n("schematics.back_to", parent.getName());
}
@Override
SVG getIcon() {
return SVG.FOLDER;
}
@Override
void onClick() {
navigateTo(parent);
}
@Override
void onReveal() {
throw new UnsupportedOperationException("Unreachable");
}
@Override
void onDelete() {
throw new UnsupportedOperationException("Unreachable");
}
}
private final class DirItem extends Item {
final Path path;
final @Nullable DirItem parent;
final List<Item> children = new ArrayList<>();
final List<String> relativePath;
DirItem(Path path, @Nullable DirItem parent) {
this.path = path;
this.parent = parent;
if (parent != null) {
this.relativePath = new ArrayList<>(parent.relativePath);
relativePath.add(path.getFileName().toString());
} else {
this.relativePath = Collections.emptyList();
}
}
@Override
int order() {
return 1;
}
@Override
Path getPath() {
return path;
@@ -359,12 +422,12 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
@Override
public String getName() {
return path.getFileName().toString();
return FileUtils.getName(path);
}
@Override
String getDescription() {
return i18n("schematics.sub_items", children.size());
return i18n("schematics.sub_items", size);
}
@Override
@@ -372,6 +435,66 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
return SVG.FOLDER;
}
void preLoad() throws IOException {
lock.lock();
try {
if (this.preLoaded) return;
this.size = 0;
this.dirChildren.clear();
try (Stream<Path> stream = Files.list(path)) {
stream.forEach(p -> {
boolean b1 = Files.isDirectory(p);
boolean b2 = Schematic.isFileSchematic(p);
if (b1 || b2) this.size++;
if (b1) {
var child = new DirItem(p, this);
this.dirChildren.add(child);
}
});
}
this.preLoaded = true;
} finally {
lock.unlock();
}
}
void load() {
lock.lock();
try {
if (this.loaded) return;
this.children.clear();
preLoad();
for (var dir : dirChildren) {
try {
dir.preLoad();
} catch (IOException e) {
LOG.warning("Failed to pre-load schematics in " + dir.path, e);
}
this.children.add(dir);
}
try (Stream<Path> stream = Files.list(path)) {
stream.filter(Schematic::isFileSchematic)
.forEach(p -> {
try {
this.children.add(new SchematicItem(p));
} catch (IOException e) {
LOG.warning("Failed to load schematic file: " + p, e);
}
});
}
this.children.sort(Comparator.naturalOrder());
this.loaded = true;
} catch (NoSuchFileException ignored) {
} catch (IOException e) {
LOG.warning("Failed to load schematics in " + path, e);
} finally {
lock.unlock();
}
}
@Override
void onClick() {
navigateTo(this);
@@ -385,8 +508,7 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
@Override
void onDelete() {
try {
FileUtils.cleanDirectory(path);
Files.deleteIfExists(path);
FileUtils.deleteDirectory(path);
refresh();
} catch (IOException e) {
LOG.warning("Failed to delete directory: " + path, e);
@@ -394,19 +516,27 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
}
}
private final class LitematicFileItem extends Item {
final LitematicFile file;
private final class SchematicItem extends Item {
final Path path;
final Schematic file;
final String name;
final Image image;
private LitematicFileItem(LitematicFile file) {
this.file = file;
private SchematicItem(Path path) throws IOException {
this.path = path;
this.file = Schematic.load(path);
String name = file.getName();
if (name != null && !"Unnamed".equals(name)) {
this.name = name;
if (file == null) throw new IOException("Unsupported or deleted file: " + path);
if (this.file instanceof LitematicFile lFile) {
String name = lFile.getName();
if (StringUtils.isNotBlank(name) && !"Unnamed".equals(name)) {
this.name = name;
} else {
this.name = FileUtils.getNameWithoutExtension(path);
}
} else {
this.name = StringUtils.removeSuffix(file.getFile().getFileName().toString(), ".litematic");
this.name = FileUtils.getNameWithoutExtension(path);
}
WritableImage image = null;
@@ -430,12 +560,12 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
@Override
int order() {
return 2;
return 1;
}
@Override
Path getPath() {
return file.getFile();
return path;
}
@Override
@@ -445,7 +575,7 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
@Override
String getDescription() {
return file.getFile().getFileName().toString();
return path.getFileName().toString();
}
@Override
@@ -469,25 +599,25 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
@Override
void onClick() {
Controllers.dialog(new LitematicInfoDialog());
Controllers.dialog(new SchematicInfoDialog());
}
@Override
void onReveal() {
FXUtils.showFileInExplorer(file.getFile());
FXUtils.showFileInExplorer(path);
}
@Override
void onDelete() {
try {
Files.deleteIfExists(file.getFile());
Files.deleteIfExists(path);
refresh();
} catch (IOException e) {
LOG.warning("Failed to delete litematic file: " + file.getFile(), e);
LOG.warning("Failed to delete schematic file: " + path, e);
}
}
private final class LitematicInfoDialog extends JFXDialogLayout {
private final class SchematicInfoDialog extends JFXDialogLayout {
private final ComponentList details;
private void addDetailItem(String key, Object detail) {
@@ -497,38 +627,45 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
details.getContent().add(borderPane);
}
private void updateContent(LitematicFile file) {
private void updateContent(Schematic file) {
details.getContent().clear();
addDetailItem(i18n("schematics.info.name"), file.getName());
addDetailItem(i18n("schematics.info.type"), translateType(file.getType()));
if (StringUtils.isNotBlank(file.getAuthor()))
addDetailItem(i18n("schematics.info.schematic_author"), translateAuthorName(file.getAuthor()));
if (file.getTimeCreated() != null)
addDetailItem(i18n("schematics.info.time_created"), I18n.formatDateTime(file.getTimeCreated()));
if (file.getTimeModified() != null && !file.getTimeModified().equals(file.getTimeCreated()))
addDetailItem(i18n("schematics.info.time_modified"), I18n.formatDateTime(file.getTimeModified()));
if (file.getRegionCount() > 0)
addDetailItem(i18n("schematics.info.region_count"), String.valueOf(file.getRegionCount()));
if (file.getTotalVolume() > 0)
addDetailItem(i18n("schematics.info.total_volume"), file.getTotalVolume());
if (file.getTotalBlocks() > 0)
addDetailItem(i18n("schematics.info.total_blocks"), file.getTotalBlocks());
if (file.getRegionCount().isPresent())
addDetailItem(i18n("schematics.info.region_count"), String.valueOf(file.getRegionCount().getAsInt()));
if (file.getTotalVolume().isPresent())
addDetailItem(i18n("schematics.info.total_volume"), file.getTotalVolume().getAsLong());
if (file.getTotalBlocks().isPresent())
addDetailItem(i18n("schematics.info.total_blocks"), file.getTotalBlocks().getAsInt());
if (file.getEnclosingSize() != null)
addDetailItem(i18n("schematics.info.enclosing_size"),
String.format("%d x %d x %d", (int) file.getEnclosingSize().getX(),
(int) file.getEnclosingSize().getY(),
(int) file.getEnclosingSize().getZ()));
addDetailItem(i18n("schematics.info.version"), file.getVersion());
String.format("%d x %d x %d", file.getEnclosingSize().x(),
file.getEnclosingSize().y(),
file.getEnclosingSize().z()));
if (StringUtils.isNotBlank(file.getMinecraftVersion()))
addDetailItem(i18n("schematics.info.mc_data_version"), file.getMinecraftVersion());
if (file.getVersion().isPresent())
addDetailItem(i18n("schematics.info.version"),
file.getSubVersion().isPresent()
? "%d.%d".formatted(file.getVersion().getAsInt(), file.getSubVersion().getAsInt())
: file.getVersion().getAsInt()
);
}
LitematicInfoDialog() {
SchematicInfoDialog() {
HBox titleBox = new HBox(8);
{
Node icon = getIcon(40);
TwoLineListItem title = new TwoLineListItem();
title.setTitle(getName());
title.setSubtitle(file.getFile().getFileName().toString());
title.setSubtitle(path.getFileName().toString());
titleBox.getChildren().setAll(icon, title);
setHeading(titleBox);
@@ -536,10 +673,15 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
{
this.details = new ComponentList();
details.setStyle("-fx-effect: null;");
StackPane detailsContainer = new StackPane();
detailsContainer.setPadding(new Insets(10, 0, 0, 0));
detailsContainer.getChildren().add(details);
setBody(detailsContainer);
ScrollPane scrollPane = new ScrollPane(detailsContainer);
scrollPane.setFitToWidth(true);
FXUtils.setOverflowHidden(scrollPane, 8);
FXUtils.smoothScrolling(scrollPane);
StackPane.setMargin(scrollPane, new Insets(10, 0, 0, 0));
setBody(scrollPane);
}
{
@@ -552,15 +694,16 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
onEscPressed(this, okButton::fire);
}
this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.6));
this.maxHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.8));
updateContent(file);
}
}
}
private static final class Cell extends ListCell<Item> {
private static final class Cell extends MDListCell<Item> {
private final RipplerContainer graphics;
private final BorderPane root;
private final StackPane left;
private final TwoLineListItem center;
private final HBox right;
@@ -568,12 +711,17 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
private final ImageContainer iconImageView;
private final SVGContainer iconSVGView;
private final BooleanProperty isFileProperty = new SimpleBooleanProperty(this, "isFile", false);
private final BooleanProperty isDirectoryProperty = new SimpleBooleanProperty(this, "isDirectory", false);
private final Tooltip tooltip = new Tooltip();
public Cell() {
this.root = new BorderPane();
root.getStyleClass().add("md-list-cell");
root.setPadding(new Insets(8));
public Cell(JFXListView<Item> listView) {
super(listView);
var box = new HBox(8);
box.setAlignment(Pos.CENTER_LEFT);
box.setPickOnBounds(false);
{
this.left = new StackPane();
@@ -581,115 +729,186 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
this.iconImageView = new ImageContainer(32);
this.iconSVGView = new SVGContainer(32);
BorderPane.setAlignment(left, Pos.CENTER);
root.setLeft(left);
}
{
this.center = new TwoLineListItem();
root.setCenter(center);
HBox.setHgrow(center, Priority.ALWAYS);
}
{
this.right = new HBox(8);
right.setAlignment(Pos.CENTER_RIGHT);
JFXButton btnReveal = FXUtils.newToggleButton4(SVG.FOLDER_OPEN);
FXUtils.installFastTooltip(btnReveal, i18n("reveal.in_file_manager"));
{
var fo = SVG.FOLDER_OPEN.createIcon();
var f = SVG.FOLDER.createIcon();
btnReveal.graphicProperty().bind(isDirectoryProperty.map(b -> b ? fo : f));
var tooltip = new Tooltip();
tooltip.textProperty().bind(isDirectoryProperty.map(b -> b ? i18n("button.reveal_dir") : i18n("reveal.in_file_manager")));
FXUtils.installFastTooltip(btnReveal, tooltip);
}
btnReveal.setOnAction(event -> {
Item item = getItem();
if (item != null && !(item instanceof BackItem))
item.onReveal();
if (item != null) item.onReveal();
});
JFXButton btnExplore = FXUtils.newToggleButton4(SVG.EXPLORE); // Change the icon if allows editing
btnExplore.setOnAction(event -> {
Item item = getItem();
if (item instanceof SchematicItem) {
try {
Controllers.navigate(new NBTEditorPage(item.getPath()));
} catch (IOException ignored) { // Should be impossible
}
}
});
btnExplore.visibleProperty().bind(isFileProperty);
JFXButton btnDelete = FXUtils.newToggleButton4(SVG.DELETE_FOREVER);
btnDelete.setOnAction(event -> {
Item item = getItem();
if (item != null && !(item instanceof BackItem)) {
if (item != null) {
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"),
item::onDelete, null);
}
});
right.getChildren().setAll(btnReveal, btnDelete);
right.getChildren().setAll(btnExplore, btnReveal, btnDelete);
}
this.graphics = new RipplerContainer(root);
FXUtils.onClicked(graphics, () -> {
Item item = getItem();
if (item != null)
item.onClick();
box.getChildren().setAll(left, center, right);
StackPane.setMargin(box, new Insets(8));
getContainer().getChildren().setAll(box);
onClicked(() -> {
var item = getItem();
if (item != null) item.onClick();
});
}
@Override
protected void updateItem(Item item, boolean empty) {
Item oldItem = getItem();
boolean oldEmpty = isEmpty();
protected void updateControl(Item item, boolean empty) {
if (empty || item == null) return;
super.updateItem(item, empty);
if (oldItem == item && oldEmpty == empty) return;
graphics.releaseRippleImmediately();
iconImageView.setImage(null);
if (empty || item == null) {
setGraphic(null);
center.setTitle("");
center.setSubtitle("");
isFileProperty.set(item instanceof SchematicItem);
isDirectoryProperty.set(item.isDirectory());
if (item instanceof SchematicItem fileItem && fileItem.getImage() != null) {
iconImageView.setImage(fileItem.getImage());
left.getChildren().setAll(iconImageView);
} else {
if (item instanceof LitematicFileItem fileItem && fileItem.getImage() != null) {
iconImageView.setImage(fileItem.getImage());
left.getChildren().setAll(iconImageView);
} else {
iconSVGView.setIcon(item.getIcon());
left.getChildren().setAll(iconSVGView);
}
iconSVGView.setIcon(item.getIcon());
left.getChildren().setAll(iconSVGView);
}
center.setTitle(item.getName());
center.setSubtitle(item.getDescription());
center.setTitle(item.getName());
center.setSubtitle(item.getDescription());
Path path = item.getPath();
if (path != null) {
tooltip.setText(FileUtils.getAbsolutePath(path));
FXUtils.installSlowTooltip(left, tooltip);
} else {
tooltip.setText("");
Tooltip.uninstall(left, tooltip);
}
root.setRight(item instanceof BackItem ? null : right);
setGraphic(graphics);
Path path = item.getPath();
if (path != null) {
tooltip.setText(FileUtils.getAbsolutePath(path));
FXUtils.installSlowTooltip(left, tooltip);
} else {
Tooltip.uninstall(left, tooltip);
}
}
}
private final class SchematicsPageSkin extends ToolbarListPageSkin<Item, SchematicsPage> {
SchematicsPageSkin() {
super(SchematicsPage.this);
private static final class SchematicsPageSkin extends SkinBase<SchematicsPage> {
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("schematics.empty"));
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
}
private final JFXListView<Item> listView;
@Override
protected List<Node> initializeToolbar(SchematicsPage skinnable) {
return Arrays.asList(
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
createToolbarButton2(i18n("schematics.add"), SVG.ADD, skinnable::onAddFiles),
createToolbarButton2(i18n("schematics.create_directory"), SVG.CREATE_NEW_FOLDER, skinnable::onCreateDirectory)
);
}
SchematicsPageSkin(SchematicsPage skinnable) {
super(skinnable);
@Override
protected ListCell<Item> createListCell(JFXListView<Item> listView) {
return new Cell();
StackPane pane = new StackPane();
pane.setPadding(new Insets(10));
pane.getStyleClass().addAll("notice-pane");
ComponentList root = new ComponentList();
root.getStyleClass().add("no-padding");
listView = new JFXListView<>();
listView.setSelectionModel(new NoneMultipleSelectionModel<>());
{
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("schematics.empty"));
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
}
{
var toolbar = new HBox();
JFXButton btnGoBack = createToolbarButton2("", SVG.ARROW_BACK, skinnable::navigateBack);
btnGoBack.disableProperty().bind(skinnable.isRootProperty());
JFXButton btnDownload = createToolbarButton2(i18n("schematics.install_mod"), SVG.DOWNLOAD, skinnable::downloadLitematica);
FXUtils.onChangeAndOperate(skinnable.downloadTarget, (t) -> btnDownload.setDisable(t == null));
toolbar.getChildren().setAll(
btnGoBack,
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
createToolbarButton2(i18n("schematics.add"), SVG.ADD, skinnable::onAddFiles),
createToolbarButton2(i18n("schematics.create_directory"), SVG.CREATE_NEW_FOLDER, skinnable::onCreateDirectory),
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::onRevealSchematicsFolder),
btnDownload
);
FXUtils.setOverflowHidden(toolbar, 8);
root.getContent().add(toolbar);
}
{
SpinnerPane center = new SpinnerPane();
ComponentList.setVgrow(center, Priority.ALWAYS);
center.loadingProperty().bind(skinnable.loadingProperty());
listView.setCellFactory(x -> new Cell(listView));
listView.setSelectionModel(new NoneMultipleSelectionModel<>());
Bindings.bindContent(listView.getItems(), skinnable.getItems());
// ListViewBehavior would consume ESC pressed event, preventing us from handling it
// So we ignore it here
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
listView.getStyleClass().add("no-horizontal-scrollbar");
center.setContent(listView);
root.getContent().add(center);
}
{
var relPath = new HBox();
relPath.getStyleClass().add("jfx-tool-bar-tip");
relPath.setAlignment(Pos.CENTER_LEFT);
FXUtils.onChangeAndOperate(skinnable.currentDirectoryProperty(), currentDir -> {
relPath.getChildren().clear();
var d = currentDir;
while (d != null) {
if (d != currentDir) {
var box = new HBox(new Text(">"));
box.setPadding(new Insets(3));
relPath.getChildren().add(0, box);
}
var txt = new Text(d.getName());
var box = new HBox(txt);
box.setPadding(new Insets(3));
box.setMouseTransparent(true);
var rippler = new RipplerContainer(box);
var finalD = d;
FXUtils.onClicked(rippler, () -> skinnable.navigateTo(finalD));
relPath.getChildren().add(0, rippler);
d = d.parent;
}
});
var relPathPane = new HBox(relPath);
relPathPane.setPadding(new Insets(2));
root.getContent().add(relPathPane);
}
pane.getChildren().setAll(root);
getChildren().setAll(pane);
}
}
}
@@ -29,7 +29,7 @@ import java.nio.file.Path;
/// @author Glavo
public enum NBTFileType {
COMPRESSED("nbt", "dat", "dat_old") {
COMPRESSED("dat", "dat_old", "litematic", "nbt", "schematic", "schem") {
@Override
public Tag read(Path file) throws IOException {
return NBTCodec.of().readTag(file);
@@ -0,0 +1,71 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util;
import org.jackhuang.hmcl.util.function.ExceptionalSupplier;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
import java.util.concurrent.locks.ReentrantLock;
/**
* Thread-safe exceptional lazy initialization wrapper.
* <p>
* On failure, the result is set to null in order to allow retrying.
*
* @param <T> value type
*/
public final class SynchronizedExceptionalLazy<T> {
private final ExceptionalSupplier<@Nullable T, Exception> supplier;
private volatile Result<T> result = null;
private final ReentrantLock lock = new ReentrantLock();
public SynchronizedExceptionalLazy(ExceptionalSupplier<@Nullable T, Exception> supplier) {
this.supplier = Objects.requireNonNull(supplier);
}
/// Gets and returns the result.
///
/// If the previous attempt succeeded, the cached value is returned directly.
///
/// Otherwise, the supplier is invoked to compute a new value.If this attempt fails,
/// {@code null} is returned; if succeeded, the computed value is cached and returned.
///
/// @return the result, or null on failure
public T get() {
if (result != null) return result.value();
lock.lock();
try {
if (result == null) {
try {
result = new Result<>(supplier.get());
} catch (Exception e) {
result = null;
return null;
}
}
return result.value();
} finally {
lock.unlock();
}
}
private record Result<T>(T value) {
}
}
@@ -713,6 +713,10 @@
-fx-text-fill: BLACK;
}
.jfx-tool-bar Text {
}
.jfx-tool-bar .jfx-options-burger {
-fx-padding: 22px;
}
@@ -761,6 +765,11 @@
-fx-padding: 10.0;
}
.jfx-tool-bar-tip Text {
-fx-font-size: 13;
-fx-fill: -monet-on-surface;
}
.jfx-decorator-button {
-fx-max-width: 40px;
-fx-background-radius: 5px;
@@ -1333,7 +1333,6 @@ schematics=Schematics
schematics.add=Add
schematics.add.failed=Failed to add schematic files
schematics.add.title=Choose schematic file you want to import
schematics.back_to=Back to "%s"
schematics.create_directory=New Directory
schematics.create_directory.prompt=Please enter the new directory name
schematics.create_directory.failed=Failed to create directory
@@ -1349,7 +1348,13 @@ schematics.info.time_created=Created Time
schematics.info.time_modified=Modified Time
schematics.info.total_blocks=Total Blocks
schematics.info.total_volume=Total Volume
schematics.info.type=Schematic Type
schematics.info.type.litematic=Litematic (Litematica)
schematics.info.type.nbt_structure=NBT Structure (Vanilla, Create)
schematics.info.type.schem=Schem (Schematica, WE, MCEdit)
schematics.info.version=Schematic Version
schematics.info.mc_data_version=Minecraft Data Version
schematics.install_mod=Install Litematica Mod
schematics.manage=Schematics
schematics.sub_items=%d sub-item(s)
@@ -1333,7 +1333,6 @@ schematics=المخططات
schematics.add=إضافة
schematics.add.failed=فشل إضافة ملفات المخططات
schematics.add.title=اختر ملف المخطط الذي تريد استيراده
schematics.back_to=العودة إلى "%s"
schematics.create_directory=مجلد جديد
schematics.create_directory.prompt=يُرجى إدخال اسم المجلد الجديد
schematics.create_directory.failed=فشل إنشاء المجلد
@@ -1349,7 +1348,13 @@ schematics.info.time_created=وقت الإنشاء
schematics.info.time_modified=وقت التعديل
schematics.info.total_blocks=إجمالي البلوكات
schematics.info.total_volume=الحجم الكلي
# schematics.info.type=
# schematics.info.type.litematic=
# schematics.info.type.nbt_structure=
# schematics.info.type.schem=
schematics.info.version=إصدار المخطط
# schematics.info.mc_data_version=
# schematics.install_mod=
schematics.manage=المخططات
schematics.sub_items=%d عنصر فرعي
@@ -1332,7 +1332,6 @@ schematics=Schematiken
schematics.add=Hinzufügen
schematics.add.failed=Fehler beim Hinzufügen der Schematik-Dateien
schematics.add.title=Wählen Sie die Schematik-Datei, die Sie importieren möchten
schematics.back_to=Zurück zu "%s"
schematics.create_directory=Neues Verzeichnis
schematics.create_directory.prompt=Bitte geben Sie den neuen Verzeichnisnamen ein
schematics.create_directory.failed=Fehler beim Erstellen des Verzeichnisses
@@ -1348,7 +1347,13 @@ schematics.info.time_created=Erstellungszeit
schematics.info.time_modified=Änderungszeit
schematics.info.total_blocks=Gesamte Blöcke
schematics.info.total_volume=Gesamtes Volumen
# schematics.info.type=
# schematics.info.type.litematic=
# schematics.info.type.nbt_structure=
# schematics.info.type.schem=
schematics.info.version=Schematik-Version
# schematics.info.mc_data_version=
# schematics.install_mod=
schematics.manage=Schematiken
schematics.sub_items=%d Unterelement(e)
@@ -1325,7 +1325,6 @@ schematics=Esquemas
# schematics.add=
schematics.add.failed=No se han podido añadir archivos de esquema
# schematics.add.title=
schematics.back_to=Atrás a «%s»
# schematics.create_directory=
schematics.create_directory.prompt=Introduzca el nuevo nombre del directorio
schematics.create_directory.failed=No se ha podido crear el directorio
@@ -1341,7 +1340,13 @@ schematics.info.time_created=Creado
schematics.info.time_modified=Modificado
schematics.info.total_blocks=Bloques totales
schematics.info.total_volume=Volumen total
# schematics.info.type=
# schematics.info.type.litematic=
# schematics.info.type.nbt_structure=
# schematics.info.type.schem=
schematics.info.version=Versión de esquema
# schematics.info.mc_data_version=
# schematics.install_mod=
schematics.manage=Esquemas
schematics.sub_items=%d elemento(s)
@@ -1129,7 +1129,6 @@ reveal.in_file_manager=ファイルマネージャーで表示する
# schematics.add=
# schematics.add.failed=
# schematics.add.title=
# schematics.back_to=
# schematics.create_directory=
# schematics.create_directory.prompt=
# schematics.create_directory.failed=
@@ -1145,7 +1144,13 @@ reveal.in_file_manager=ファイルマネージャーで表示する
# schematics.info.time_modified=
# schematics.info.total_blocks=
# schematics.info.total_volume=
# schematics.info.type=
# schematics.info.type.litematic=
# schematics.info.type.nbt_structure=
# schematics.info.type.schem=
# schematics.info.version=
# schematics.info.mc_data_version=
# schematics.install_mod=
# schematics.manage=
# schematics.sub_items=
@@ -1141,7 +1141,6 @@ schematics=蜃圖
schematics.add=增蜃圖
schematics.add.failed=增蜃圖未成
# schematics.add.title=
schematics.back_to=退至「%s」
schematics.create_directory=增案夾
schematics.create_directory.prompt=書新案夾之名
schematics.create_directory.failed=增案夾未成
@@ -1157,7 +1156,13 @@ schematics.info.time_created=創之時辰
schematics.info.time_modified=改之時辰
schematics.info.total_blocks=總塊數
schematics.info.total_volume=總體積
# schematics.info.type=
# schematics.info.type.litematic=
# schematics.info.type.nbt_structure=
# schematics.info.type.schem=
schematics.info.version=蜃圖之版
# schematics.info.mc_data_version=
# schematics.install_mod=
schematics.manage=司蜃圖
schematics.sub_items=%d 子项
@@ -1265,7 +1265,6 @@ schematics=Схемы
schematics.add=Добавить
schematics.add.failed=Не удалось добавить файлы схем
schematics.add.title=Выберите файл схемы для импорта
schematics.back_to=Назад на «%s»
schematics.create_directory=Новая папка
schematics.create_directory.prompt=Введите новое имя папки
schematics.create_directory.failed=Не удалось создать папку
@@ -1281,7 +1280,13 @@ schematics.info.time_created=Создан
schematics.info.time_modified=Изменен
schematics.info.total_blocks=Всего блоков
schematics.info.total_volume=Общий объём
# schematics.info.type=
# schematics.info.type.litematic=
# schematics.info.type.nbt_structure=
# schematics.info.type.schem=
schematics.info.version=Версия схемы
# schematics.info.mc_data_version=
# schematics.install_mod=
schematics.manage=Схемы
schematics.sub_items=%d элемент(ов)
@@ -1288,7 +1288,6 @@ schematics=Схематики
schematics.add=Додати
schematics.add.failed=Не вдалося додати файли схематик
schematics.add.title=Виберіть файл схеми, який ви хочете імпортувати
schematics.back_to=Назад до "%s"
schematics.create_directory=Новий каталог
schematics.create_directory.prompt=Введіть нову назву каталогу
schematics.create_directory.failed=Не вдалося створити каталог
@@ -1304,7 +1303,13 @@ schematics.info.time_created=Час створення
schematics.info.time_modified=Час зміни
schematics.info.total_blocks=Всього блоків
schematics.info.total_volume=Загальний об'єм
# schematics.info.type=
# schematics.info.type.litematic=
# schematics.info.type.nbt_structure=
# schematics.info.type.schem=
schematics.info.version=Версія схематики
# schematics.info.mc_data_version=
# schematics.install_mod=
schematics.manage=Схематики
schematics.sub_items=%d підпунктів
@@ -1145,7 +1145,6 @@ schematics=原理图
schematics.add=添加原理图
schematics.add.failed=添加原理图失败
schematics.add.title=选择要添加的原理图文件
schematics.back_to=返回到“%s”
schematics.create_directory=创建文件夹
schematics.create_directory.prompt=请输入新文件夹名称
schematics.create_directory.failed=创建文件夹失败
@@ -1161,7 +1160,13 @@ schematics.info.time_created=创建时间
schematics.info.time_modified=修改时间
schematics.info.total_blocks=总方块数
schematics.info.total_volume=总体积
schematics.info.type=原理图类型
schematics.info.type.litematic=Litematic (Litematica)
schematics.info.type.nbt_structure=NBT 结构 (原版, 机械动力)
schematics.info.type.schem=Schem (Schematica, WE, MCEdit)
schematics.info.version=原理图版本
schematics.info.mc_data_version=Minecraft 数据版本
schematics.install_mod=安装投影模组
schematics.manage=原理图管理
schematics.sub_items=%d 个子项
@@ -1139,7 +1139,6 @@ schematics=原理圖
schematics.add=新增原理圖
schematics.add.failed=新增原理圖失敗
schematics.add.title=選取要新增的原理圖檔案
schematics.back_to=返回到「%s」
schematics.create_directory=建立目錄
schematics.create_directory.prompt=請輸入新目錄名稱
schematics.create_directory.failed=建立目錄失敗
@@ -1155,7 +1154,13 @@ schematics.info.time_created=建立時間
schematics.info.time_modified=修改時間
schematics.info.total_blocks=總方塊數
schematics.info.total_volume=總體積
schematics.info.type=原理圖類型
schematics.info.type.litematic=Litematic (Litematica)
schematics.info.type.nbt_structure=NBT 結構 (原版, 機械動力)
schematics.info.type.schem=Schem (Schematica, WE, MCEdit)
schematics.info.version=原理圖版本
schematics.info.mc_data_version=Minecraft 資料版本
schematics.install_mod=安裝投影模組
schematics.manage=原理圖管理
schematics.sub_items=%d 個子項
@@ -24,6 +24,7 @@ import org.jackhuang.hmcl.addon.meta.*;
import org.jackhuang.hmcl.game.DefaultGameInstance;
import org.jackhuang.hmcl.game.GameComponentAnalyzer;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.game.NoSuchGameInstanceException;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
@@ -65,7 +66,9 @@ public final class ModManager extends LocalAddonManager<LocalModFile> {
READERS = map;
}
private String gameVersion;
private final HashMap<Pair<String, ModLoaderType>, LocalMod> localMods = new HashMap<>();
private final EnumSet<ModLoaderType> supportedLoaders = EnumSet.noneOf(ModLoaderType.class);
private GameComponentAnalyzer analyzer;
private boolean loaded = false;
@@ -83,7 +86,12 @@ public final class ModManager extends LocalAddonManager<LocalModFile> {
}
public GameComponentAnalyzer getComponentAnalyzer() {
return analyzer;
lock.lock();
try {
return analyzer;
} finally {
lock.unlock();
}
}
public LocalMod getLocalMod(String modId, ModLoaderType modLoaderType) {
@@ -105,6 +113,24 @@ public final class ModManager extends LocalAddonManager<LocalModFile> {
}
}
public String getGameVersion() {
lock.lock();
try {
return gameVersion;
} finally {
lock.unlock();
}
}
public EnumSet<ModLoaderType> getSupportedLoaders() {
lock.lock();
try {
return EnumSet.copyOf(supportedLoaders);
} finally {
lock.unlock();
}
}
private void addModInfo(Path file) {
String fileName = StringUtils.removeSuffix(FileUtils.getName(file), DISABLED_EXTENSION, OLD_EXTENSION);
String extension = fileName.substring(fileName.lastIndexOf(".") + 1);
@@ -176,6 +202,18 @@ public final class ModManager extends LocalAddonManager<LocalModFile> {
}
}
public void analyze() throws IOException {
lock.lock();
try {
gameVersion = instance.getVersion().toString();
analyzer = instance.getAnalyzer();
} catch (NoSuchGameInstanceException e) {
throw new IOException(e);
} finally {
lock.unlock();
}
}
@Override
public void refresh() throws IOException {
lock.lock();
@@ -183,7 +221,7 @@ public final class ModManager extends LocalAddonManager<LocalModFile> {
localFiles.clear();
localMods.clear();
analyzer = instance.getAnalyzer();
analyze();
boolean supportSubfolders = analyzer.has(GameComponentType.FORGE)
|| analyzer.has(GameComponentType.QUILT)
@@ -205,6 +243,9 @@ public final class ModManager extends LocalAddonManager<LocalModFile> {
}
}
}
updateSupportedLoaders();
loaded = true;
} finally {
lock.unlock();
@@ -254,6 +295,50 @@ public final class ModManager extends LocalAddonManager<LocalModFile> {
}
}
private void updateSupportedLoaders() {
supportedLoaders.clear();
if (this.analyzer == null) {
Collections.addAll(supportedLoaders, ModLoaderType.values());
return;
}
for (GameComponentType type : GameComponentType.values()) {
if (type.isModLoader() && this.analyzer.has(type)) {
ModLoaderType modLoaderType = type.getModLoaderType();
if (modLoaderType != null) {
supportedLoaders.add(modLoaderType);
if (modLoaderType == ModLoaderType.CLEANROOM)
supportedLoaders.add(ModLoaderType.FORGE);
}
}
}
if (this.analyzer.has(GameComponentType.NEO_FORGE) && "1.20.1".equals(gameVersion)) {
supportedLoaders.add(ModLoaderType.FORGE);
}
if (this.analyzer.has(GameComponentType.QUILT)) {
supportedLoaders.add(ModLoaderType.FABRIC);
}
if (this.analyzer.has(GameComponentType.LEGACY_FABRIC)) {
supportedLoaders.add(ModLoaderType.FABRIC);
}
if (this.analyzer.has(GameComponentType.FABRIC) && hasMod("kilt", ModLoaderType.FABRIC)) {
supportedLoaders.add(ModLoaderType.FORGE);
supportedLoaders.add(ModLoaderType.NEO_FORGE);
}
// Sinytra Connector
if (this.analyzer.has(GameComponentType.NEO_FORGE) && (hasMod("connector", ModLoaderType.NEO_FORGE) || hasMod("connectormod", ModLoaderType.NEO_FORGE))
|| "1.20.1".equals(gameVersion) && this.analyzer.has(GameComponentType.FORGE) && hasMod("connectormod", ModLoaderType.FORGE)) {
supportedLoaders.add(ModLoaderType.FABRIC);
}
}
public void rollback(LocalModFile from, LocalModFile to) throws IOException {
lock.lock();
try {
@@ -18,17 +18,23 @@
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.schematic.LitematicaConfig;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.platform.Platform;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.EnumSet;
import java.util.Optional;
import java.util.Set;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Provides a view of a game instance and its instance-specific paths within a
/// [GameRepositorySnapshot].
///
@@ -160,7 +166,25 @@ public interface GameInstance {
///
/// @return the schematics directory below the run directory
default Path getSchematicsDirectory() {
return getRunDirectory().resolve("schematics");
var runDir = getRunDirectory();
var litematicaConfig = runDir.resolve("config").resolve("litematica.json");
Path dir = runDir.resolve("schematics");
if (Files.isRegularFile(litematicaConfig)) {
try {
var conf = JsonUtils.fromJsonFile(litematicaConfig, LitematicaConfig.class);
if (conf != null
&& conf.generic() != null
&& conf.generic().customSchematicBaseDirectoryEnabled()
&& StringUtils.isNotBlank(conf.generic().customSchematicBaseDirectory())
) {
var p = Path.of(conf.generic().customSchematicBaseDirectory());
dir = p.isAbsolute() ? p.normalize() : runDir.resolve(p).normalize();
}
} catch (Exception e) {
LOG.warning("Failed to load custom schematics directory from litematica config at '%s'".formatted(litematicaConfig), e);
}
}
return dir;
}
/// Returns the directory used for extracted native libraries for a platform.
@@ -59,7 +59,6 @@ public interface ModAdviser {
"irisUpdateInfo.json", // Iris
"modernfix", // ModernFix
"modtranslations", // Mod translations
"schematics", // Schematics mod
"journeymap/data", // JourneyMap
"mods/.connector" // Sinytra Connector
);
@@ -70,6 +69,7 @@ public interface ModAdviser {
"blueprints" /* BuildCraft */,
"optionsof.txt" /* OptiFine */,
"journeymap" /* JourneyMap */,
"schematics" /* Schematics mod */,
"optionsshaders.txt",
"mods/VoxelMods");
@@ -17,33 +17,31 @@
*/
package org.jackhuang.hmcl.schematic;
import javafx.geometry.Point3D;
import org.glavo.nbt.io.NBTCodec;
import org.glavo.nbt.tag.*;
import org.glavo.nbt.tag.CompoundTag;
import org.glavo.nbt.tag.IntArrayTag;
import org.glavo.nbt.tag.IntTag;
import org.glavo.nbt.tag.Tag;
import org.jackhuang.hmcl.util.Point3I;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.zip.GZIPInputStream;
import java.util.OptionalInt;
import java.util.OptionalLong;
/// @author Glavo
/// @see <a href="https://litemapy.readthedocs.io/en/v0.9.0b0/litematics.html">The Litematic file format</a>
public final class LitematicFile {
import static org.jackhuang.hmcl.util.NBTUtils.*;
private static @Nullable String tryGetString(Tag tag) {
return tag instanceof StringTag stringTag ? stringTag.get() : null;
}
/**
* @author Glavo
* @see <a href="https://litemapy.readthedocs.io/en/v0.9.0b0/litematics.html">The Litematic file format</a>
*/
public final class LitematicFile extends Schematic {
public static LitematicFile load(Path file) throws IOException {
CompoundTag root;
try (InputStream in = new GZIPInputStream(Files.newInputStream(file))) {
root = NBTCodec.of().readTag(in, TagType.COMPOUND);
}
CompoundTag root = readCompressed(file);
Tag versionTag = root.get("Version");
if (versionTag == null)
@@ -61,19 +59,29 @@ public final class LitematicFile {
if (root.get("Regions") instanceof CompoundTag regionsTag)
regions = regionsTag.size();
Point3I enclosingSize = null;
Tag enclosingSizeTag = ((CompoundTag) metadataTag).get("EnclosingSize");
if (enclosingSizeTag instanceof CompoundTag) {
CompoundTag list = (CompoundTag) enclosingSizeTag;
int x = tryGetInt(list.get("x")).orElse(0);
int y = tryGetInt(list.get("y")).orElse(0);
int z = tryGetInt(list.get("z")).orElse(0);
if (x > 0 && y > 0 && z > 0) enclosingSize = new Point3I(x, y, z);
}
Tag subVersionTag = root.get("SubVersion");
return new LitematicFile(file, (CompoundTag) metadataTag,
((IntTag) versionTag).getValue(),
root.getIntOrZero("SubVersion"),
root.getIntOrZero("MinecraftDataVersion"),
regions
((IntTag) versionTag).get(),
tryGetInt(subVersionTag).orElse(-1),
tryGetInt(root.get("MinecraftDataVersion")).orElse(0),
regions,
enclosingSize
);
}
private final @NotNull Path file;
private final int version;
private final int subVersion;
private final int minecraftDataVersion;
private final int regionCount;
private final int[] previewImageData;
private final String name;
@@ -83,14 +91,12 @@ public final class LitematicFile {
private final Instant timeModified;
private final int totalBlocks;
private final int totalVolume;
private final Point3D enclosingSize;
private LitematicFile(@NotNull Path file, @NotNull CompoundTag metadata,
int version, int subVersion, int minecraftDataVersion, int regionCount) {
this.file = file;
int version, int subVersion, int minecraftDataVersion, int regionCount, Point3I enclosingSize) {
super(file, minecraftDataVersion, enclosingSize);
this.version = version;
this.subVersion = subVersion;
this.minecraftDataVersion = minecraftDataVersion;
this.regionCount = regionCount;
Tag previewImageData = metadata.get("PreviewImageData");
@@ -101,48 +107,39 @@ public final class LitematicFile {
this.name = tryGetString(metadata.get("Name"));
this.author = tryGetString(metadata.get("Author"));
this.description = tryGetString(metadata.get("Description"));
this.timeCreated = metadata.get("TimeCreated") instanceof LongTag time ? Instant.ofEpochMilli(time.getValue()) : null;
this.timeModified = metadata.get("TimeModified") instanceof LongTag time ? Instant.ofEpochMilli(time.getValue()) : null;
this.totalBlocks = metadata.getIntOrZero("TotalBlocks");
this.totalVolume = metadata.getIntOrZero("TotalVolume");
Point3D enclosingSize = null;
if (metadata.get("EnclosingSize") instanceof CompoundTag list) {
int x = list.getIntOrZero("x");
int y = list.getIntOrZero("y");
int z = list.getIntOrZero("z");
if (x >= 0 && y >= 0 && z >= 0)
enclosingSize = new Point3D(x, y, z);
}
this.enclosingSize = enclosingSize;
this.timeCreated = tryGetLongTimestamp(metadata.get("TimeCreated"));
this.timeModified = tryGetLongTimestamp(metadata.get("TimeModified"));
this.totalBlocks = tryGetInt(metadata.get("TotalBlocks")).orElse(-1);
this.totalVolume = tryGetInt(metadata.get("TotalVolume")).orElse(-1);
}
public @NotNull Path getFile() {
return file;
@Override
public SchematicType getType() {
return SchematicType.LITEMATIC;
}
public int getVersion() {
return version;
@Override
public OptionalInt getVersion() {
return OptionalInt.of(version);
}
public int getSubVersion() {
return subVersion;
}
public int getMinecraftDataVersion() {
return minecraftDataVersion;
@Override
public OptionalInt getSubVersion() {
return subVersion >= 0 ? OptionalInt.of(subVersion) : OptionalInt.empty();
}
@Override
public int[] getPreviewImageData() {
return previewImageData != null ? previewImageData.clone() : null;
}
public String getName() {
return name;
@Override
public @NotNull String getName() {
return StringUtils.isNotBlank(name) ? name : super.getName();
}
@Override
public String getAuthor() {
return author;
}
@@ -151,27 +148,29 @@ public final class LitematicFile {
return description;
}
@Override
public Instant getTimeCreated() {
return timeCreated;
}
@Override
public Instant getTimeModified() {
return timeModified;
}
public int getTotalBlocks() {
return totalBlocks;
@Override
public OptionalInt getTotalBlocks() {
return totalBlocks >= 1 ? OptionalInt.of(totalBlocks) : OptionalInt.empty();
}
public int getTotalVolume() {
return totalVolume;
@Override
public OptionalLong getTotalVolume() {
return totalVolume >= 1 ? OptionalLong.of(totalVolume) : OptionalLong.empty();
}
public Point3D getEnclosingSize() {
return enclosingSize;
@Override
public OptionalInt getRegionCount() {
return regionCount >= 1 ? OptionalInt.of(regionCount) : OptionalInt.empty();
}
public int getRegionCount() {
return regionCount;
}
}
@@ -0,0 +1,25 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.schematic;
import com.google.gson.annotations.SerializedName;
public record LitematicaConfig(@SerializedName("Generic") Generic generic) {
public record Generic(boolean customSchematicBaseDirectoryEnabled, String customSchematicBaseDirectory) {
}
}
@@ -0,0 +1,83 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.schematic;
import org.glavo.nbt.tag.CompoundTag;
import org.glavo.nbt.tag.ListTag;
import org.glavo.nbt.tag.Tag;
import org.jackhuang.hmcl.util.NBTUtils;
import org.jackhuang.hmcl.util.Point3I;
import java.io.IOException;
import java.nio.file.Path;
import static org.jackhuang.hmcl.util.NBTUtils.tryGetInt;
/// @author Calboot
/// @see <a href="https://minecraft.wiki/w/Structure_file">Structure File</a>
public final class NBTStructureFile extends Schematic {
public static NBTStructureFile load(Path file) throws IOException {
CompoundTag root = NBTUtils.readCompressed(file);
if (!(root.get("blocks") instanceof ListTag<?>))
throw new IOException("blocks tag not found");
if (!(root.get("entities") instanceof ListTag<?>))
throw new IOException("entities tag not found");
Tag sizeTag = root.get("size");
if (sizeTag == null)
throw new IOException("size tag not found");
else if (!(sizeTag instanceof ListTag<?>))
throw new IOException("size tag is not a list");
ListTag<?> size = (ListTag<?>) sizeTag;
if (size.size() != 3)
throw new IOException("size tag does not have 3 elements");
Tag xTag = size.getTag(0);
Tag yTag = size.getTag(1);
Tag zTag = size.getTag(2);
Point3I enclosingSize = null;
int width = tryGetInt(xTag).orElse(0);
int height = tryGetInt(yTag).orElse(0);
int length = tryGetInt(zTag).orElse(0);
if (width > 0 && height > 0 && length > 0) {
enclosingSize = new Point3I(width, height, length);
}
return new NBTStructureFile(file, tryGetInt(root.get("DataVersion")).orElse(500), NBTUtils.tryGetString(root.get("author")), enclosingSize);
}
private final String author;
private NBTStructureFile(Path file, int dataVersion, String author, Point3I enclosingSize) {
super(file, dataVersion, enclosingSize);
this.author = author;
}
@Override
public SchematicType getType() {
return SchematicType.NBT_STRUCTURE;
}
@Override
public String getAuthor() {
return author;
}
}
@@ -0,0 +1,152 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.schematic;
import org.glavo.nbt.tag.CompoundTag;
import org.glavo.nbt.tag.IntTag;
import org.glavo.nbt.tag.StringTag;
import org.glavo.nbt.tag.Tag;
import org.jackhuang.hmcl.util.NBTUtils;
import org.jackhuang.hmcl.util.Point3I;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
import java.util.OptionalInt;
import static org.jackhuang.hmcl.util.NBTUtils.tryGetShort;
import static org.jackhuang.hmcl.util.NBTUtils.tryGetString;
/// @author Calboot
/// @see <a href="https://minecraft.wiki/w/Schematic_file_format">Schematic File Format Wiki</a>
/// @see <a href="https://github.com/Lunatrius/Schematica/blob/master/src/main/java/com/github/lunatrius/schematica/world/schematic/SchematicAlpha.java">Schematica</a>
/// @see <a href="https://github.com/SpongePowered/Schematic-Specification/tree/master/versions">Sponge Spec</a>
public final class SchemFile extends Schematic {
private static final int DATA_VERSION_MC_1_13_2 = 1631;
public static SchemFile load(Path file) throws IOException {
CompoundTag root = NBTUtils.readCompressed(file);
if (root.get("Materials") != null) return loadLegacy(file, root);
else if (root.get("Version") != null) return loadSponge(file, root);
else {
var schem = root.get("Schematic");
if (schem instanceof CompoundTag sponge3 && sponge3.get("Version") != null) {
return loadSponge(file, sponge3);
}
}
throw new IOException("No Materials tag or Version tag found");
}
private static SchemFile loadLegacy(Path file, CompoundTag root) throws IOException {
Tag materialsTag = root.get("Materials");
if (!(materialsTag instanceof StringTag))
throw new IOException("Materials tag is not a string");
Tag widthTag = root.get("Width");
Tag heightTag = root.get("Height");
Tag lengthTag = root.get("Length");
Point3I enclosingSize = null;
if (widthTag != null && heightTag != null && lengthTag != null) {
short width = tryGetShort(widthTag).orElse((short) 0);
short height = tryGetShort(heightTag).orElse((short) 0);
short length = tryGetShort(lengthTag).orElse((short) 0);
if (width > 0 && height > 0 && length > 0) {
enclosingSize = new Point3I(width, height, length);
}
}
return new SchemFile(file, null, null, ((StringTag) materialsTag).getValue(), 0, 0, enclosingSize);
}
private static SchemFile loadSponge(Path file, CompoundTag root) throws IOException {
Tag versionTag = root.get("Version");
if (!(versionTag instanceof IntTag))
throw new IOException("Version tag is not an integer");
Tag dataVersionTag = root.get("DataVersion");
int dataVersion;
dataVersion = dataVersionTag == null ? DATA_VERSION_MC_1_13_2 : NBTUtils.tryGetInt(dataVersionTag).orElse(0);
Tag widthTag = root.get("Width");
Tag heightTag = root.get("Height");
Tag lengthTag = root.get("Length");
Point3I enclosingSize = null;
if (widthTag != null && heightTag != null && lengthTag != null) {
int width = tryGetShort(widthTag).orElse((short) 0) & 0xFFFF;
int height = tryGetShort(heightTag).orElse((short) 0) & 0xFFFF;
int length = tryGetShort(lengthTag).orElse((short) 0) & 0xFFFF;
if (width > 0 && height > 0 && length > 0) {
enclosingSize = new Point3I(width, height, length);
}
}
Tag metaTag = root.get("Metadata");
String name = null, author = null;
if (metaTag instanceof CompoundTag meta) {
name = tryGetString(meta.get("Name"));
author = tryGetString(meta.get("Author"));
}
return new SchemFile(file, name, author, null, dataVersion, ((IntTag) versionTag).getValue(), enclosingSize);
}
private final @Nullable String name;
private final @Nullable String author;
private final @Nullable String materials;
private final int version;
private SchemFile(Path file, @Nullable String name, @Nullable String author, @Nullable String materials, int dataVersion, int version, Point3I enclosingSize) {
super(file, dataVersion, enclosingSize);
this.name = name;
this.author = author;
this.materials = materials;
this.version = version;
}
@Override
public @NotNull String getName() {
if (name == null) return super.getName();
return name;
}
@Override
public String getAuthor() {
if (author == null) return super.getAuthor();
return author;
}
@Override
public SchematicType getType() {
return SchematicType.SCHEM;
}
@Override
public OptionalInt getVersion() {
return version > 0 ? OptionalInt.of(version) : OptionalInt.empty();
}
@Override
public String getMinecraftVersion() {
return materials == null ? super.getMinecraftVersion() : materials;
}
}
@@ -0,0 +1,128 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.schematic;
import org.jackhuang.hmcl.util.Point3I;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
import java.time.Instant;
import java.util.OptionalInt;
import java.util.OptionalLong;
public sealed abstract class Schematic permits LitematicFile, SchemFile, NBTStructureFile {
public static boolean isFileSchematic(Path file) {
return SchematicType.getType(file) != null;
}
@Nullable
public static Schematic load(Path file) throws IOException {
var type = SchematicType.getType(file);
if (type == null) return null;
return switch (type) {
case LITEMATIC -> LitematicFile.load(file);
case SCHEM -> SchemFile.load(file);
case NBT_STRUCTURE -> NBTStructureFile.load(file);
};
}
private final Path file;
private final int dataVersion;
private final Point3I enclosingSize;
protected Schematic(Path file, int dataVersion, @Nullable Point3I enclosingSize) {
this.file = file;
this.dataVersion = dataVersion;
this.enclosingSize = enclosingSize;
}
public abstract SchematicType getType();
@NotNull
public Path getFile() {
return file;
}
public OptionalInt getVersion() {
return OptionalInt.empty();
}
/// Non-negative, otherwise empty
public OptionalInt getSubVersion() {
return OptionalInt.empty();
}
/// At least 100, otherwise empty
public OptionalInt getMinecraftDataVersion() {
/* 15w32a */
return dataVersion >= 100 ? OptionalInt.of(dataVersion) : OptionalInt.empty();
}
@Nullable
public String getMinecraftVersion() {
return getMinecraftDataVersion().isPresent() ? Integer.toString(getMinecraftDataVersion().getAsInt()) : null;
}
@NotNull
public String getName() {
return FileUtils.getNameWithoutExtension(getFile());
}
@Nullable
public String getAuthor() {
return null;
}
@Nullable
public Instant getTimeCreated() {
return null;
}
@Nullable
public Instant getTimeModified() {
return null;
}
public OptionalInt getRegionCount() {
return OptionalInt.empty();
}
public OptionalInt getTotalBlocks() {
return OptionalInt.empty();
}
@Nullable
public Point3I getEnclosingSize() {
return enclosingSize;
}
public OptionalLong getTotalVolume() {
var enclosingSize = getEnclosingSize();
if (enclosingSize != null) return OptionalLong.of((long) enclosingSize.x() * enclosingSize.y() * enclosingSize.z());
return OptionalLong.empty();
}
public int @Nullable [] getPreviewImageData() {
return null;
}
}
@@ -0,0 +1,45 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.schematic;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public enum SchematicType {
LITEMATIC("litematic"),
NBT_STRUCTURE("nbt"),
SCHEM("schematic", "schem");
public static SchematicType getType(Path file) {
if (file == null || !Files.isRegularFile(file)) return null;
String ext = FileUtils.getExtension(file);
for (SchematicType type : values()) {
if (type.extensions.contains(ext)) return type;
}
return null;
}
public final List<String> extensions;
SchematicType(String... ext) {
this.extensions = List.of(ext);
}
}
@@ -0,0 +1,79 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util;
import org.glavo.nbt.io.NBTCodec;
import org.glavo.nbt.tag.*;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.OptionalLong;
public final class NBTUtils {
public static CompoundTag readCompressed(Path file) throws IOException {
return NBTCodec.of().readTag(file, TagType.COMPOUND);
}
public static OptionalLong tryGetLong(Tag tag) {
if (tag instanceof ValueTag<?> valueTag) {
Object val = valueTag.getValue();
if (val instanceof Long || val instanceof Integer || val instanceof Short || val instanceof Byte) {
return OptionalLong.of(((Number) val).longValue());
}
}
return OptionalLong.empty();
}
public static OptionalInt tryGetInt(Tag tag) {
var ol = tryGetLong(tag);
if (ol.isEmpty()) return OptionalInt.empty();
long l = ol.getAsLong();
int i = (int) l;
if (l == i) return OptionalInt.of(i);
return OptionalInt.empty();
}
public static Optional<Short> tryGetShort(Tag tag) {
var ol = tryGetLong(tag);
if (ol.isEmpty()) return Optional.empty();
long l = ol.getAsLong();
short i = (short) l;
if (l == i) return Optional.of(i);
return Optional.empty();
}
public static @Nullable Instant tryGetLongTimestamp(Tag tag) {
if (tag instanceof LongTag longTag) {
return Instant.ofEpochMilli(longTag.get());
}
return null;
}
public static @Nullable String tryGetString(Tag tag) {
return tag instanceof StringTag stringTag ? stringTag.get() : null;
}
private NBTUtils() {
}
}
@@ -0,0 +1,24 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util;
public record Point3I(int x, int y, int z) {
public static final Point3I ZERO = new Point3I(0, 0, 0);
}
@@ -1,53 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.schematic;
import javafx.geometry.Point3D;
import org.jackhuang.hmcl.game.CrashReportAnalyzerTest;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Paths;
import java.time.Instant;
import static org.junit.jupiter.api.Assertions.assertEquals;
public final class LitematicFileTest {
private static LitematicFile load(String name) throws IOException, URISyntaxException {
URL resource = CrashReportAnalyzerTest.class.getResource(name);
if (resource == null)
throw new IOException("Resource not found: " + name);
return LitematicFile.load(Paths.get(resource.toURI()));
}
@Test
public void test() throws Exception {
LitematicFile file = load("/schematics/test.litematic");
assertEquals("刷石机一桶岩浆下推爆破8.3万每小时", file.getName());
assertEquals("hsds", file.getAuthor());
assertEquals("", file.getDescription());
assertEquals(Instant.ofEpochMilli(1746443586433L), file.getTimeCreated());
assertEquals(Instant.ofEpochMilli(1746443586433L), file.getTimeModified());
assertEquals(1334, file.getTotalBlocks());
assertEquals(5746, file.getTotalVolume());
assertEquals(new Point3D(17, 26, 13), file.getEnclosingSize());
assertEquals(1, file.getRegionCount());
}
}
@@ -0,0 +1,88 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.schematic;
import org.jackhuang.hmcl.game.CrashReportAnalyzerTest;
import org.jackhuang.hmcl.util.Point3I;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Paths;
import java.time.Instant;
import static org.junit.jupiter.api.Assertions.assertEquals;
public final class SchematicTest {
private static Schematic load(String name) throws IOException, URISyntaxException {
URL resource = CrashReportAnalyzerTest.class.getResource(name);
if (resource == null)
throw new IOException("Resource not found: " + name);
return Schematic.load(Paths.get(resource.toURI()));
}
@Test
public void test() throws Exception {
{
LitematicFile lFile = (LitematicFile) load("/schematics/test.litematic");
assertEquals(SchematicType.LITEMATIC, lFile.getType());
assertEquals("刷石机一桶岩浆下推爆破8.3万每小时", lFile.getName());
assertEquals("hsds", lFile.getAuthor());
assertEquals("", lFile.getDescription());
assertEquals(Instant.ofEpochMilli(1746443586433L), lFile.getTimeCreated());
assertEquals(Instant.ofEpochMilli(1746443586433L), lFile.getTimeModified());
assertEquals(1334, lFile.getTotalBlocks().orElse(0));
assertEquals(5746, lFile.getTotalVolume().orElse(0));
assertEquals(new Point3I(17, 26, 13), lFile.getEnclosingSize());
assertEquals(1, lFile.getRegionCount().orElse(0));
assertEquals(4325, lFile.getMinecraftDataVersion().orElse(0));
assertEquals("4325", lFile.getMinecraftVersion());
assertEquals(7, lFile.getVersion().orElse(0));
assertEquals(1, lFile.getSubVersion().orElse(-1));
}
{
SchemFile sFile = (SchemFile) load("/schematics/test.schematic");
assertEquals(SchematicType.SCHEM, sFile.getType());
assertEquals("test", sFile.getName());
assertEquals(new Point3I(28, 35, 18), sFile.getEnclosingSize());
assertEquals(17640, sFile.getTotalVolume().orElse(0));
assertEquals("Alpha", sFile.getMinecraftVersion());
}
{
SchemFile sFileSponge = (SchemFile) load("/schematics/test.schem");
assertEquals(SchematicType.SCHEM, sFileSponge.getType());
assertEquals("test", sFileSponge.getName());
assertEquals(3465, sFileSponge.getMinecraftDataVersion().orElse(0));
assertEquals("3465", sFileSponge.getMinecraftVersion());
assertEquals(new Point3I(9, 5, 9), sFileSponge.getEnclosingSize());
}
{
NBTStructureFile nFile = (NBTStructureFile) load("/schematics/test.nbt");
assertEquals(SchematicType.NBT_STRUCTURE, nFile.getType());
assertEquals("test", nFile.getName());
assertEquals(new Point3I(9, 11, 13), nFile.getEnclosingSize());
assertEquals(1287, nFile.getTotalVolume().orElse(0));
assertEquals(3465, nFile.getMinecraftDataVersion().orElse(0));
assertEquals("3465", nFile.getMinecraftVersion());
}
}
}
Binary file not shown.
Binary file not shown.