Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Treat loaded resource packs separately from each other #2689

Open
LikeLakers2 opened this issue May 5, 2021 · 13 comments
Open

Treat loaded resource packs separately from each other #2689

LikeLakers2 opened this issue May 5, 2021 · 13 comments

Comments

@LikeLakers2
Copy link

LikeLakers2 commented May 5, 2021

Describe the project you are working on

I want Godot to have better modding support.

Describe the problem or limitation you are having in your project

While it is possible for additional resource packs to be loaded in via ProjectSettings.load_resource_pack(), the current system does not allow unloading of resource packs, meaning that there is no way to unload mods beyond restarting the game (which can be a pain for large-scale games with lots of resources).

Additionally, while the current system offers a replace_files parameter so we may choose if a mod can "overwrite" the game's files, this actually causes a much bigger problem. Namely, if the game and a mod contain a file at the same file path (let's say, res://enemies/list.txt), only one or the other can be accessed (and this problem only gets worse the more mods we add).

Describe the feature / enhancement and how it helps to overcome the problem or limitation

I propose that we create a system where loaded resource packs are treated separately from each other. By doing so, we will be able to solve certain issues with moddability, such as:

  • Being able to unload resource packs during gameplay
  • Allowing the game (and loaded mods) to choose whether its files can be overridden by mods -- potentially on a case-by-case basis
  • Disabling or distrusting individual mods, so that developers can choose what things a mod can affect by a game (for example, whether a mod can only act as a texture pack, or if it can run code)

and so on.

Describe how your proposal will work, with code, pseudo-code, mock-ups, and/or diagrams

