From b37e9785857fffca204ee06657493c49b6c3faaa Mon Sep 17 00:00:00 2001 From: Axtel Sturnclaw Date: Sun, 12 Apr 2026 23:11:36 -0400 Subject: [PATCH] v2.0 - EA Release support - Support appending mod version number to filename. - Default to .vmz filename extension. - Allow exporting directly to a location of the user's choice (installed mods folder). - Save output folder location between sessions. - Redesign UI to use new EditorDock API and dock vertically. --- LICENSE | 2 +- README.md | 17 +- plugin.cfg | 8 +- plugin.gd | 558 ++++++++++++++++++++++++++++++++--------------------- 4 files changed, 349 insertions(+), 236 deletions(-) diff --git a/LICENSE b/LICENSE index 768aa81..2c14f01 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Ryhon +Copyright (c) 2026 Ryhon, AxtelSturnclaw Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index c4e4049..5dd2c4f 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,20 @@ -# Mod Zip Exporter -Exports the selected folder to a `.zip` for use with mods. +# Vostok Mod Exporter +Exports the selected folder to a `.vmz` for use with mods. Exports the imported assets and converts text resources to binary resources. Unlike exporting through the editor with only selected resources and scenes, it doesn't pull in any unnecessary dependencies like the splash screen, autoloads and any resources referenced by your files outside of the selected directory. + + +To install, create a `res://addons/mod_exporter` directory, and unzip the latest release into that directory. + Enable the plugin in Project > Project Settings... > Plugins. -A new panel called "Mod" will be created at the bottom of the screen, it will scan for any files named `mod.txt` and add them to the list of mods. +A new panel called "Mod" will be created at the bottom of the screen, it will scan for any folders containing a `mod.txt` file and add them to the list of mods. Remaps for existing files can be defined by creating a `[remaps]` section in the mod.txt you are exporting. The target path will automatically be resolved to the imported asset path. Example: -```conf +```ini [remaps] -"res://MyFile.tres"="res://mods/MyMod/ModdedFile.tres" -``` \ No newline at end of file +"res://GameFile.tres"="res://mods/MyMod/ModdedFile.tres" +``` diff --git a/plugin.cfg b/plugin.cfg index 486445c..101fab6 100644 --- a/plugin.cfg +++ b/plugin.cfg @@ -1,7 +1,7 @@ [plugin] -name="ModExporter" -description="Exprot mods" -author="Ryhon" -version="1.0" +name="Vostok Mod Exporter" +description="Export mods in Vostok Mod Zip (.vmz) format" +author="Ryhon & AxtelSturnclaw" +version="2.0" script="plugin.gd" diff --git a/plugin.gd b/plugin.gd index 7c14d58..63623e3 100644 --- a/plugin.gd +++ b/plugin.gd @@ -1,7 +1,7 @@ @tool extends EditorPlugin -var dock: Control +var dock: EditorDock var dockBtn : Button var projectSelect: OptionButton var dirline: LineEdit @@ -11,251 +11,359 @@ var progressLabel: Label var currentLabel: Label var detectedProjects: Array[String] var compiledRemaps: Dictionary +var exportTextTimer: SceneTreeTimer + +var output_dir_setting := "vostok_mod_exporter/output_path" +var version_setting := "vostok_mod_exporter/include_version_in_filename" + +func _build_default_filename(mod_path: String): + var filename = mod_path.get_file() + + if EditorInterface.get_editor_settings().get_setting(version_setting): + + var modCfg := ConfigFile.new() + modCfg.load(mod_path.path_join("mod.txt")) + + var version = modCfg.get_value("mod", "version") + if version: filename += "-" + version + + return filename + ".vmz" func _enter_tree() -> void: - dock = VBoxContainer.new() - - var inputBox = HBoxContainer.new() - dock.add_child(inputBox) - - var projectScanBtn = Button.new() - projectScanBtn.text = "Scan" - projectScanBtn.pressed.connect(func(): scanProjects()) - inputBox.add_child(projectScanBtn) - - projectSelect = OptionButton.new() - projectSelect.item_selected.connect(func(index: int): - dirline.text = detectedProjects[index] - fileline.text = detectedProjects[index].get_file() + ".zip" - ) - inputBox.add_child(projectSelect) - - dirline = LineEdit.new() - dirline.placeholder_text = "res://mods/MyMod" - dirline.size_flags_horizontal = Control.SIZE_EXPAND_FILL - inputBox.add_child(dirline) - - var fileDialogBtn = Button.new() - fileDialogBtn.text = "..." - fileDialogBtn.pressed.connect(func(): - var fd = FileDialog.new() - fd.size = Vector2(700,400) - fd.title = "Select mod folder" - fd.file_mode = FileDialog.FILE_MODE_OPEN_DIR - fd.access = FileDialog.ACCESS_RESOURCES - fd.dir_selected.connect(func(dir): dirline.text = dir) - fd.canceled.connect(func(): fd.queue_free()) - fd.close_requested.connect(func(): fd.queue_free()) - - add_child(fd) - fd.popup_centered() - ) - inputBox.add_child(fileDialogBtn) - - fileline = LineEdit.new() - fileline.placeholder_text = "mod.zip" - fileline.custom_minimum_size = Vector2(200, 0) - inputBox.add_child(fileline) - - var btn = Button.new() - btn.text = "Export!" - btn.custom_minimum_size = Vector2(100, 0) - btn.pressed.connect(exportZip) - inputBox.add_child(btn) - - var progressBox = HBoxContainer.new() - dock.add_child(progressBox) - - progressBar = ProgressBar.new() - progressBar.size_flags_horizontal = Control.SIZE_EXPAND_FILL - progressBox.add_child(progressBar) - - progressLabel = Label.new() - progressLabel.custom_minimum_size = Vector2(100, 0) - progressBox.add_child(progressLabel) - - currentLabel = Label.new() - currentLabel.text = "Select mod folder, enter zip name and press Export!" - dock.add_child(currentLabel) - - scanProjects() - - dockBtn = add_control_to_bottom_panel(dock, "Mod") + dock = EditorDock.new() + dock.default_slot = EditorDock.DOCK_SLOT_LEFT_BR + dock.size_flags_vertical = Control.SIZE_SHRINK_END + dock.title = "Vostok Exporter" + + var container = VBoxContainer.new() + dock.add_child(container) + + var header = Label.new() + header.text = "Select mod folder to export:" + header.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + container.add_child(header) + + var inputBox = HBoxContainer.new() + container.add_child(inputBox) + + var projectScanBtn = Button.new() + projectScanBtn.text = "Scan" + projectScanBtn.pressed.connect(func(): scanProjects()) + inputBox.add_child(projectScanBtn) + + projectSelect = OptionButton.new() + projectSelect.size_flags_horizontal = Control.SIZE_EXPAND_FILL + projectSelect.item_selected.connect(func(index: int): + dirline.text = detectedProjects[index] + fileline.text = _build_default_filename(detectedProjects[index]) + ) + inputBox.add_child(projectSelect) + + var fileDialogBtn = Button.new() + fileDialogBtn.text = "..." + fileDialogBtn.pressed.connect(func(): + var fd := FileDialog.new() + fd.size = Vector2(700,400) + fd.title = "Select mod folder" + fd.file_mode = FileDialog.FILE_MODE_OPEN_DIR + fd.access = FileDialog.ACCESS_RESOURCES + fd.dir_selected.connect(func(dir): dirline.text = dir) + fd.canceled.connect(func(): fd.queue_free()) + fd.close_requested.connect(func(): fd.queue_free()) + + add_child(fd) + fd.popup_centered() + ) + inputBox.add_child(fileDialogBtn) + + dirline = LineEdit.new() + dirline.placeholder_text = "res://mods/MyMod" + dirline.size_flags_horizontal = Control.SIZE_EXPAND_FILL + container.add_child(dirline) + + container.add_child(HSeparator.new()) + var outputBox = HBoxContainer.new() + outputBox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + container.add_child(outputBox) + + var outputHeader = Label.new() + outputHeader.text = "Output filename:" + outputHeader.size_flags_horizontal = Control.SIZE_EXPAND + outputBox.add_child(outputHeader) + + var version_checkbox = CheckBox.new() + version_checkbox.text = "Include Version Number" + version_checkbox.size_flags_horizontal = Control.SIZE_SHRINK_END + version_checkbox.button_pressed = EditorInterface.get_editor_settings().get_setting(version_setting) + version_checkbox.toggled.connect(func(active): + EditorInterface.get_editor_settings().set_setting(version_setting, active) + fileline.text = _build_default_filename(dirline.text) + ) + outputBox.add_child(version_checkbox) + + fileline = LineEdit.new() + fileline.placeholder_text = "mod.zip" + fileline.custom_minimum_size = Vector2(200, 0) + fileline.expand_to_text_length = true + + container.add_child(fileline) + + var marginBox = MarginContainer.new() + marginBox.add_theme_constant_override("margin_top", 6) + marginBox.add_theme_constant_override("margin_bottom", 6) + marginBox.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + container.add_child(marginBox) + + var exportBox = HBoxContainer.new() + marginBox.add_child(exportBox) + + var outputDirBtn = Button.new() + outputDirBtn.text = "Select Output Folder" + outputDirBtn.pressed.connect(func(): + var fd := FileDialog.new() + fd.size = Vector2(700, 400) + fd.title = "Select output folder" + fd.file_mode = FileDialog.FILE_MODE_OPEN_DIR + fd.access = FileDialog.ACCESS_FILESYSTEM + + var path = EditorInterface.get_editor_settings().get_setting(output_dir_setting) + fd.current_dir = path if path && !path.is_empty() else ProjectSettings.globalize_path("res://") + + fd.dir_selected.connect(func(dir): + EditorInterface.get_editor_settings().set_setting(output_dir_setting, dir) + ) + fd.canceled.connect(func(): fd.queue_free()) + fd.close_requested.connect(func(): fd.queue_free()) + + add_child(fd) + fd.popup_centered() + ) + exportBox.add_child(outputDirBtn) + + var btn = Button.new() + btn.text = "Export!" + btn.custom_minimum_size = Vector2(100, 0) + btn.pressed.connect(exportZip) + exportBox.add_child(btn) + + progressBar = ProgressBar.new() + progressBar.size_flags_horizontal = Control.SIZE_EXPAND_FILL + progressBar.size_flags_vertical = Control.SIZE_SHRINK_CENTER + container.add_child(progressBar) + + var progressBox = HBoxContainer.new() + container.add_child(progressBox) + + progressLabel = Label.new() + progressLabel.visible = false + progressBox.add_child(progressLabel) + + currentLabel = Label.new() + currentLabel.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + currentLabel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + currentLabel.visible = false + progressBox.add_child(currentLabel) + + scanProjects() + + add_dock(dock) + +func _exit_tree(): + remove_dock(dock) + dock.queue_free() func scanProjects(): - projectSelect.clear() - detectedProjects.clear() + projectSelect.clear() + detectedProjects.clear() - scanDirForProjects("res://") + scanDirForProjects("res://") - if projectSelect.item_count > 0: - projectSelect.item_selected.emit(0) - + if projectSelect.item_count > 0: + projectSelect.item_selected.emit(0) + func scanDirForProjects(dir): - for d in DirAccess.get_directories_at(dir): - if FileAccess.file_exists(dir.path_join(d).path_join("mod.txt")): - detectedProjects.append(dir.path_join(d)) - projectSelect.add_item(d) - else: - scanDirForProjects(dir.path_join(d)) + for d in DirAccess.get_directories_at(dir): + if FileAccess.file_exists(dir.path_join(d).path_join("mod.txt")): + detectedProjects.append(dir.path_join(d)) + projectSelect.add_item(d) + else: + scanDirForProjects(dir.path_join(d)) var zipPaths = [] var customResourceHash = "" var files: Array[String] = [] func exportZip(): - currentLabel.modulate = Color.WHITE - compiledRemaps = {} - - var dir = dirline.text - var out = fileline.text - - var modCfgPath = null - var overrideCfgPath = dir.path_join("override.cfg") - if FileAccess.file_exists(dir.path_join("mod.txt")): - modCfgPath = dir.path_join("mod.txt") - - customResourceHash = DirAccess.get_directories_at("res://.godot/exported")[0] - files = [] - collectFiles(dir) - - zipPaths = [] - var zip = ZIPPacker.new() - zip.open("res://mods/" + out) - - var classList = ProjectSettings.get_global_class_list() - var modClassList : Array[Dictionary] = [] - - var i = 1 - for f in files: - currentLabel.text = "Exporting " + f + "..." - progressLabel.text = str(i) + "/" + str(files.size()) - progressBar.min_value = 0 - progressBar.step = 1 - progressBar.max_value = files.size() - progressBar.value = i - await get_tree().create_timer(0.01).timeout - - for c in classList: - if c.path == f: - modClassList.append(c) - break - - if f == overrideCfgPath: - zipAddFile(zip, f, "override.cfg") - elif f != modCfgPath: - addFile(zip, f) - - i += 1 - - if modClassList.size(): - currentLabel.text = "Writing class list..." - await get_tree().create_timer(0.01).timeout - var classListCfg = ConfigFile.new() - classListCfg.set_value("", "list", modClassList) - zipAddBuf(zip, ".godot/global_script_class_cache.cfg", classListCfg.encode_to_text().to_utf8_buffer()) - - currentLabel.text = "Writing mod.txt..." - await get_tree().create_timer(0.01).timeout - if modCfgPath: - var modcfg = ConfigFile.new() - modcfg.load(modCfgPath) - - # Store the remaps defined in the mod.txt remaps section - for src in modcfg.get_section_keys("remaps"): - var remapCfg = ConfigFile.new() - var override = modcfg.get_value("remaps", src) - override = compiledRemaps.get(override, override) - remapCfg.set_value("remap", "path", override) - zipAddBuf(zip, src + ".remap", remapCfg.encode_to_text().to_utf8_buffer()) - - # Remove the remaps section - modcfg.erase_section("remaps") - # Store the mod.txt - zipAddBuf(zip, "mod.txt", modcfg.encode_to_text().to_utf8_buffer()) - - zip.close() - currentLabel.text = "Done!" - currentLabel.modulate = Color.LIME - OS.shell_show_in_file_manager(ProjectSettings.globalize_path("res://mods/" + out)) + currentLabel.modulate = Color.WHITE + compiledRemaps = {} + + var dir = dirline.text + var out = fileline.text + + var modCfgPath = null + var overrideCfgPath = dir.path_join("override.cfg") + if FileAccess.file_exists(dir.path_join("mod.txt")): + modCfgPath = dir.path_join("mod.txt") + + var outDir = EditorInterface.get_editor_settings().get_setting(output_dir_setting) + if !outDir || outDir.is_empty(): + outDir = "res://mods/" + + DirAccess.make_dir_absolute(outDir) + + customResourceHash = DirAccess.get_directories_at("res://.godot/exported")[0] + files = [] + collectFiles(dir) + + zipPaths = [] + var zip = ZIPPacker.new() + zip.open(outDir.path_join(out)) + + var classList = ProjectSettings.get_global_class_list() + var modClassList : Array[Dictionary] = [] + + currentLabel.visible = true + progressLabel.visible = true + + var i = 1 + for f in files: + currentLabel.text = "Exporting " + f + "..." + progressLabel.text = str(i) + "/" + str(files.size()) + progressBar.min_value = 0 + progressBar.step = 1 + progressBar.max_value = files.size() + progressBar.value = i + await get_tree().create_timer(0.01).timeout + + for c in classList: + if c.path == f: + modClassList.append(c) + break + + if f == overrideCfgPath: + zipAddFile(zip, f, "override.cfg") + elif f != modCfgPath: + addFile(zip, f) + + i += 1 + + if modClassList.size(): + currentLabel.text = "Writing class list..." + await get_tree().create_timer(0.01).timeout + var classListCfg = ConfigFile.new() + classListCfg.set_value("", "list", modClassList) + zipAddBuf(zip, ".godot/global_script_class_cache.cfg", classListCfg.encode_to_text().to_utf8_buffer()) + + currentLabel.text = "Writing mod.txt..." + await get_tree().create_timer(0.01).timeout + if modCfgPath: + var modcfg = ConfigFile.new() + modcfg.load(modCfgPath) + + # Store the remaps defined in the mod.txt remaps section + if modcfg.get_sections().has("remaps"): + for src in modcfg.get_section_keys("remaps"): + var remapCfg = ConfigFile.new() + var override = modcfg.get_value("remaps", src) + override = compiledRemaps.get(override, override) + remapCfg.set_value("remap", "path", override) + zipAddBuf(zip, src + ".remap", remapCfg.encode_to_text().to_utf8_buffer()) + + # Remove the remaps section + modcfg.erase_section("remaps") + + # Store the mod.txt + zipAddBuf(zip, "mod.txt", modcfg.encode_to_text().to_utf8_buffer()) + + zip.close() + currentLabel.text = "%s exported!" % out #"Done!" + currentLabel.modulate = Color.LIME + OS.shell_show_in_file_manager(ProjectSettings.globalize_path(outDir.path_join(out))) + + if !exportTextTimer: + exportTextTimer = get_tree().create_timer(10) + + exportTextTimer.timeout.connect(func(): + currentLabel.visible = false + progressLabel.visible = false + exportTextTimer = null + ) + else: + exportTextTimer.time_left = 10 func collectFiles(dir: String): - for d in DirAccess.get_directories_at(dir): - if not d.ends_with(".git"): - collectFiles(dir.path_join(d)) - for f in DirAccess.get_files_at(dir): - if f == "mod.txt": continue - if dir.ends_with(".import"): continue - files.append(dir.path_join(f)) + for d in DirAccess.get_directories_at(dir): + if not d.ends_with(".git"): + collectFiles(dir.path_join(d)) + for f in DirAccess.get_files_at(dir): + if f == "mod.txt": continue + if dir.ends_with(".import"): continue + files.append(dir.path_join(f)) func addFile(zip: ZIPPacker, path: String): - var f: String = path.get_file() - var dir: String = path.trim_suffix(f) - - var importPath = dir.path_join(f + ".import") - if FileAccess.file_exists(importPath): - var fa = FileAccess.open(importPath, FileAccess.ModeFlags.READ) - var importCfg = ConfigFile.new() - importCfg.parse(fa.get_as_text()) - fa.close() - - # Store dest files - if importCfg.has_section_key("deps", "dest_files"): - for df in importCfg.get_value("deps", "dest_files"): - zipAddFile(zip, df) - - # Store the .import file - var remapCfg = ConfigFile.new() - for k in importCfg.get_section_keys("remap"): - if k == "generator_parameters": continue - remapCfg.set_value("remap", k, importCfg.get_value("remap", k)) - zipAddBuf(zip, dir.path_join(f + ".import"), remapCfg.encode_to_text().to_utf8_buffer()) - else: - # Convert text resources to binary - if f.ends_with(".tres") || f.ends_with(".tscn"): - # Convert to binary and store - var binaryName = f.trim_suffix(".tres").trim_suffix(".tscn") + (".scn" if f.ends_with(".tscn") else ".res") - var res: Resource = ResourceLoader.load(dir.path_join(f)) - var binOut = "res://.godot/exported".path_join(customResourceHash) \ - .path_join("export-" + dir.path_join(f).md5_text() + "-" + binaryName); - ResourceSaver.save(res, binOut) - zipAddFile(zip, binOut) - - # Save remap - var remapCfg = ConfigFile.new() - remapCfg.set_value("remap", "path", binOut) - compiledRemaps[path] = binOut - zipAddBuf(zip, dir.path_join(f + ".remap"), remapCfg.encode_to_text().to_utf8_buffer()) - else: - # Store the file raw - zipAddFile(zip, dir.path_join(f)) + var f: String = path.get_file() + var dir: String = path.trim_suffix(f) + + var importPath = dir.path_join(f + ".import") + if FileAccess.file_exists(importPath): + var fa = FileAccess.open(importPath, FileAccess.ModeFlags.READ) + var importCfg = ConfigFile.new() + importCfg.parse(fa.get_as_text()) + fa.close() + + # Store dest files + if importCfg.has_section_key("deps", "dest_files"): + for df in importCfg.get_value("deps", "dest_files"): + zipAddFile(zip, df) + + # Store the .import file + var remapCfg = ConfigFile.new() + for k in importCfg.get_section_keys("remap"): + if k == "generator_parameters": continue + remapCfg.set_value("remap", k, importCfg.get_value("remap", k)) + zipAddBuf(zip, dir.path_join(f + ".import"), remapCfg.encode_to_text().to_utf8_buffer()) + else: + # Convert text resources to binary + if f.ends_with(".tres") || f.ends_with(".tscn"): + # Convert to binary and store + var binaryName = f.trim_suffix(".tres").trim_suffix(".tscn") + (".scn" if f.ends_with(".tscn") else ".res") + var res: Resource = ResourceLoader.load(dir.path_join(f)) + var binOut = "res://.godot/exported".path_join(customResourceHash) \ + .path_join("export-" + dir.path_join(f).md5_text() + "-" + binaryName); + ResourceSaver.save(res, binOut) + zipAddFile(zip, binOut) + + # Save remap + var remapCfg = ConfigFile.new() + remapCfg.set_value("remap", "path", binOut) + compiledRemaps[path] = binOut + zipAddBuf(zip, dir.path_join(f + ".remap"), remapCfg.encode_to_text().to_utf8_buffer()) + else: + # Store the file raw + zipAddFile(zip, dir.path_join(f)) func zipAddBuf(zip: ZIPPacker, path: String, buf: PackedByteArray): - path = path.trim_prefix("res://") - if path in zipPaths: - return - - zip.start_file(path) - zip.write_file(buf) - zip.close_file() + path = path.trim_prefix("res://") + if path in zipPaths: + return + + zip.start_file(path) + zip.write_file(buf) + zip.close_file() - zipPaths.append(path) + zipPaths.append(path) func zipAddFile(zip: ZIPPacker, path: String, dest: String = ""): - path = path.trim_prefix("res://") - if path in zipPaths: - return + path = path.trim_prefix("res://") + if path in zipPaths: + return - if dest == "": - dest = path + if dest == "": + dest = path - zip.start_file(dest) - var fa = FileAccess.open("res://" + path, FileAccess.ModeFlags.READ) - zip.write_file(fa.get_buffer(fa.get_length())) - fa.close() - zip.close_file() + zip.start_file(dest) + var fa = FileAccess.open("res://" + path, FileAccess.ModeFlags.READ) + zip.write_file(fa.get_buffer(fa.get_length())) + fa.close() + zip.close_file() - zipPaths.append(path) - -func _exit_tree() -> void: - remove_control_from_bottom_panel(dock) - dock.queue_free() + zipPaths.append(path)