Browse Source

Add ability to export patch packs

Co-authored-by: Poq Xert <[email protected]>
Mikael Hermansson 10 months ago
parent
commit
d3be030ea6

+ 16 - 0
core/io/file_access_pack.cpp

@@ -102,6 +102,22 @@ void PackedData::add_pack_source(PackSource *p_source) {
 	}
 }
 
+uint8_t *PackedData::get_file_hash(const String &p_path) {
+	PathMD5 pmd5(p_path.md5_buffer());
+	HashMap<PathMD5, PackedFile, PathMD5>::Iterator E = files.find(pmd5);
+	if (!E || E->value.offset == 0) {
+		return nullptr;
+	}
+
+	return E->value.md5;
+}
+
+void PackedData::clear() {
+	files.clear();
+	_free_packed_dirs(root);
+	root = memnew(PackedDir);
+}
+
 PackedData *PackedData::singleton = nullptr;
 
 PackedData::PackedData() {

+ 3 - 0
core/io/file_access_pack.h

@@ -111,6 +111,7 @@ private:
 public:
 	void add_pack_source(PackSource *p_source);
 	void add_path(const String &p_pkg_path, const String &p_path, uint64_t p_ofs, uint64_t p_size, const uint8_t *p_md5, PackSource *p_src, bool p_replace_files, bool p_encrypted = false); // for PackSource
+	uint8_t *get_file_hash(const String &p_path);
 
 	void set_disabled(bool p_disabled) { disabled = p_disabled; }
 	_FORCE_INLINE_ bool is_disabled() const { return disabled; }
@@ -118,6 +119,8 @@ public:
 	static PackedData *get_singleton() { return singleton; }
 	Error add_pack(const String &p_path, bool p_replace_files, uint64_t p_offset);
 
+	void clear();
+
 	_FORCE_INLINE_ Ref<FileAccess> try_open_path(const String &p_path);
 	_FORCE_INLINE_ bool has_path(const String &p_path);
 

+ 42 - 0
doc/classes/EditorExportPlatform.xml

@@ -42,6 +42,18 @@
 				Creates a PCK archive at [param path] for the specified [param preset].
 			</description>
 		</method>
+		<method name="export_pack_patch">
+			<return type="int" enum="Error" />
+			<param index="0" name="preset" type="EditorExportPreset" />
+			<param index="1" name="debug" type="bool" />
+			<param index="2" name="path" type="String" />
+			<param index="3" name="patches" type="PackedStringArray" default="PackedStringArray()" />
+			<param index="4" name="flags" type="int" enum="EditorExportPlatform.DebugFlags" is_bitfield="true" default="0" />
+			<description>
+				Creates a patch PCK archive at [param path] for the specified [param preset], containing only the files that have changed since the last patch.
+				[b]Note:[/b] [param patches] is an optional override of the set of patches defined in the export preset. When empty the patches defined in the export preset will be used instead.
+			</description>
+		</method>
 		<method name="export_project">
 			<return type="int" enum="Error" />
 			<param index="0" name="preset" type="EditorExportPreset" />
@@ -75,6 +87,18 @@
 				Create a ZIP archive at [param path] for the specified [param preset].
 			</description>
 		</method>
+		<method name="export_zip_patch">
+			<return type="int" enum="Error" />
+			<param index="0" name="preset" type="EditorExportPreset" />
+			<param index="1" name="debug" type="bool" />
+			<param index="2" name="path" type="String" />
+			<param index="3" name="patches" type="PackedStringArray" default="PackedStringArray()" />
+			<param index="4" name="flags" type="int" enum="EditorExportPlatform.DebugFlags" is_bitfield="true" default="0" />
+			<description>
+				Create a patch ZIP archive at [param path] for the specified [param preset], containing only the files that have changed since the last patch.
+				[b]Note:[/b] [param patches] is an optional override of the set of patches defined in the export preset. When empty the patches defined in the export preset will be used instead.
+			</description>
+		</method>
 		<method name="find_export_template" qualifiers="const">
 			<return type="Dictionary" />
 			<param index="0" name="template_file_name" type="String" />
@@ -151,6 +175,15 @@
 				If [param embed] is [code]true[/code], PCK content is appended to the end of [param path] file and return [Dictionary] additionally include following keys: [code]embedded_start: int[/code] (embedded PCK offset) and [code]embedded_size: int[/code] (embedded PCK size).
 			</description>
 		</method>
+		<method name="save_pack_patch">
+			<return type="Dictionary" />
+			<param index="0" name="preset" type="EditorExportPreset" />
+			<param index="1" name="debug" type="bool" />
+			<param index="2" name="path" type="String" />
+			<description>
+				Saves patch PCK archive and returns [Dictionary] with the following keys: [code]result: Error[/code], [code]so_files: Array[/code] (array of the shared/static objects which contains dictionaries with the following keys: [code]path: String[/code], [code]tags: PackedStringArray[/code], and [code]target_folder: String[/code]).
+			</description>
+		</method>
 		<method name="save_zip">
 			<return type="Dictionary" />
 			<param index="0" name="preset" type="EditorExportPreset" />
@@ -160,6 +193,15 @@
 				Saves ZIP archive and returns [Dictionary] with the following keys: [code]result: Error[/code], [code]so_files: Array[/code] (array of the shared/static objects which contains dictionaries with the following keys: [code]path: String[/code], [code]tags: PackedStringArray[/code], and [code]target_folder: String[/code]).
 			</description>
 		</method>
+		<method name="save_zip_patch">
+			<return type="Dictionary" />
+			<param index="0" name="preset" type="EditorExportPreset" />
+			<param index="1" name="debug" type="bool" />
+			<param index="2" name="path" type="String" />
+			<description>
+				Saves patch ZIP archive and returns [Dictionary] with the following keys: [code]result: Error[/code], [code]so_files: Array[/code] (array of the shared/static objects which contains dictionaries with the following keys: [code]path: String[/code], [code]tags: PackedStringArray[/code], and [code]target_folder: String[/code]).
+			</description>
+		</method>
 		<method name="ssh_push_to_remote" qualifiers="const">
 			<return type="int" enum="Error" />
 			<param index="0" name="host" type="String" />

+ 30 - 2
doc/classes/EditorExportPlatformExtension.xml

@@ -36,7 +36,21 @@
 			<description>
 				[b]Optional.[/b]
 				Creates a PCK archive at [param path] for the specified [param preset].
-				This method is called when "Export PCK/ZIP" button is pressed in the export dialog, and PCK is selected as a file type.
+				This method is called when "Export PCK/ZIP" button is pressed in the export dialog, with "Export as Patch" disabled, and PCK is selected as a file type.
+			</description>
+		</method>
+		<method name="_export_pack_patch" qualifiers="virtual">
+			<return type="int" enum="Error" />
+			<param index="0" name="preset" type="EditorExportPreset" />
+			<param index="1" name="debug" type="bool" />
+			<param index="2" name="path" type="String" />
+			<param index="3" name="patches" type="PackedStringArray" />
+			<param index="4" name="flags" type="int" enum="EditorExportPlatform.DebugFlags" is_bitfield="true" />
+			<description>
+				[b]Optional.[/b]
+				Creates a patch PCK archive at [param path] for the specified [param preset], containing only the files that have changed since the last patch.
+				This method is called when "Export PCK/ZIP" button is pressed in the export dialog, with "Export as Patch" enabled, and PCK is selected as a file type.
+				[b]Note:[/b] The patches provided in [param patches] have already been loaded when this method is called and are merely provided as context. When empty the patches defined in the export preset have been loaded instead.
 			</description>
 		</method>
 		<method name="_export_project" qualifiers="virtual">
@@ -61,7 +75,21 @@
 			<description>
 				[b]Optional.[/b]
 				Create a ZIP archive at [param path] for the specified [param preset].
-				This method is called when "Export PCK/ZIP" button is pressed in the export dialog, and ZIP is selected as a file type.
+				This method is called when "Export PCK/ZIP" button is pressed in the export dialog, with "Export as Patch" disabled, and ZIP is selected as a file type.
+			</description>
+		</method>
+		<method name="_export_zip_patch" qualifiers="virtual">
+			<return type="int" enum="Error" />
+			<param index="0" name="preset" type="EditorExportPreset" />
+			<param index="1" name="debug" type="bool" />
+			<param index="2" name="path" type="String" />
+			<param index="3" name="patches" type="PackedStringArray" />
+			<param index="4" name="flags" type="int" enum="EditorExportPlatform.DebugFlags" is_bitfield="true" />
+			<description>
+				[b]Optional.[/b]
+				Create a ZIP archive at [param path] for the specified [param preset], containing only the files that have changed since the last patch.
+				This method is called when "Export PCK/ZIP" button is pressed in the export dialog, with "Export as Patch" enabled, and ZIP is selected as a file type.
+				[b]Note:[/b] The patches provided in [param patches] have already been loaded when this method is called and are merely provided as context. When empty the patches defined in the export preset have been loaded instead.
 			</description>
 		</method>
 		<method name="_get_binary_extensions" qualifiers="virtual const">

+ 6 - 0
doc/classes/EditorExportPreset.xml

@@ -109,6 +109,12 @@
 				Returns export option value or value of environment variable if it is set.
 			</description>
 		</method>
+		<method name="get_patches" qualifiers="const">
+			<return type="PackedStringArray" />
+			<description>
+				Returns the list of packs on which to base a patch export on.
+			</description>
+		</method>
 		<method name="get_preset_name" qualifiers="const">
 			<return type="String" />
 			<description>

+ 13 - 3
editor/editor_node.cpp

@@ -1007,9 +1007,17 @@ void EditorNode::_fs_changed() {
 				export_preset->update_value_overrides();
 				if (export_defer.pack_only) { // Only export .pck or .zip data pack.
 					if (export_path.ends_with(".zip")) {
-						err = platform->export_zip(export_preset, export_defer.debug, export_path);
+						if (export_defer.patch) {
+							err = platform->export_zip_patch(export_preset, export_defer.debug, export_path, export_defer.patches);
+						} else {
+							err = platform->export_zip(export_preset, export_defer.debug, export_path);
+						}
 					} else if (export_path.ends_with(".pck")) {
-						err = platform->export_pack(export_preset, export_defer.debug, export_path);
+						if (export_defer.patch) {
+							err = platform->export_pack_patch(export_preset, export_defer.debug, export_path, export_defer.patches);
+						} else {
+							err = platform->export_pack(export_preset, export_defer.debug, export_path);
+						}
 					} else {
 						ERR_PRINT(vformat("Export path \"%s\" doesn't end with a supported extension.", export_path));
 						err = FAILED;
@@ -5147,12 +5155,14 @@ void EditorNode::_begin_first_scan() {
 	requested_first_scan = true;
 }
 
-Error EditorNode::export_preset(const String &p_preset, const String &p_path, bool p_debug, bool p_pack_only, bool p_android_build_template) {
+Error EditorNode::export_preset(const String &p_preset, const String &p_path, bool p_debug, bool p_pack_only, bool p_android_build_template, bool p_patch, const Vector<String> &p_patches) {
 	export_defer.preset = p_preset;
 	export_defer.path = p_path;
 	export_defer.debug = p_debug;
 	export_defer.pack_only = p_pack_only;
 	export_defer.android_build_template = p_android_build_template;
+	export_defer.patch = p_patch;
+	export_defer.patches = p_patches;
 	cmdline_export_mode = true;
 	return OK;
 }

+ 3 - 1
editor/editor_node.h

@@ -246,6 +246,8 @@ private:
 		bool debug = false;
 		bool pack_only = false;
 		bool android_build_template = false;
+		bool patch = false;
+		Vector<String> patches;
 	} export_defer;
 
 	static EditorNode *singleton;
@@ -879,7 +881,7 @@ public:
 
 	void _copy_warning(const String &p_str);
 
-	Error export_preset(const String &p_preset, const String &p_path, bool p_debug, bool p_pack_only, bool p_android_build_template);
+	Error export_preset(const String &p_preset, const String &p_path, bool p_debug, bool p_pack_only, bool p_android_build_template, bool p_patch, const Vector<String> &p_patches);
 	bool is_project_exporting() const;
 
 	Control *get_gui_base() { return gui_base; }

+ 3 - 0
editor/export/editor_export.cpp

@@ -83,6 +83,8 @@ void EditorExport::_save() {
 		config->set_value(section, "include_filter", preset->get_include_filter());
 		config->set_value(section, "exclude_filter", preset->get_exclude_filter());
 		config->set_value(section, "export_path", preset->get_export_path());
+		config->set_value(section, "patches", preset->get_patches());
+
 		config->set_value(section, "encryption_include_filters", preset->get_enc_in_filter());
 		config->set_value(section, "encryption_exclude_filters", preset->get_enc_ex_filter());
 		config->set_value(section, "encrypt_pck", preset->get_enc_pck());
@@ -303,6 +305,7 @@ void EditorExport::load_config() {
 		preset->set_exclude_filter(config->get_value(section, "exclude_filter"));
 		preset->set_export_path(config->get_value(section, "export_path", ""));
 		preset->set_script_export_mode(config->get_value(section, "script_export_mode", EditorExportPreset::MODE_SCRIPT_BINARY_TOKENS_COMPRESSED));
+		preset->set_patches(config->get_value(section, "patches", Vector<String>()));
 
 		if (config->has_section_key(section, "encrypt_pck")) {
 			preset->set_enc_pck(config->get_value(section, "encrypt_pck"));

+ 168 - 6
editor/export/editor_export_platform.cpp

@@ -167,6 +167,44 @@ bool EditorExportPlatform::fill_log_messages(RichTextLabel *p_log, Error p_err)
 	return has_messages;
 }
 
+bool EditorExportPlatform::_check_hash(const uint8_t *p_hash, const Vector<uint8_t> &p_data) {
+	if (p_hash == nullptr) {
+		return false;
+	}
+
+	unsigned char hash[16];
+	Error err = CryptoCore::md5(p_data.ptr(), p_data.size(), hash);
+	if (err != OK) {
+		return false;
+	}
+
+	for (int i = 0; i < 16; i++) {
+		if (p_hash[i] != hash[i]) {
+			return false;
+		}
+	}
+
+	return true;
+}
+
+Error EditorExportPlatform::_load_patches(const Vector<String> &p_patches) {
+	Error err = OK;
+	if (!p_patches.is_empty()) {
+		for (const String &path : p_patches) {
+			err = PackedData::get_singleton()->add_pack(path, true, 0);
+			if (err != OK) {
+				add_message(EXPORT_MESSAGE_ERROR, TTR("Patch Creation"), vformat(TTR("Could not load patch pack with path \"%s\"."), path));
+				return err;
+			}
+		}
+	}
+	return err;
+}
+
+void EditorExportPlatform::_unload_patches() {
+	PackedData::get_singleton()->clear();
+}
+
 Error EditorExportPlatform::_save_pack_file(void *p_userdata, const String &p_path, const Vector<uint8_t> &p_data, int p_file, int p_total, const Vector<String> &p_enc_in_filters, const Vector<String> &p_enc_ex_filters, const Vector<uint8_t> &p_key) {
 	ERR_FAIL_COND_V_MSG(p_total < 1, ERR_PARAMETER_RANGE_ERROR, "Must select at least one file to export.");
 
@@ -237,6 +275,14 @@ Error EditorExportPlatform::_save_pack_file(void *p_userdata, const String &p_pa
 	return OK;
 }
 
+Error EditorExportPlatform::_save_pack_patch_file(void *p_userdata, const String &p_path, const Vector<uint8_t> &p_data, int p_file, int p_total, const Vector<String> &p_enc_in_filters, const Vector<String> &p_enc_ex_filters, const Vector<uint8_t> &p_key) {
+	if (_check_hash(PackedData::get_singleton()->get_file_hash(p_path), p_data)) {
+		return OK;
+	}
+
+	return _save_pack_file(p_userdata, p_path, p_data, p_file, p_total, p_enc_in_filters, p_enc_ex_filters, p_key);
+}
+
 Error EditorExportPlatform::_save_zip_file(void *p_userdata, const String &p_path, const Vector<uint8_t> &p_data, int p_file, int p_total, const Vector<String> &p_enc_in_filters, const Vector<String> &p_enc_ex_filters, const Vector<uint8_t> &p_key) {
 	ERR_FAIL_COND_V_MSG(p_total < 1, ERR_PARAMETER_RANGE_ERROR, "Must select at least one file to export.");
 
@@ -260,6 +306,8 @@ Error EditorExportPlatform::_save_zip_file(void *p_userdata, const String &p_pat
 	zipWriteInFileInZip(zip, p_data.ptr(), p_data.size());
 	zipCloseFileInZip(zip);
 
+	zd->file_count += 1;
+
 	if (zd->ep->step(TTR("Storing File:") + " " + p_path, 2 + p_file * 100 / p_total, false)) {
 		return ERR_SKIP;
 	}
@@ -267,6 +315,14 @@ Error EditorExportPlatform::_save_zip_file(void *p_userdata, const String &p_pat
 	return OK;
 }
 
+Error EditorExportPlatform::_save_zip_patch_file(void *p_userdata, const String &p_path, const Vector<uint8_t> &p_data, int p_file, int p_total, const Vector<String> &p_enc_in_filters, const Vector<String> &p_enc_ex_filters, const Vector<uint8_t> &p_key) {
+	if (_check_hash(PackedData::get_singleton()->get_file_hash(p_path), p_data)) {
+		return OK;
+	}
+
+	return _save_zip_file(p_userdata, p_path, p_data, p_file, p_total, p_enc_in_filters, p_enc_ex_filters, p_key);
+}
+
 Ref<ImageTexture> EditorExportPlatform::get_option_icon(int p_index) const {
 	Ref<Theme> theme = EditorNode::get_singleton()->get_editor_theme();
 	ERR_FAIL_COND_V(theme.is_null(), Ref<ImageTexture>());
@@ -1561,7 +1617,7 @@ Dictionary EditorExportPlatform::_save_pack(const Ref<EditorExportPreset> &p_pre
 	Vector<SharedObject> so_files;
 	int64_t embedded_start = 0;
 	int64_t embedded_size = 0;
-	Error err_code = save_pack(p_preset, p_debug, p_path, &so_files, p_embed, &embedded_start, &embedded_size);
+	Error err_code = save_pack(p_preset, p_debug, p_path, &so_files, nullptr, p_embed, &embedded_start, &embedded_size);
 
 	Dictionary ret;
 	ret["result"] = err_code;
@@ -1605,9 +1661,55 @@ Dictionary EditorExportPlatform::_save_zip(const Ref<EditorExportPreset> &p_pres
 	return ret;
 }
 
-Error EditorExportPlatform::save_pack(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, Vector<SharedObject> *p_so_files, bool p_embed, int64_t *r_embedded_start, int64_t *r_embedded_size) {
+Dictionary EditorExportPlatform::_save_pack_patch(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path) {
+	Vector<SharedObject> so_files;
+	Error err_code = save_pack_patch(p_preset, p_debug, p_path, &so_files);
+
+	Dictionary ret;
+	ret["result"] = err_code;
+	if (err_code == OK) {
+		Array arr;
+		for (const SharedObject &E : so_files) {
+			Dictionary so;
+			so["path"] = E.path;
+			so["tags"] = E.tags;
+			so["target_folder"] = E.target;
+			arr.push_back(so);
+		}
+		ret["so_files"] = arr;
+	}
+
+	return ret;
+}
+
+Dictionary EditorExportPlatform::_save_zip_patch(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path) {
+	Vector<SharedObject> so_files;
+	Error err_code = save_zip_patch(p_preset, p_debug, p_path, &so_files);
+
+	Dictionary ret;
+	ret["result"] = err_code;
+	if (err_code == OK) {
+		Array arr;
+		for (const SharedObject &E : so_files) {
+			Dictionary so;
+			so["path"] = E.path;
+			so["tags"] = E.tags;
+			so["target_folder"] = E.target;
+			arr.push_back(so);
+		}
+		ret["so_files"] = arr;
+	}
+
+	return ret;
+}
+
+Error EditorExportPlatform::save_pack(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, Vector<SharedObject> *p_so_files, EditorExportSaveFunction p_save_func, bool p_embed, int64_t *r_embedded_start, int64_t *r_embedded_size) {
 	EditorProgress ep("savepack", TTR("Packing"), 102, true);
 
+	if (p_save_func == nullptr) {
+		p_save_func = _save_pack_file;
+	}
+
 	// Create the temporary export directory if it doesn't exist.
 	Ref<DirAccess> da = DirAccess::create(DirAccess::ACCESS_FILESYSTEM);
 	da->make_dir_recursive(EditorPaths::get_singleton()->get_cache_dir());
@@ -1624,7 +1726,7 @@ Error EditorExportPlatform::save_pack(const Ref<EditorExportPreset> &p_preset, b
 	pd.f = ftmp;
 	pd.so_files = p_so_files;
 
-	Error err = export_project_files(p_preset, p_debug, _save_pack_file, &pd, _pack_add_shared_object);
+	Error err = export_project_files(p_preset, p_debug, p_save_func, &pd, _pack_add_shared_object);
 
 	// Close temp file.
 	pd.f.unref();
@@ -1636,6 +1738,12 @@ Error EditorExportPlatform::save_pack(const Ref<EditorExportPreset> &p_preset, b
 		return err;
 	}
 
+	if (pd.file_ofs.is_empty()) {
+		DirAccess::remove_file_or_error(tmppath);
+		add_message(EXPORT_MESSAGE_ERROR, TTR("Save PCK"), TTR("No files or changes to export."));
+		return FAILED;
+	}
+
 	pd.file_ofs.sort(); //do sort, so we can do binary search later
 
 	Ref<FileAccess> f;
@@ -1831,28 +1939,56 @@ Error EditorExportPlatform::save_pack(const Ref<EditorExportPreset> &p_preset, b
 	return OK;
 }
 
-Error EditorExportPlatform::save_zip(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, Vector<SharedObject> *p_so_files) {
+Error EditorExportPlatform::save_pack_patch(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, Vector<SharedObject> *p_so_files, bool p_embed, int64_t *r_embedded_start, int64_t *r_embedded_size) {
+	return save_pack(p_preset, p_debug, p_path, p_so_files, _save_pack_patch_file, p_embed, r_embedded_start, r_embedded_size);
+}
+
+Error EditorExportPlatform::save_zip(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, Vector<SharedObject> *p_so_files, EditorExportSaveFunction p_save_func) {
 	EditorProgress ep("savezip", TTR("Packing"), 102, true);
 
+	if (p_save_func == nullptr) {
+		p_save_func = _save_zip_file;
+	}
+
+	String tmppath = EditorPaths::get_singleton()->get_cache_dir().path_join("packtmp");
+
 	Ref<FileAccess> io_fa;
 	zlib_filefunc_def io = zipio_create_io(&io_fa);
-	zipFile zip = zipOpen2(p_path.utf8().get_data(), APPEND_STATUS_CREATE, nullptr, &io);
+	zipFile zip = zipOpen2(tmppath.utf8().get_data(), APPEND_STATUS_CREATE, nullptr, &io);
 
 	ZipData zd;
 	zd.ep = &ep;
 	zd.zip = zip;
 	zd.so_files = p_so_files;
 
-	Error err = export_project_files(p_preset, p_debug, _save_zip_file, &zd, _zip_add_shared_object);
+	Error err = export_project_files(p_preset, p_debug, p_save_func, &zd, _zip_add_shared_object);
 	if (err != OK && err != ERR_SKIP) {
 		add_message(EXPORT_MESSAGE_ERROR, TTR("Save ZIP"), TTR("Failed to export project files."));
 	}
 
 	zipClose(zip, nullptr);
 
+	Ref<DirAccess> da = DirAccess::create(DirAccess::ACCESS_FILESYSTEM);
+
+	if (zd.file_count == 0) {
+		da->remove(tmppath);
+		add_message(EXPORT_MESSAGE_ERROR, TTR("Save PCK"), TTR("No files or changes to export."));
+		return FAILED;
+	}
+
+	err = da->rename(tmppath, p_path);
+	if (err != OK) {
+		da->remove(tmppath);
+		add_message(EXPORT_MESSAGE_ERROR, TTR("Save ZIP"), vformat(TTR("Failed to move temporary file \"%s\" to \"%s\"."), tmppath, p_path));
+	}
+
 	return OK;
 }
 
+Error EditorExportPlatform::save_zip_patch(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, Vector<SharedObject> *p_so_files) {
+	return save_zip(p_preset, p_debug, p_path, p_so_files, _save_zip_patch_file);
+}
+
 Error EditorExportPlatform::export_pack(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, BitField<EditorExportPlatform::DebugFlags> p_flags) {
 	ExportNotifier notifier(*this, p_preset, p_debug, p_path, p_flags);
 	return save_pack(p_preset, p_debug, p_path);
@@ -1863,6 +1999,28 @@ Error EditorExportPlatform::export_zip(const Ref<EditorExportPreset> &p_preset,
 	return save_zip(p_preset, p_debug, p_path);
 }
 
+Error EditorExportPlatform::export_pack_patch(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, const Vector<String> &p_patches, BitField<EditorExportPlatform::DebugFlags> p_flags) {
+	ExportNotifier notifier(*this, p_preset, p_debug, p_path, p_flags);
+	Error err = _load_patches(p_patches.is_empty() ? p_preset->get_patches() : p_patches);
+	if (err != OK) {
+		return err;
+	}
+	err = save_pack_patch(p_preset, p_debug, p_path);
+	_unload_patches();
+	return err;
+}
+
+Error EditorExportPlatform::export_zip_patch(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, const Vector<String> &p_patches, BitField<EditorExportPlatform::DebugFlags> p_flags) {
+	ExportNotifier notifier(*this, p_preset, p_debug, p_path, p_flags);
+	Error err = _load_patches(p_patches.is_empty() ? p_preset->get_patches() : p_patches);
+	if (err != OK) {
+		return err;
+	}
+	err = save_zip_patch(p_preset, p_debug, p_path);
+	_unload_patches();
+	return err;
+}
+
 Vector<String> EditorExportPlatform::gen_export_flags(BitField<EditorExportPlatform::DebugFlags> p_flags) {
 	Vector<String> ret;
 	String host = EDITOR_GET("network/debug/remote_host");
@@ -2115,6 +2273,8 @@ void EditorExportPlatform::_bind_methods() {
 
 	ClassDB::bind_method(D_METHOD("save_pack", "preset", "debug", "path", "embed"), &EditorExportPlatform::_save_pack, DEFVAL(false));
 	ClassDB::bind_method(D_METHOD("save_zip", "preset", "debug", "path"), &EditorExportPlatform::_save_zip);
+	ClassDB::bind_method(D_METHOD("save_pack_patch", "preset", "debug", "path"), &EditorExportPlatform::_save_pack_patch);
+	ClassDB::bind_method(D_METHOD("save_zip_patch", "preset", "debug", "path"), &EditorExportPlatform::_save_zip_patch);
 
 	ClassDB::bind_method(D_METHOD("gen_export_flags", "flags"), &EditorExportPlatform::gen_export_flags);
 
@@ -2123,6 +2283,8 @@ void EditorExportPlatform::_bind_methods() {
 	ClassDB::bind_method(D_METHOD("export_project", "preset", "debug", "path", "flags"), &EditorExportPlatform::export_project, DEFVAL(0));
 	ClassDB::bind_method(D_METHOD("export_pack", "preset", "debug", "path", "flags"), &EditorExportPlatform::export_pack, DEFVAL(0));
 	ClassDB::bind_method(D_METHOD("export_zip", "preset", "debug", "path", "flags"), &EditorExportPlatform::export_zip, DEFVAL(0));
+	ClassDB::bind_method(D_METHOD("export_pack_patch", "preset", "debug", "path", "patches", "flags"), &EditorExportPlatform::export_pack_patch, DEFVAL(PackedStringArray()), DEFVAL(0));
+	ClassDB::bind_method(D_METHOD("export_zip_patch", "preset", "debug", "path", "patches", "flags"), &EditorExportPlatform::export_zip_patch, DEFVAL(PackedStringArray()), DEFVAL(0));
 
 	ClassDB::bind_method(D_METHOD("clear_messages"), &EditorExportPlatform::clear_messages);
 	ClassDB::bind_method(D_METHOD("add_message", "type", "category", "message"), &EditorExportPlatform::add_message);

+ 18 - 2
editor/export/editor_export_platform.h

@@ -101,6 +101,7 @@ private:
 		void *zip = nullptr;
 		EditorProgress *ep = nullptr;
 		Vector<SharedObject> *so_files = nullptr;
+		int file_count = 0;
 	};
 
 	Vector<ExportMessage> messages;
@@ -109,10 +110,14 @@ private:
 	void _export_find_customized_resources(const Ref<EditorExportPreset> &p_preset, EditorFileSystemDirectory *p_dir, EditorExportPreset::FileExportMode p_mode, HashSet<String> &p_paths);
 	void _export_find_dependencies(const String &p_path, HashSet<String> &p_paths);
 
+	static bool _check_hash(const uint8_t *p_hash, const Vector<uint8_t> &p_data);
+
 	static Error _save_pack_file(void *p_userdata, const String &p_path, const Vector<uint8_t> &p_data, int p_file, int p_total, const Vector<String> &p_enc_in_filters, const Vector<String> &p_enc_ex_filters, const Vector<uint8_t> &p_key);
+	static Error _save_pack_patch_file(void *p_userdata, const String &p_path, const Vector<uint8_t> &p_data, int p_file, int p_total, const Vector<String> &p_enc_in_filters, const Vector<String> &p_enc_ex_filters, const Vector<uint8_t> &p_key);
 	static Error _pack_add_shared_object(void *p_userdata, const SharedObject &p_so);
 
 	static Error _save_zip_file(void *p_userdata, const String &p_path, const Vector<uint8_t> &p_data, int p_file, int p_total, const Vector<String> &p_enc_in_filters, const Vector<String> &p_enc_ex_filters, const Vector<uint8_t> &p_key);
+	static Error _save_zip_patch_file(void *p_userdata, const String &p_path, const Vector<uint8_t> &p_data, int p_file, int p_total, const Vector<String> &p_enc_in_filters, const Vector<String> &p_enc_ex_filters, const Vector<uint8_t> &p_key);
 	static Error _zip_add_shared_object(void *p_userdata, const SharedObject &p_so);
 
 	struct ScriptCallbackData {
@@ -188,6 +193,9 @@ protected:
 	Error ssh_run_on_remote_no_wait(const String &p_host, const String &p_port, const Vector<String> &p_ssh_args, const String &p_cmd_args, OS::ProcessID *r_pid = nullptr, int p_port_fwd = -1) const;
 	Error ssh_push_to_remote(const String &p_host, const String &p_port, const Vector<String> &p_scp_args, const String &p_src_file, const String &p_dst_file) const;
 
+	Error _load_patches(const Vector<String> &p_patches);
+	void _unload_patches();
+
 public:
 	virtual void get_preset_features(const Ref<EditorExportPreset> &p_preset, List<String> *r_features) const = 0;
 
@@ -284,8 +292,14 @@ public:
 	Dictionary _save_pack(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, bool p_embed = false);
 	Dictionary _save_zip(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path);
 
-	Error save_pack(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, Vector<SharedObject> *p_so_files = nullptr, bool p_embed = false, int64_t *r_embedded_start = nullptr, int64_t *r_embedded_size = nullptr);
-	Error save_zip(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, Vector<SharedObject> *p_so_files = nullptr);
+	Dictionary _save_pack_patch(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path);
+	Dictionary _save_zip_patch(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path);
+
+	Error save_pack(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, Vector<SharedObject> *p_so_files = nullptr, EditorExportSaveFunction p_save_func = nullptr, bool p_embed = false, int64_t *r_embedded_start = nullptr, int64_t *r_embedded_size = nullptr);
+	Error save_zip(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, Vector<SharedObject> *p_so_files = nullptr, EditorExportSaveFunction p_save_func = nullptr);
+
+	Error save_pack_patch(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, Vector<SharedObject> *p_so_files = nullptr, bool p_embed = false, int64_t *r_embedded_start = nullptr, int64_t *r_embedded_size = nullptr);
+	Error save_zip_patch(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, Vector<SharedObject> *p_so_files = nullptr);
 
 	virtual bool poll_export() { return false; }
 	virtual int get_options_count() const { return 0; }
@@ -307,6 +321,8 @@ public:
 	virtual Error export_project(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, BitField<EditorExportPlatform::DebugFlags> p_flags = 0) = 0;
 	virtual Error export_pack(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, BitField<EditorExportPlatform::DebugFlags> p_flags = 0);
 	virtual Error export_zip(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, BitField<EditorExportPlatform::DebugFlags> p_flags = 0);
+	virtual Error export_pack_patch(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, const Vector<String> &p_patches = Vector<String>(), BitField<EditorExportPlatform::DebugFlags> p_flags = 0);
+	virtual Error export_zip_patch(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, const Vector<String> &p_patches = Vector<String>(), BitField<EditorExportPlatform::DebugFlags> p_flags = 0);
 	virtual void get_platform_features(List<String> *r_features) const = 0;
 	virtual void resolve_platform_feature_priorities(const Ref<EditorExportPreset> &p_preset, HashSet<String> &p_features) {}
 	virtual String get_debug_protocol() const { return "tcp://"; }

+ 40 - 0
editor/export/editor_export_platform_extension.cpp

@@ -71,6 +71,8 @@ void EditorExportPlatformExtension::_bind_methods() {
 	GDVIRTUAL_BIND(_export_project, "preset", "debug", "path", "flags");
 	GDVIRTUAL_BIND(_export_pack, "preset", "debug", "path", "flags");
 	GDVIRTUAL_BIND(_export_zip, "preset", "debug", "path", "flags");
+	GDVIRTUAL_BIND(_export_pack_patch, "preset", "debug", "path", "patches", "flags");
+	GDVIRTUAL_BIND(_export_zip_patch, "preset", "debug", "path", "patches", "flags");
 
 	GDVIRTUAL_BIND(_get_platform_features);
 
@@ -291,6 +293,44 @@ Error EditorExportPlatformExtension::export_zip(const Ref<EditorExportPreset> &p
 	return save_zip(p_preset, p_debug, p_path);
 }
 
+Error EditorExportPlatformExtension::export_pack_patch(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, const Vector<String> &p_patches, BitField<EditorExportPlatform::DebugFlags> p_flags) {
+	ExportNotifier notifier(*this, p_preset, p_debug, p_path, p_flags);
+
+	Error err = _load_patches(p_patches.is_empty() ? p_preset->get_patches() : p_patches);
+	if (err != OK) {
+		return err;
+	}
+
+	Error ret = FAILED;
+	if (GDVIRTUAL_CALL(_export_pack_patch, p_preset, p_debug, p_path, p_patches, p_flags, ret)) {
+		_unload_patches();
+		return ret;
+	}
+
+	err = save_pack_patch(p_preset, p_debug, p_path);
+	_unload_patches();
+	return err;
+}
+
+Error EditorExportPlatformExtension::export_zip_patch(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, const Vector<String> &p_patches, BitField<EditorExportPlatform::DebugFlags> p_flags) {
+	ExportNotifier notifier(*this, p_preset, p_debug, p_path, p_flags);
+
+	Error err = _load_patches(p_patches.is_empty() ? p_preset->get_patches() : p_patches);
+	if (err != OK) {
+		return err;
+	}
+
+	Error ret = FAILED;
+	if (GDVIRTUAL_CALL(_export_zip_patch, p_preset, p_debug, p_path, p_patches, p_flags, ret)) {
+		_unload_patches();
+		return ret;
+	}
+
+	err = save_zip_patch(p_preset, p_debug, p_path);
+	_unload_patches();
+	return err;
+}
+
 void EditorExportPlatformExtension::get_platform_features(List<String> *r_features) const {
 	Vector<String> ret;
 	if (GDVIRTUAL_REQUIRED_CALL(_get_platform_features, ret) && r_features) {

+ 6 - 0
editor/export/editor_export_platform_extension.h

@@ -136,6 +136,12 @@ public:
 	virtual Error export_zip(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, BitField<EditorExportPlatform::DebugFlags> p_flags = 0) override;
 	GDVIRTUAL4R(Error, _export_zip, Ref<EditorExportPreset>, bool, const String &, BitField<EditorExportPlatform::DebugFlags>);
 
+	virtual Error export_pack_patch(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, const Vector<String> &p_patches = Vector<String>(), BitField<EditorExportPlatform::DebugFlags> p_flags = 0) override;
+	GDVIRTUAL5R(Error, _export_pack_patch, Ref<EditorExportPreset>, bool, const String &, const Vector<String> &, BitField<EditorExportPlatform::DebugFlags>);
+
+	virtual Error export_zip_patch(const Ref<EditorExportPreset> &p_preset, bool p_debug, const String &p_path, const Vector<String> &p_patches = Vector<String>(), BitField<EditorExportPlatform::DebugFlags> p_flags = 0) override;
+	GDVIRTUAL5R(Error, _export_zip_patch, Ref<EditorExportPreset>, bool, const String &, const Vector<String> &, BitField<EditorExportPlatform::DebugFlags>);
+
 	virtual void get_platform_features(List<String> *r_features) const override;
 	GDVIRTUAL0RC(Vector<String>, _get_platform_features);
 

+ 1 - 1
editor/export/editor_export_platform_pc.cpp

@@ -194,7 +194,7 @@ Error EditorExportPlatformPC::export_project_data(const Ref<EditorExportPreset>
 
 	int64_t embedded_pos;
 	int64_t embedded_size;
-	Error err = save_pack(p_preset, p_debug, pck_path, &so_files, p_preset->get("binary_format/embed_pck"), &embedded_pos, &embedded_size);
+	Error err = save_pack(p_preset, p_debug, pck_path, &so_files, nullptr, p_preset->get("binary_format/embed_pck"), &embedded_pos, &embedded_size);
 	if (err == OK && p_preset->get("binary_format/embed_pck")) {
 		if (embedded_size >= 0x100000000 && String(p_preset->get("binary_format/architecture")).contains("32")) {
 			add_message(EXPORT_MESSAGE_ERROR, TTR("PCK Embedding"), TTR("On 32-bit exports the embedded PCK cannot be bigger than 4 GiB."));

+ 37 - 0
editor/export/editor_export_preset.cpp

@@ -79,6 +79,7 @@ void EditorExportPreset::_bind_methods() {
 	ClassDB::bind_method(D_METHOD("get_include_filter"), &EditorExportPreset::get_include_filter);
 	ClassDB::bind_method(D_METHOD("get_exclude_filter"), &EditorExportPreset::get_exclude_filter);
 	ClassDB::bind_method(D_METHOD("get_custom_features"), &EditorExportPreset::get_custom_features);
+	ClassDB::bind_method(D_METHOD("get_patches"), &EditorExportPreset::get_patches);
 	ClassDB::bind_method(D_METHOD("get_export_path"), &EditorExportPreset::get_export_path);
 	ClassDB::bind_method(D_METHOD("get_encryption_in_filter"), &EditorExportPreset::get_enc_in_filter);
 	ClassDB::bind_method(D_METHOD("get_encryption_ex_filter"), &EditorExportPreset::get_enc_ex_filter);
@@ -366,6 +367,42 @@ EditorExportPreset::FileExportMode EditorExportPreset::get_file_export_mode(cons
 	return p_default;
 }
 
+void EditorExportPreset::add_patch(const String &p_path, int p_at_pos) {
+	ERR_FAIL_COND_EDMSG(patches.has(p_path), vformat("Failed to add patch \"%s\". Patches must be unique.", p_path));
+
+	if (p_at_pos < 0) {
+		patches.push_back(p_path);
+	} else {
+		patches.insert(p_at_pos, p_path);
+	}
+
+	EditorExport::singleton->save_presets();
+}
+
+void EditorExportPreset::set_patch(int p_index, const String &p_path) {
+	remove_patch(p_index);
+	add_patch(p_path, p_index);
+}
+
+String EditorExportPreset::get_patch(int p_index) {
+	ERR_FAIL_INDEX_V(p_index, patches.size(), String());
+	return patches[p_index];
+}
+
+void EditorExportPreset::remove_patch(int p_index) {
+	ERR_FAIL_INDEX(p_index, patches.size());
+	patches.remove_at(p_index);
+	EditorExport::singleton->save_presets();
+}
+
+void EditorExportPreset::set_patches(const Vector<String> &p_patches) {
+	patches = p_patches;
+}
+
+Vector<String> EditorExportPreset::get_patches() const {
+	return patches;
+}
+
 void EditorExportPreset::set_custom_features(const String &p_custom_features) {
 	custom_features = p_custom_features;
 	EditorExport::singleton->save_presets();

+ 9 - 0
editor/export/editor_export_preset.h

@@ -74,6 +74,8 @@ private:
 	bool advanced_options_enabled = false;
 	bool dedicated_server = false;
 
+	Vector<String> patches;
+
 	friend class EditorExport;
 	friend class EditorExportPlatform;
 
@@ -144,6 +146,13 @@ public:
 	void set_exclude_filter(const String &p_exclude);
 	String get_exclude_filter() const;
 
+	void add_patch(const String &p_path, int p_at_pos = -1);
+	void set_patch(int p_index, const String &p_path);
+	String get_patch(int p_index);
+	void remove_patch(int p_index);
+	void set_patches(const Vector<String> &p_patches);
+	Vector<String> get_patches() const;
+
 	void set_custom_features(const String &p_custom_features);
 	String get_custom_features() const;
 

+ 186 - 3
editor/export/project_export.cpp

@@ -102,11 +102,13 @@ void ProjectExportDialog::_notification(int p_what) {
 		case NOTIFICATION_THEME_CHANGED: {
 			duplicate_preset->set_icon(presets->get_editor_theme_icon(SNAME("Duplicate")));
 			delete_preset->set_icon(presets->get_editor_theme_icon(SNAME("Remove")));
+			patch_add_btn->set_icon(get_editor_theme_icon(SNAME("Add")));
 		} break;
 
 		case NOTIFICATION_READY: {
 			duplicate_preset->set_icon(presets->get_editor_theme_icon(SNAME("Duplicate")));
 			delete_preset->set_icon(presets->get_editor_theme_icon(SNAME("Remove")));
+			patch_add_btn->set_icon(get_editor_theme_icon(SNAME("Add")));
 			connect(SceneStringName(confirmed), callable_mp(this, &ProjectExportDialog::_export_pck_zip));
 			_update_export_all();
 		} break;
@@ -248,6 +250,7 @@ void ProjectExportDialog::_edit_preset(int p_index) {
 		duplicate_preset->set_disabled(true);
 		delete_preset->set_disabled(true);
 		sections->hide();
+		patches->clear();
 		export_error->hide();
 		export_templates_error->hide();
 		return;
@@ -292,6 +295,21 @@ void ProjectExportDialog::_edit_preset(int p_index) {
 	exclude_filters->set_text(current->get_exclude_filter());
 	server_strip_message->set_visible(current->get_export_filter() == EditorExportPreset::EXPORT_CUSTOMIZED);
 
+	patches->clear();
+	TreeItem *patch_root = patches->create_item();
+	Vector<String> patch_list = current->get_patches();
+	for (int i = 0; i < patch_list.size(); i++) {
+		TreeItem *patch = patches->create_item(patch_root);
+		const String &patch_path = patch_list[i];
+		patch->set_cell_mode(0, TreeItem::CELL_MODE_STRING);
+		patch->set_editable(0, true);
+		patch->set_text(0, patch_path.get_file());
+		patch->set_tooltip_text(0, patch_path);
+		patch->set_metadata(0, i);
+		patch->add_button(0, get_editor_theme_icon(SNAME("Remove")), 0);
+		patch->add_button(0, get_editor_theme_icon(SNAME("FileBrowse")), 1);
+	}
+
 	_fill_resource_tree();
 
 	bool needs_templates;
@@ -664,6 +682,7 @@ void ProjectExportDialog::_duplicate_preset() {
 	preset->set_export_filter(current->get_export_filter());
 	preset->set_include_filter(current->get_include_filter());
 	preset->set_exclude_filter(current->get_exclude_filter());
+	preset->set_patches(current->get_patches());
 	preset->set_custom_features(current->get_custom_features());
 
 	for (const KeyValue<StringName, Variant> &E : current->get_values()) {
@@ -720,8 +739,22 @@ Variant ProjectExportDialog::get_drag_data_fw(const Point2 &p_point, Control *p_
 
 			return d;
 		}
-	}
+	} else if (p_from == patches) {
+		TreeItem *item = patches->get_item_at_position(p_point);
+
+		if (item) {
+			int item_metadata = item->get_metadata(0);
+			Dictionary d;
+			d["type"] = "export_patch";
+			d["patch"] = item_metadata;
 
+			Label *label = memnew(Label);
+			label->set_text(item->get_text(0));
+			patches->set_drag_preview(label);
+
+			return d;
+		}
+	}
 	return Variant();
 }
 
@@ -735,6 +768,18 @@ bool ProjectExportDialog::can_drop_data_fw(const Point2 &p_point, const Variant
 		if (presets->get_item_at_position(p_point, true) < 0 && !presets->is_pos_at_end_of_items(p_point)) {
 			return false;
 		}
+	} else if (p_from == patches) {
+		Dictionary d = p_data;
+		if (d.get("type", "") != "export_patch") {
+			return false;
+		}
+
+		TreeItem *item = patches->get_item_at_position(p_point);
+		if (!item) {
+			return false;
+		}
+
+		patches->set_drop_mode_flags(Tree::DROP_MODE_INBETWEEN);
 	}
 
 	return true;
@@ -771,6 +816,31 @@ void ProjectExportDialog::drop_data_fw(const Point2 &p_point, const Variant &p_d
 		} else {
 			_edit_preset(presets->get_item_count() - 1);
 		}
+	} else if (p_from == patches) {
+		Dictionary d = p_data;
+		int from_pos = d["patch"];
+
+		TreeItem *item = patches->get_item_at_position(p_point);
+		if (!item) {
+			return;
+		}
+
+		int to_pos = item->get_metadata(0);
+
+		if (patches->get_drop_section_at_position(p_point) > 0) {
+			to_pos++;
+		}
+
+		if (to_pos > from_pos) {
+			to_pos--;
+		}
+
+		Ref<EditorExportPreset> preset = get_current_preset();
+		String patch = preset->get_patch(from_pos);
+		preset->remove_patch(from_pos);
+		preset->add_patch(patch, to_pos);
+
+		_update_current_preset();
 	}
 }
 
@@ -1026,6 +1096,75 @@ void ProjectExportDialog::_set_file_export_mode(int p_id) {
 	_propagate_file_export_mode(include_files->get_root(), EditorExportPreset::MODE_FILE_NOT_CUSTOMIZED);
 }
 
+void ProjectExportDialog::_patch_tree_button_clicked(Object *p_item, int p_column, int p_id, int p_mouse_button_index) {
+	TreeItem *ti = Object::cast_to<TreeItem>(p_item);
+
+	patch_index = ti->get_metadata(0);
+
+	Ref<EditorExportPreset> current = get_current_preset();
+	ERR_FAIL_COND(current.is_null());
+
+	if (p_id == 0) {
+		Vector<String> preset_patches = current->get_patches();
+		ERR_FAIL_INDEX(patch_index, preset_patches.size());
+		patch_erase->set_text(vformat(TTR("Delete patch '%s' from list?"), preset_patches[patch_index].get_file()));
+		patch_erase->popup_centered();
+	} else {
+		patch_dialog->popup_file_dialog();
+	}
+}
+
+void ProjectExportDialog::_patch_tree_item_edited() {
+	TreeItem *item = patches->get_edited();
+	if (!item) {
+		return;
+	}
+
+	Ref<EditorExportPreset> current = get_current_preset();
+	ERR_FAIL_COND(current.is_null());
+
+	int index = item->get_metadata(0);
+	String patch_path = item->get_text(0);
+
+	current->set_patch(index, patch_path);
+	item->set_tooltip_text(0, patch_path);
+}
+
+void ProjectExportDialog::_patch_file_selected(const String &p_path) {
+	Ref<EditorExportPreset> current = get_current_preset();
+	ERR_FAIL_COND(current.is_null());
+
+	String relative_path = ProjectSettings::get_singleton()->get_resource_path().path_to_file(p_path);
+
+	Vector<String> preset_patches = current->get_patches();
+	if (patch_index >= preset_patches.size()) {
+		current->add_patch(relative_path);
+	} else {
+		current->set_patch(patch_index, relative_path);
+	}
+
+	_update_current_preset();
+}
+
+void ProjectExportDialog::_patch_delete_confirmed() {
+	Ref<EditorExportPreset> current = get_current_preset();
+	ERR_FAIL_COND(current.is_null());
+
+	Vector<String> preset_patches = current->get_patches();
+	if (patch_index < preset_patches.size()) {
+		current->remove_patch(patch_index);
+		_update_current_preset();
+	}
+}
+
+void ProjectExportDialog::_patch_add_pack_pressed() {
+	Ref<EditorExportPreset> current = get_current_preset();
+	ERR_FAIL_COND(current.is_null());
+
+	patch_index = current->get_patches().size();
+	patch_dialog->popup_file_dialog();
+}
+
 void ProjectExportDialog::_export_pck_zip() {
 	Ref<EditorExportPreset> current = get_current_preset();
 	ERR_FAIL_COND(current.is_null());
@@ -1044,11 +1183,20 @@ void ProjectExportDialog::_export_pck_zip_selected(const String &p_path) {
 
 	const Dictionary &fd_option = export_pck_zip->get_selected_options();
 	bool export_debug = fd_option.get(TTR("Export With Debug"), true);
+	bool export_as_patch = fd_option.get(TTR("Export As Patch"), true);
 
 	if (p_path.ends_with(".zip")) {
-		platform->export_zip(current, export_debug, p_path);
+		if (export_as_patch) {
+			platform->export_zip_patch(current, export_debug, p_path);
+		} else {
+			platform->export_zip(current, export_debug, p_path);
+		}
 	} else if (p_path.ends_with(".pck")) {
-		platform->export_pack(current, export_debug, p_path);
+		if (export_as_patch) {
+			platform->export_pack_patch(current, export_debug, p_path);
+		} else {
+			platform->export_pack(current, export_debug, p_path);
+		}
 	} else {
 		ERR_FAIL_MSG("Path must end with .pck or .zip");
 	}
@@ -1386,6 +1534,40 @@ ProjectExportDialog::ProjectExportDialog() {
 			exclude_filters);
 	exclude_filters->connect(SceneStringName(text_changed), callable_mp(this, &ProjectExportDialog::_filter_changed));
 
+	// Patch packages.
+
+	VBoxContainer *patch_vb = memnew(VBoxContainer);
+	sections->add_child(patch_vb);
+	patch_vb->set_name(TTR("Patches"));
+
+	patches = memnew(Tree);
+	patches->set_v_size_flags(Control::SIZE_EXPAND_FILL);
+	patches->set_hide_root(true);
+	patches->set_auto_translate_mode(AUTO_TRANSLATE_MODE_DISABLED);
+	patches->connect("button_clicked", callable_mp(this, &ProjectExportDialog::_patch_tree_button_clicked));
+	patches->connect("item_edited", callable_mp(this, &ProjectExportDialog::_patch_tree_item_edited));
+	SET_DRAG_FORWARDING_GCD(patches, ProjectExportDialog);
+	patches->set_edit_checkbox_cell_only_when_checkbox_is_pressed(true);
+	patch_vb->add_margin_child(TTR("Base Packs:"), patches, true);
+
+	patch_dialog = memnew(EditorFileDialog);
+	patch_dialog->add_filter("*.pck", TTR("Godot Project Pack"));
+	patch_dialog->set_access(EditorFileDialog::ACCESS_FILESYSTEM);
+	patch_dialog->set_file_mode(EditorFileDialog::FILE_MODE_OPEN_FILE);
+	patch_dialog->connect("file_selected", callable_mp(this, &ProjectExportDialog::_patch_file_selected));
+	add_child(patch_dialog);
+
+	patch_erase = memnew(ConfirmationDialog);
+	patch_erase->set_ok_button_text(TTR("Delete"));
+	patch_erase->connect(SceneStringName(confirmed), callable_mp(this, &ProjectExportDialog::_patch_delete_confirmed));
+	add_child(patch_erase);
+
+	patch_add_btn = memnew(Button);
+	patch_add_btn->set_text(TTR("Add Pack"));
+	patch_add_btn->set_h_size_flags(Control::SIZE_SHRINK_CENTER);
+	patch_add_btn->connect(SceneStringName(pressed), callable_mp(this, &ProjectExportDialog::_patch_add_pack_pressed));
+	patch_vb->add_child(patch_add_btn);
+
 	// Feature tags.
 
 	VBoxContainer *feature_vb = memnew(VBoxContainer);
@@ -1569,6 +1751,7 @@ ProjectExportDialog::ProjectExportDialog() {
 
 	export_project->add_option(TTR("Export With Debug"), Vector<String>(), true);
 	export_pck_zip->add_option(TTR("Export With Debug"), Vector<String>(), true);
+	export_pck_zip->add_option(TTR("Export As Patch"), Vector<String>(), true);
 
 	set_hide_on_ok(false);
 

+ 13 - 0
editor/export/project_export.h

@@ -105,6 +105,13 @@ class ProjectExportDialog : public ConfirmationDialog {
 	AcceptDialog *export_all_dialog = nullptr;
 
 	RBSet<String> feature_set;
+
+	Tree *patches = nullptr;
+	int patch_index = -1;
+	EditorFileDialog *patch_dialog = nullptr;
+	ConfirmationDialog *patch_erase = nullptr;
+	Button *patch_add_btn = nullptr;
+
 	LineEdit *custom_features = nullptr;
 	RichTextLabel *custom_feature_display = nullptr;
 
@@ -148,6 +155,12 @@ class ProjectExportDialog : public ConfirmationDialog {
 	void _tree_popup_edited(bool p_arrow_clicked);
 	void _set_file_export_mode(int p_id);
 
+	void _patch_tree_button_clicked(Object *p_item, int p_column, int p_id, int p_mouse_button_index);
+	void _patch_tree_item_edited();
+	void _patch_file_selected(const String &p_path);
+	void _patch_delete_confirmed();
+	void _patch_add_pack_pressed();
+
 	Variant get_drag_data_fw(const Point2 &p_point, Control *p_from);
 	bool can_drop_data_fw(const Point2 &p_point, const Variant &p_data, Control *p_from) const;
 	void drop_data_fw(const Point2 &p_point, const Variant &p_data, Control *p_from);

+ 25 - 2
main/main.cpp

@@ -650,6 +650,8 @@ void Main::print_help(const char *p_binary) {
 	print_help_option("", "The target directory must exist.\n");
 	print_help_option("--export-debug <preset> <path>", "Export the project in debug mode using the given preset and output path. See --export-release description for other considerations.\n", CLI_OPTION_AVAILABILITY_EDITOR);
 	print_help_option("--export-pack <preset> <path>", "Export the project data only using the given preset and output path. The <path> extension determines whether it will be in PCK or ZIP format.\n", CLI_OPTION_AVAILABILITY_EDITOR);
+	print_help_option("--export-patch <preset> <path>", "Export pack with changed files only. See --export-pack description for other considerations.\n", CLI_OPTION_AVAILABILITY_EDITOR);
+	print_help_option("--patches <paths>", "List of patches to use with --export-patch. The list is comma-separated.\n", CLI_OPTION_AVAILABILITY_EDITOR);
 	print_help_option("--install-android-build-template", "Install the Android build template. Used in conjunction with --export-release or --export-debug.\n", CLI_OPTION_AVAILABILITY_EDITOR);
 #ifndef DISABLE_DEPRECATED
 	// Commands are long; split the description to a second line.
@@ -1469,12 +1471,23 @@ Error Main::setup(const char *execpath, int argc, char *argv[], bool p_second_ph
 			wait_for_import = true;
 			quit_after = 1;
 		} else if (arg == "--export-release" || arg == "--export-debug" ||
-				arg == "--export-pack") { // Export project
+				arg == "--export-pack" || arg == "--export-patch") { // Export project
 			// Actually handling is done in start().
 			editor = true;
 			cmdline_tool = true;
 			wait_for_import = true;
 			main_args.push_back(arg);
+		} else if (arg == "--patches") {
+			if (N) {
+				// Actually handling is done in start().
+				main_args.push_back(arg);
+				main_args.push_back(N->get());
+
+				N = N->next();
+			} else {
+				OS::get_singleton()->print("Missing comma-separated list of patches after --patches, aborting.\n");
+				goto error;
+			}
 #ifndef DISABLE_DEPRECATED
 		} else if (arg == "--export") { // For users used to 3.x syntax.
 			OS::get_singleton()->print("The Godot 3 --export option was changed to more explicit --export-release / --export-debug / --export-pack options.\nSee the --help output for details.\n");
@@ -3480,9 +3493,11 @@ int Main::start() {
 	bool doc_tool_implicit_cwd = false;
 	BitField<DocTools::GenerateFlags> gen_flags;
 	String _export_preset;
+	Vector<String> patches;
 	bool export_debug = false;
 	bool export_pack_only = false;
 	bool install_android_build_template = false;
+	bool export_patch = false;
 #ifdef MODULE_GDSCRIPT_ENABLED
 	String gdscript_docs_path;
 #endif
@@ -3572,6 +3587,14 @@ int Main::start() {
 				editor = true;
 				_export_preset = E->next()->get();
 				export_pack_only = true;
+			} else if (E->get() == "--export-patch") {
+				ERR_FAIL_COND_V_MSG(!editor && !found_project, EXIT_FAILURE, "Please provide a valid project path when exporting, aborting.");
+				editor = true;
+				_export_preset = E->next()->get();
+				export_pack_only = true;
+				export_patch = true;
+			} else if (E->get() == "--patches") {
+				patches = E->next()->get().split(",", false);
 #endif
 			} else {
 				// The parameter does not match anything known, don't skip the next argument
@@ -3975,7 +3998,7 @@ int Main::start() {
 			sml->get_root()->add_child(editor_node);
 
 			if (!_export_preset.is_empty()) {
-				editor_node->export_preset(_export_preset, positional_arg, export_debug, export_pack_only, install_android_build_template);
+				editor_node->export_preset(_export_preset, positional_arg, export_debug, export_pack_only, install_android_build_template, export_patch, patches);
 				game_path = ""; // Do not load anything.
 			}