I propose that we expose functions for the following:

  • Load and unload resource packs.

  • Check if a specific path is already loaded as a resource pack.

  • Retrieve resources from loaded resource packs.

    • Whether this is a new function, or something internal that @GDScript.load() nor ResourceLoader.load() wrap, is up for debate. However, I'm including this here for the sake of completeness.
      That said, if this is a new function, it should not deprecate other resource-loading functions.
  • Retrieve resources from a single loaded resource pack, even if the pack is not enabled.

  • Set a loaded pack as "disabled" (without unloading it), preventing resources from being loaded from the resource pack, unless the pack is specifically targeted.

  • Set a loaded pack as "untrusted", preventing scripts from being loaded from that pack (even if the pack is specifically targeted).

  • Obtain various pieces of information on a single loaded pack and its files (such as a loaded pack's path, file list, and the existence of certain files).

  • Obtain a file from the root of an unloaded pack, without needing to load the pack first, that would provide various bits of information to the game... such as pack name, pack author, pack version, dependencies, and so on.

If this enhancement will not be used often, can it be worked around with a few lines of script?

No, not really. The limitations I described in the "Describe the problem or limitation you are having in your project" section are not really possible to overcome through GDScript.

Is there a reason why this should be core and not an add-on in the asset library?

It's technically possible for this to be handled as an add-on... but I don't think this would work too well as just an add-on. Plus, even if 90% of Godot games might not implement modding, this could make modding much more flexible for the other 10%.

@LikeLakers2
Copy link
Author

LikeLakers2 commented May 5, 2021

Edit: Hey there! Just want to let anyone reading this, that I'm thinking of reworking this proposed implementation. Quite honestly, this one feels a bit over-engineered to me. I think I can do a lot better.


Below is my proposal for how I believe this could be implemented. If you have your own proposal for how this proposal should be implemented, feel free to base it on this, or make your own wildly different implementation if you desire.

I believe this functionality should be exposed under a new singleton. The name is up in the air, but for ease, I'll be calling it ResourceFilesystemServer. This class manages all access to the res:// filesystem, as well as facilitating loading and unloading of resource packs, and additionally allows inspection of individual loaded resource packs.

Function signatures

  • Load and unload resource packs.
    • func load_resource_pack(path: String, trusted: bool = false, load_position: int = -1) -> bool
      func unload_resource_pack(idx: int)
      The game's main resource pack cannot be unloaded.
      Attempting to load a resource pack that is already loaded will return false, and not change anything (included trusted state).
      The parameter load_position is primarily intended to be used in situations where a mod needs to be reloaded without changing its position (such as when switching the trusted state). If left unspecified, it will be considered the most-recently-loaded pack by functions such as get_resource(); but if specified (and valid, meaning between 1 and loaded_pack.size(), inclusive) will be given that index
  • Check if a specific path is already loaded as a resource pack.
    • func is_loaded(path: String) -> bool
  • Retrieve resources from loaded resource packs.
    • func get_resource(path: String) -> Resource
      This should search the most-recently-loaded pack first, and the least-recently-loaded pack (usually the game's main resource pack) last.
  • Retrieve resources from a single loaded resource pack, even if the pack is not enabled.
    • func get_resource_from_pack(pack_idx: int, path: String) -> Resource
    • Alternatively, something akin to a "Versioned File Paths" concept (described in the "Notes and Implementation Details" section) will suffice.
  • Be able to set a loaded pack as "disabled" (without unloading it)
    • func get_loaded_pack_enabled(idx: int) -> bool
      func set_loaded_pack_enabled(idx: int, enabled: bool)
  • Be able to set a loaded pack as "untrusted", preventing scripts from being loaded from that pack (even if the pack is specifically targeted).
    • func get_loaded_pack_trusted(idx: int) -> bool
      func load_resource_pack(path: String, trusted: bool = false) -> bool
  • Obtain various pieces of information on a single loaded pack and its files (such as a loaded pack's path, file list, and the existence of certain files).
    • func get_loaded_pack(idx: int) -> ResourceFilesystemSource
      func get_loaded_packs() -> Array<ResourceFilesystemSource>
      func get_loaded_pack_count() -> int
      The ResourceFilesystemSource class referenced here is described in the "Notes and Implementation Details" section of this comment.
  • Obtain a file from the root of an unloaded pack, without needing to load the pack first, that would provide various bits of information to the game
    • func inspect_pack(pack_path: String) -> Variant
      This would load a pack.json file, from the root of a pack specified by pack_path. That file would be parsed the JSON within into a Variant (in the same manner as @GDScript.parse_json() does), and returns that Variant to the user for inspection
      (Note: We'll most likely want this to return something else! Perhaps a PackInfo object that we can use to inspect the pack.json file?)

I propose the following additional items:

  • One or two signals that signify when a pack is loaded or unloaded
    • signal pack_loaded(pack_idx: int)
      signal pack_unloaded(pack_idx: int)

Notes and Implementation Details

This also includes details on the ResourceFilesystemSource class referenced above.

Click to expand

Disabling/enabling packs

Using set_loaded_pack_enabled(), a pack can be marked as enabled or disabled. By default, all loaded packs are marked as enabled. Additionally, a game's main resource pack cannot be marked as disabled.

When a pack is disabled, it is not searched for resources when using get_resource(). However, it is still possible to retrieve resources from a disabled pack, by specifying a disabled pack's index to get_resource_from_pack()'s pack_idx parameter.

NOTE: Disabling a pack does not affect any already-loaded resources in any way, nor does it reverse any effects those resources may have had.

(Un)Trusted packs

When loading a pack, an optional argument to load_resource_pack(), named trusted, can be passed. By default this is set to false (except for a game's main resource pack), meaning all loaded packs are normally untrusted.

When a pack is untrusted, no scripts can be loaded from that pack, but resources from that pack can still use scripts from trusted packs. The ability to set a pack as untrusted is useful for developers who desire mod support where the mods are not allowed to add/change code -- for example, with texture packs or custom levels.

To be more specific on how this works: When a pack is untrusted, any resources within are subject to the following restrictions when using any functions that return a Resource:

  1. If a file path contains any script extensions (such as .gd), then any untrusted packs are not searched.
  2. If an untrusted pack is searched for a resource, and it returns a resource inheriting Script, then that resource is discarded. If applicable, resource searching continues on the next pack.
  3. If an untrusted pack returns something with a script sub-resource:
    1. If the script sub-resource is a built-in script, we do not load it. However, the rest of the resource may be loaded.
    2. If the script sub-resource is a non-built-in script, then we attempt to load it through get_resource(), subjecting it to the above restrictions as normal. This allows untrusted packs to use scripts provided by a game, without allowing a mod to extend or change the game's functionality.

If a developer wishes to change the trusted status of a pack at runtime, they should unload the resource pack and reload it using unload_resource_pack() and load_resource_pack().

NOTE: If you previously had a resource pack marked as trusted, then decided to reload it as untrusted, be aware that any already-loaded resources coming from that pack will not be affected in any way. Be careful if you decide to change a pack's trusted status at runtime.

ResourceFilesystemSource

ResourceFilesystemSource is a representation of a singular source of resources for the game. You can obtain this through the get_loaded_pack() function.

This class (as I describe it here) is meant as a base class, and should be inherited by subclasses that handle the different types of sources we can accept. For example:

  • ResourceFilesystemSourcePCK, for PCK sources
  • ResourceFilesystemSourceZIP, for ZIP sources
  • ResourceFilesystemSourceFolder, for exposing an entire folder as a source

and so on.

I believe each source should manage its own resource cache, rather than the engine managing one large resource cache. This makes it easier for the engine to continue using a cache for speed, while not requiring the engine to recreate the cache every time a mod is loaded or unloaded.

Additionally, I propose that the following information be exposed through this class:

  • The source's path
    • func get_path() -> String
  • The source's full file list
    • func get_file_list() -> PackedStringArray
  • Whether a file exists within the source
    • func file_exists(path: String) -> bool
  • The contents of a file
    • func read_file(path: String) -> PackedByteArray

Lastly, while I do list ResourceFilesystemSource as its own class, there is nothing preventing the above functions from being included directly within ResourceFilesystemServer.

Versioned File Paths

File paths, such as those in Node.filename and Resource.resource_path, as well as paths provided to the extends keyword in scripts, currently only point to one version of the file: the latest version of the file to be loaded.

However, in some cases, it may be desired that a file path be able to point to a specific version of a file. For example, let's say that a mod does not want to use any version of res://enemies/list.txt other than its own. While it is possible for the mod to obtain its own version of the file through ResourceFilesystemServer.get_resource_from_pack(), it may be desirable to achieve this functionality while still only requiring @GDScript.load().

To handle this, I propose the concept of "versioned file paths." These file paths will always refer to one specific file from one specific loaded pack, and never to any other pack. Ideally, these file paths would still be Strings, just as normal file paths are. However, these strings are formatted specially to refer to one pack.

I am not sure what format these "versioned file paths" should be in, however. Ideally, a "versioned file path" would refer to a pack even if that pack becomes unloaded, and perhaps could be accepted even if the source pack was never loaded before.

With all that said, to round out the example given above (a mod wanting to load its own enemies list, and nobody else's), let's assume that these file paths are formatted like <source-pack>::<resource-path>. In this example, the mod would want to load mod.pck::enemies/list.txt (where mod.pck could be a relative path or an absolute path).

P.S. I understand this "versioned file paths" proposal may warrant being a separate proposal from the rest of this proposal. If desired, I can move this to a new proposal.

Other Wanted/Needed Changes

Click to expand

Changes to ProjectSettings

ProjectSettings should have a new function added to it, ProjectSettings.unload_resource_pack(pack: String), to facilitate easy unloading of resource packs. This function would unload a resource pack by its file path, by first figuring out the index of the loaded pack with the provided file path, and then using ResourceFilesystemServer.unload_resource_pack() to unload it.

Additionally, ProjectSettings.load_resource_pack(pack: String) -> bool should be changed to use ResourceFilesystemServer.load_resource_pack(pack). I am not currently sure how to handle the replace_files parameter, since with this proposal, the resource filesystem loses its concept of "patching" in place for simply reading multiple sources in reverse order, so it is possible that load_resource_pack() may need to ditch the replace_files parameter.

Misc changes

Due to the nature of my proposed implementation, some functions may need to be changed or removed:

  • File's behavior for handling res:// paths should be changed, as any loaded packs are meant to be read-only:

    • If we are running on an editor build, then File should globalize any res:// paths to the project directory. It should do this whether we are in the editor, or running a non-exported test of a project.
    • If we are not running on an editor build, then File should use ResourceFilesystemServer's functions to obtain the requested res:// file. However, any attempts at writing to these paths should fail.
  • Resource.take_over_path() should be removed, as there is no longer one global resource cache.

GDScript Reference Code

ResourceFilesystemServer
extends Object

class ResourceFilesystemLoadedPack:
	var enabled: bool = true
	var trusted: bool = false
	var source: ResourceFilesystemSource
	
	# The resource cache for this loaded pack. Putting it in this instead of
	# having a global resource cache means it's easier for the cache to be
	# "updated" when a pack is loaded/unloaded... since the resource cache for
	# each pack will get deleted when that pack gets unloaded.
	#
	# As an aside, this `ResourceCache` class is entirely ficticious, and the
	# name is used here as a placeholder for however we actually implement the
	# resource cache.
	#
	# For now, assume this has two functions: `has(path: String)` for checking if
	# a resource is cached, and `add(path: String, resource: Resource)` for adding
	# a resource to the cache.
	var resource_cache: ResourceCache

var loaded_packs: Array # Array<ResourceFilesystemLoadedPack>

################################################################################

func is_loaded(
	path: String
) -> bool:
	for pack in loaded_packs:
		if pack.source.get_path() == path:
			return true
	return false

func load_resource_pack(
	path: String,
	trusted: bool = false,
	load_position: int = -1
) -> bool: # We can also use Error here, if we don't mind adding a few constants
	# First, check if this path is already loaded:
	if is_loaded(path):
		return false
	
	# The path supplied isn't loaded, so let's load it!
	
	var new_source = ResourceFilesystemLoadedPack.new() 
	# <magic goes here for figuring out and instantiating the correct `ResourceFilesystemSource*` class>
	# This is also probably where we'd return any IO errors that we encounter (file not existing, for example)
	
	# Make sure to set the trusted state!
	new_source.trusted = trusted
	
	# We want to respect `load_position` here.
	if (load_position > 0) and (load_position <= loaded_packs.size()):
		# `load_position` was specified and is valid (not 0, not negative, not above the size of the array)
		loaded_packs.insert(load_position, new_source)
	elif load_position == -1:
		# `load_position` was not specified, or was specified as -1
		loaded_packs.push_back(new_source)
	else:
		printerr("Error loading pack: Invalid load_position specified")
		return false
	
	return true

func unload_resource_pack(
	idx: int
):
	if idx == 0:
		# We don't want to unload the main game's resources.
		printerr("Main resource pack (index 0) cannot be unloaded")
		return
	
	if (idx < 0) or (idx >= loaded_packs.size()):
		# Out of bounds index
		printerr("Attempted to unload resource pack at out-of-bounds index " + str(idx))
		return
	
	loaded_packs.remove(idx)

func inspect_pack(
	pack_path: String
) -> Variant:
	# We most likely wouldn't use the normal loading procedure here. Instead, we'd probably
	# use a function that returns a `ResourceFilesystemLoadedPack` that hasn't been added to
	# the `loaded_packs` array. However, for simplicity, I'm going to use `load_resource_pack()`
	# here. Just do me a favor and assume that the pack we specify will always end up at the
	# end of the `loaded_packs` array.
	# 
	# (I'll probably clean up this code later!)
	load_resource_pack(pack_path)
	# This has the side effect of unloading it, but ideally we'd get the `ResourceFilesystemLoadedPack`
	# from whatever function we use to load it.
	var temporary_pack = loaded_packs.pop_back()
	
	# Get `pack.json` and parse it into a Dictionary
	if temporary_pack.source.file_exists("pack.json"):
		var json_byte_array = temporary_pack.source.read_file("pack.json")
		var json_string = json_byte_array.get_string_from_utf8()
		var pack_info_dict = parse_json(json_string)
		return pack_info_dict
	else:
		return ERR_FILE_NOT_FOUND

################################################################################

func get_loaded_pack(
	idx: int
) -> ResourceFilesystemSource:
	return loaded_packs[idx].source

func get_loaded_pack_count() -> int:
	return loaded_packs.size()

func get_loaded_packs() -> Array:
	return loaded_packs

################################################################################

func get_resource(
	path: String
) -> Variant:
	# We want to check the loaded packs in reverse order. Easiest way is to invert
	# an array returned from range().
	var inverted_pack_order = range(loaded_packs.size())
	inverted_pack_order.invert()
	
	var returned_resource = null
	
	for i in inverted_pack_order:
		if loaded_packs[i].enabled:
			var returned_resource = _get_resource_from_pack(
				i,
				path
			)
			
			# If we got a resource, return it
			if returned_resource:
				return returned_resource
	
	# We didn't get a resource, so we return `null`.
	return null

func get_resource_from_pack(
	pack_idx: int,
	path: String
) -> Variant:
	return _get_resource_from_pack(
		pack_idx,
		path
	)

# Internal function, not exposed to GDScript
func _get_resource_from_pack(
	pack_idx: int,
	path: String
) -> Variant:
	var current_pack = self.loaded_packs[pack_idx]
	var loaded_resource = null
	
	# So, for this function, I'm going to be using pseudo-code for the purposes of summarizing. If you spot
	# a function being called that doesn't exist in this reference code, assume it does what I describe
	# in the comments just above it.
	
	# Firstly, we should check the resource cache:
	if current_pack.resource_cache.has(path):
		loaded_resource = current_pack.resource_cache.get(path)
	else:
		# Since it doesn't exist in the resource cache, we need to load it.
		
		# If the pack is untrusted, and the requested path's extension may refer to a Script resource (for
		# example, if the filename ends in `.gd`), we preemptively return `null`.
		if !(current_pack.trusted) and path_extension_refers_to_script_resource(path):
			return null
			
		# Otherwise, we continue on by checking for the file's existance:
		if current_pack.source.file_exists(path):
			# Since the file supposedly exists, we grab the file. Then we load it using the appropriate
			# ResourceFormatLoader. We should ensure that any ResourceFormatLoader we call here knows
			# if a resource comes from an untrusted pack. This allows the ResourceFormatLoader to know
			# if it should ignore built-in scripts.
			loaded_resource = load_this_content_to_a_resource(
				current_pack.source.read_file(path),
				current_pack.trusted
			)
	
	# As one final precaution before we return the resource, we check if the current pack is trusted, and if the
	# loaded resource is a Script. If both of those conditions are true, we instead return `null`.
	if !(current_pack.trusted) and (loaded_resource is Script):
		return null
	
	# Finally, we add the loaded resource to the resource cache (if there was even a resource loaded to begin with:
	if loaded_resource and !(current_pack.resource_cache.has(path)):
		current_pack.resource_cache.add(
			path,
			loaded_resource
		)
	
	return loaded_resource

################################################################################

func set_loaded_pack_enabled(
	idx: int,
	enabled: bool
):
	if idx != 0:
		loaded_packs[idx].enabled = enabled

func get_loaded_pack_enabled(
	idx: int
) -> bool:
	return loaded_packs[idx].enabled

func get_loaded_pack_trusted(
	idx: int
) -> bool:
	return loaded_packs[idx].trusted
ResourceFilesystemSource
extends Reference

################################################################################

func get_file_list() -> PackedStringArray:
	# Must be implemented by inheriting this class.
	pass

func get_path() -> String:
	# Must be implemented by inheriting this class.
	pass

################################################################################

func file_exists(
	path: String
) -> bool:
	# Must be implemented by inheriting this class.
	pass

func read_file(
	path: String
) -> PackedByteArray:
	# Must be implemented by inheriting this class.
	pass

@Ansraer
Copy link

Ansraer commented May 5, 2021

If every resource pack is loaded as untrusted and Scripts are simply ignored, what happens to all the missing Script resources and subresources when I change the pack to trusted? No matter how much I think about it, I can't come up with any (good) way to load them after the fact, especially when some scenes have already been instantiated (and maybe modified) without the Script.

Instead of allowing users to change the trusted status of a resource pack at runtime, I propose that this is set directly when the pack is loaded via an additional optional argument (defaulting to not trusted).
Should people ever want to change the state of a resource pack afterwards they would have to manually unload and reload the pack.

In addition, some sort of inspect_pack(path: String) function that reads a pack.json file in a pack's root directory would be useful. This way devs could get both generic (e.g. authors, trusted_required, dependencies, name, version,...) and game-specific information about packs before (and after) they load them.
We could also use this information to throw errors (e.g. trying to load a pack as untrusted when it declares that it needs to be loaded as trusted) and provide additional functionality such as loading an entire folder of mods in the correct order/throw errors if any dependency is missing.

@Calinou Calinou changed the title Better Mod-Loading Support Treat loaded resource packs separately from each other May 5, 2021
@LikeLakers2
Copy link
Author

LikeLakers2 commented May 5, 2021

If every resource pack is loaded as untrusted and Scripts are simply ignored, what happens to all the missing Script resources and subresources when I change the pack to trusted? No matter how much I think about it, I can't come up with any (good) way to load them after the fact, especially when some scenes have already been instantiated (and maybe modified) without the Script.

@Ansraer Okay, this is a good point. I had left it as I did thinking that there might be some reason we would want to trust a pack after the fact, but in hindsight I hadn't actually tried to think of any reasons. And now I imagine any situations I do think up would be few and far between.

I think I'll take your suggestion:

Instead of allowing users to change the trusted status of a resource pack at runtime, I propose that this is set directly when the pack is loaded via an additional optional argument (defaulting to not trusted).
Should people ever want to change the state of a resource pack afterwards they would have to manually unload and reload the pack.

and change my proposed implementation to match.


In addition, some sort of inspect_pack(path: String) function that reads a pack.json file in a pack's root directory would be useful. This way devs could get both generic (e.g. authors, trusted_required, dependencies, name, version,...) and game-specific information about packs before (and after) they load them.
We could also use this information to throw errors (e.g. trying to load a pack as untrusted when it declares that it needs to be loaded as trusted) and provide additional functionality such as loading an entire folder of mods in the correct order/throw errors if any dependency is missing.

Good suggestion! Though I'm not sure if I want to change my proposed implementation to include this, as then we would want to include facilities in the editor to specify what info goes into this pack.json file, at export-time.

@Ansraer
Copy link

Ansraer commented May 5, 2021

Good suggestion! Though I'm not sure if I want to change my proposed implementation to include this, as then we would want to include facilities in the editor to specify what info goes into this pack.json file, at export-time.

I think you will have to. Without it there is no way for people who use gdscript to get information about provided packs. They would either have to load, get info, unload the entire pack (significant performance overhead) or write a hacky workaround with the ZipUtils (which might break when the pack is signed/encrypted).

Plus, having additional information should allow us to catch certain bugs before they happen while also providing more in depth crash logs.

@LikeLakers2
Copy link
Author

LikeLakers2 commented May 5, 2021

@Ansraer Before I tackle that issue, I want to go back to the issue of handling trusted state. There is something important I missed making my first reply.

I did some thinking, and I actually realized one issue with changing my implementation to match what you described (trusted state is set at the time of loading rather than at any time). Specifically, requiring that the developer unload and reload the pack, it can cause issues with load order. In its current state, newly-loaded packs are checked first for resources -- but if we're simply changing the trusted state, we may not want to change where in the list that mod is.

That said, I think it might be an easy fix: Adding a load_position argument to load_resource_pack() which determines where in the loaded_packs array that the new pack would go. What do you think?

@Ansraer
Copy link

Ansraer commented May 7, 2021

Oh, yeah. Totally forgot about that. An optional load order argument sounds like a good solution.

Should we ever get around to including additional information in packs we could also provide a list of dependencies, which could be used to try and automatically figure out the correct load order.

@LikeLakers2
Copy link
Author

LikeLakers2 commented May 7, 2021

@Ansraer Not a bad idea, to be honest -- but that means we'd have to deal with how to specify that list of dependencies. Unfortunately, I'm not sure we can do that in a game-agnostic manner. But even if we could, I think it would be best figured out down the road.

Anyways, I'll add func inspect_pack(pack_path: String) -> Variant to my proposal, which would read a file like pack.json from the root of the pack, parse the JSON to a Variant (because we are not guaranteed to get a JSON Object as the root... thing), and returns that Variant.

It's likely that we'll want to change that return value to something else -- perhaps it returns a PackInfo object that we can use to inspect the contents of that pack.json file? But for now, I'm writing it as "You get a json object" because that's the simplest way to think about it.

@ghost
Copy link

ghost commented Jun 1, 2021

I tackled this problem over the last few days and came up with a simple solution with very few changes. Now this hasn't been battle-tested nor I'm not knowledgeable enough to ascertain if this is a proper approach to this but, nonetheless, it currently works with what I expect to use this feature for. I hope this sparks better ideas on how to implement this. From now on I will refer to resource packs loaded in this manner as DLC. In a similar way I have named the classes like this too. I welcome suggestions for more general purpose class names.

Here it is in a single commit:
https://github.com/chottokite/godot/commit/ebadfa6986576c65c992e9cd5de252f3d206209f

Changes

I added 3 classes to core Godot. PackedDLCData, DLCPackResourceLoader and DLC.

PackedDLCData

PackedData is the filesystem of a Godot game or program. It contains (hashed) mappings of filesystem paths to offsets to Godot data files. Godot uses a single PackedData object. If it loads a new resource pack and such resource pack contains duplicated file paths, it may replace the old or drop the new file references. PackedDLCData comes into this by creating additional filesystems for each DLC the game loads. That means the conflict of repeated file paths among DLC packs is eliminated however, DLC packs can't replace the game's filesystem paths, because the latter has priority.

DLCPackResourceLoader

Once you load a DLC pack you get a DLCPackResourceLoader object that you have to use to load the imported resources. This class activates and deactivates the DLC pack resources this object internally refers to.

DLC

This is a singleton with functionality similar to ProjectSettings::load_resource_pack. It is the entry point of dealing with DLC packs.

PackResource (Not included)

There is an extra class that I didn't include but is from a custom module in my Godot build. It is similar to DLCPackResourceLoader in that it loads resources from a DLC pack but it is a resource itself that is in the DLC. It's meant to describe and load a DLC element (a composition of scenes and resources).

Discovering DLC content

Each project should handle this in the way that fits them the best. In my case, I expect DLC to have res://resources.list which is a simple text file with a PackResource resource full path in each line.

Addressing some proposals from this issue

Load and unload resource packs.

Loading is implemented and unload is easily doable too

Check if a specific path is already loaded as a resource pack.

Besides not loading a resource pack twice (this implementation doesn't) what is the need for this?

Retrieve resources from loaded resource packs.
Retrieve resources from a single loaded resource pack, even if the pack is not enabled.

DLCPackResourceLoader does this

Set a loaded pack as "disabled" (without unloading it), preventing resources from being loaded from the resource pack, unless the pack is specifically targeted.

You can only load resources from packs you explicitly desire to load from

Set a loaded pack as "untrusted", preventing scripts from being loaded from that pack (even if the pack is specifically targeted).

I too wish we could have limited ClassDBs for user generated DLC packs.

Obtain various pieces of information on a single loaded pack and its files (such as a loaded pack's path, file list, and the existence of certain files).
Obtain a file from the root of an unloaded pack, without needing to load the pack first, that would provide various bits of information to the game... such as pack name, pack author, pack version, dependencies, and so on.

Since DLC can have repeated file paths that don't conflict with the base game resources, this can be provided by a known file in the DLC root. But if you must, as in you want to avoid loading a resource pack, you can prepend or append meta-data to a pack file. That is, you can load resource packs with a file offset and use the additional file bytes for meta-data.

@Calinou
Copy link
Member

Calinou commented Jun 1, 2021

@chottokite By the way, I think we should use the term "ResourcePack" instead of "DLC" as run-time-loaded packs may not necessarily be downloaded from an external source. It's longer, but I think the added consistency is worth it 🙂

@LikeLakers2
Copy link
Author

LikeLakers2 commented Jun 2, 2021

Check if a specific path is already loaded as a resource pack.

Besides not loading a resource pack twice (this implementation doesn't) what is the need for this?

@chottokite There isn't a specific need so much as I think it would be a good idea to have. Even if you expose some sort of get_path() function for getting a resource pack's path, I feel "check if any loaded resource pack's path matches this string" would be a common enough function. But even if it's not commonly used, it'd likely be very few lines of code compared to everything else, making it very easy to maintain.

Set a loaded pack as "disabled" (without unloading it), preventing resources from being loaded from the resource pack, unless the pack is specifically targeted.

You can only load resources from packs you explicitly desire to load from

Maybe I'm misunderstanding, but this sounds like it conflicts with the original goal of this proposal. Specifically, I want to allow all enabled resource packs to be treated as a single filesystem (as we already do when loading resource packs) when using load(), but then also allow a single resource pack (enabled or not) to be targeted in the event that a mod/DLC wants to only access its own files.

Set a loaded pack as "untrusted", preventing scripts from being loaded from that pack (even if the pack is specifically targeted).

I too wish we could have limited ClassDBs for user generated DLC packs.

I'm not sure I understand. What does the ClassDB have to do with this? I thought this only affected ResourceLoaders.

Obtain various pieces of information on a single loaded pack and its files (such as a loaded pack's path, file list, and the existence of certain files).

Obtain a file from the root of an unloaded pack, without needing to load the pack first, that would provide various bits of information to the game... such as pack name, pack author, pack version, dependencies, and so on.

Since DLC can have repeated file paths that don't conflict with the base game resources, this can be provided by a known file in the DLC root. But if you must, as in you want to avoid loading a resource pack, you can prepend or append meta-data to a pack file. That is, you can load resource packs with a file offset and use the additional file bytes for meta-data.

I don't really see the need for that, at least in regard to the first point. The first point regards things already inherent to a PCK file, like its file path, its file list, and so on.

However, I could see such an idea working for the second point. The second point would be better described as "metadata", so using some extra bytes in the PCK file would work for that purpose. But then I wonder -- if we're going to allow loading a resource pack using an offset, why not just implement a "Metadata" tab to the Export dialog, and have that attached to the PCK in some way? But at that point, I start to think that my "metadata" idea would be better off as its own proposal, separate from this modding-focused proposal.


That all said, the rest of your implementation looks nice, and honestly sort of promising. Thank you for sharing! :)

@Calinou
Copy link
Member

Calinou commented May 5, 2022

We discussed this proposal in a meeting. We agreed that adding methods to unload a resource pack (and check if a resource pack is loaded) is welcome.

However, scoped per-file loading and sandbox support is best left for later and should be discussed in a separate proposal (see also #4495).

@rsubtil
Copy link

rsubtil commented May 21, 2022

I'm interested in implementing this; it's a pretty important feature for a project I'm working on which has a core focus on being able to load user-generated content.

I will, however, only focus on implementing the functionality to unload packs (and checking if a certain pack is loaded), mainly because I'm not familiar with modding concepts and paradigms, so it's best for someone else more experienced to handle that aspect.

@Calinou
Copy link
Member

Calinou commented May 21, 2022

I'm interested in implementing this; it's a pretty important feature for a project I'm working on which has a core focus on being able to load user-generated content.

I will, however, only focus on implementing the functionality to unload packs (and checking if a certain pack is loaded), mainly because I'm not familiar with modding concepts and paradigms, so it's best for someone else more experienced to handle that aspect.

Sounds good to me 🙂

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: Ready for Implementation
Development

No branches or pull requests

5 participants