From 21ec84a1fa98781b55aec46d863b9e0814a263bd Mon Sep 17 00:00:00 2001 From: Pyral Date: Tue, 3 Dec 2024 11:39:32 -0500 Subject: [PATCH] Initial upload --- create_vfs.gd | 180 +++++++++++++++++++++++++++++++++++++++++++++++++ vfs.gd | 136 +++++++++++++++++++++++++++++++++++++ vfs_loaders.gd | 50 ++++++++++++++ 3 files changed, 366 insertions(+) create mode 100644 create_vfs.gd create mode 100644 vfs.gd create mode 100644 vfs_loaders.gd diff --git a/create_vfs.gd b/create_vfs.gd new file mode 100644 index 0000000..5dd8b91 --- /dev/null +++ b/create_vfs.gd @@ -0,0 +1,180 @@ +# Not unlike factory pattern. + +## Recursively creates virtual file systems. +static func create_meta_accessor(file_accessors:Array[VFileAccess])->VFileAccess: + var vfiler := VFileAccess.new() + + vfiler._get_stuff = func()->Variant: return file_accessors + + vfiler._file_exists = func(path:String)->bool: + return file_accessors.any(func(vfs:VFileAccess)->bool: + return vfs.file_exists(path)) + + vfiler._get_files_at = func(path:String)->Array[String]: + var accum:Array[String] = [] + accum.assign( + file_accessors.reduce( + (func _merge_files(total:Array[String], current:VFileAccess)->Array[String]: + var res:Array[String] = total.duplicate() + for file:String in current.get_files_at(path): + if not file in total: + res.append(file) + return res) ,[])) + return accum + + vfiler._get_buffer = func(path:String)->PackedByteArray: + for vfs:VFileAccess in file_accessors: + if vfs.file_exists(path): + return vfs.get_buffer(path) + push_error("File '%s' not found in any checked accessors." % path) + return PackedByteArray() + + vfiler._write_file = func _zip_write(_path:String, _buffer:PackedByteArray)->Error: + return ERR_FILE_CANT_WRITE + + vfiler._close = func()->void: + file_accessors.map(func(vfs:VFileAccess)->void: vfs.close()) + + return vfiler + + +## A basic file accessor. +static func create_file_access(root:String = "./")->VFileAccess: + var vfiler := VFileAccess.new(root) + + vfiler._get_buffer = FileAccess.get_file_as_bytes # thanks for being static + + vfiler._file_exists = FileAccess.file_exists # thanks for being static + + vfiler._write_file = func _file_access_write(path:String, buffer:PackedByteArray)->void: + var abs_path := vfiler.get_absolute_path(path) + if not DirAccess.dir_exists_absolute(abs_path): + DirAccess.make_dir_recursive_absolute(abs_path.get_base_dir()) + var f := FileAccess.open(abs_path, FileAccess.WRITE_READ) + f.store_buffer(buffer) + f.close() + + vfiler._get_files_at = func(path:String)->Array[String]: + var paths:Array[String] = [] + #var abs_path := vfiler.get_absolute_path(path) + if not DirAccess.dir_exists_absolute(path): + print("Path '%s' not found. Adding..." % path) + DirAccess.make_dir_recursive_absolute(path) + paths.assign(DirAccess.get_files_at(path)) + return paths + + return vfiler + + +static func create_writeonly_zip_access( + zip_path:String, + append := ZIPPacker.ZipAppend.APPEND_CREATE + )->VFileAccess: + var vfiler := VFileAccess.new() + if not is_instance_valid(vfiler): return null + + var writer := ZIPPacker.new() + # Open once to verify it exists and works. + var _open_err := writer.open(zip_path, append) + #if open_err != OK: + #push_error("Could not open zip writer '%s'" % error_string(open_err)) + #return null + #writer.close() + + vfiler._get_stuff = func()->Variant: return writer + + vfiler._write_file = func _zip_write(path:String, buffer:PackedByteArray)->Error: + var abs_path := vfiler.get_absolute_path(path) + writer.start_file(path) + var write_err := writer.write_file(buffer) + writer.close_file() + return write_err + + vfiler._get_buffer = func(_path:String)->PackedByteArray: + push_error("Attempted to read from write-only zip access! Returning 0 bytes.") + return PackedByteArray() + + vfiler._get_files_at = func(_path:String)->Array[String]: + push_error("Attempted to get files from write-only zip access!") + return [] + + vfiler._close = func()->void: writer.close() + + return vfiler + + + +## A single zip file accessor, but with write functions disabled. +## TODO option to keep zips open, for OS to flag files as in-use. +static func create_readonly_zip_access(zip_path:String)->VFileAccess: + var vfiler := create_bulk_readonly_zip_access([zip_path]) + vfiler._get_stuff = func()->Variant: return vfiler.get_stuff()[0] + return vfiler + +## A multi-zip readonly accessor. Allows for multiple zips to load with overrides. +## TODO option to keep zips open, for OS to flag files as in-use. +static func create_bulk_readonly_zip_access( + zip_paths:Array[String], keep_open:bool = false, + if_missing_zip := func _ignore(_zip_path:String)->bool: return false + )->VFileAccess: + var vfiler := VFileAccess.new("") + + var readers:Array[ZIPReader] = [] + readers.assign(zip_paths.map( + func _open_zip_reader(zip_path:String)->ZIPReader: + var zip := ZIPReader.new() + if not FileAccess.file_exists(zip_path): # Use VFileAccess recursively so we are more powerful? + if_missing_zip.call(zip_path) + var open_err := zip.open(zip_path) + if open_err: + push_error("Could not open zip '%s': %s" % [zip_path, error_string(open_err)]) + return null + return zip)) + + readers.assign(readers.filter(is_instance_valid)) + + vfiler._get_stuff = func()->Variant: return readers + + # file exists set first so we can bulk-check later + vfiler._file_exists = func(abs_path:String)->bool: + return readers.any( + func(z:ZIPReader)->bool: return z.file_exists(abs_path)) + + vfiler._get_buffer = func(abs_path:String)->PackedByteArray: + var output := PackedByteArray() + if not vfiler.file_exists(abs_path): + push_error("Zips don't have file id '%s'" % abs_path) + return output + for index:int in readers.size(): + var z := readers[index] + if not z.file_exists(abs_path): continue + output = z.read_file(abs_path) + break + return output + + vfiler._write_file = func _zip_write(_path:String, _buffer:PackedByteArray)->Error: + return ERR_FILE_CANT_WRITE + vfiler._close = func()->void: + readers.map(func(z:ZIPReader)->void: z.close()) + + vfiler._get_files_at = func(path:String)->Array[String]: + if not path.ends_with("/"): # TODO ensure no // as well because that can be problem. + path += "/" # sorry for the mutation + var accum:Array[String] = [] + accum.assign( readers.reduce( + (func _merge_files(total:Array[String], current:ZIPReader)->Array[String]: + var res:Array[String] = total.duplicate() + var filtered:Array = [] + filtered.assign(current.get_files()) + filtered.filter( + func _filter_dir(curpath:String)->bool: + return curpath.begins_with(path)) + for file:String in filtered: + var stripped_file:String = file.replace(path, "") + # remove files with / after the file stripping to prevent directories. + if not file in total and not stripped_file.contains("/"): + res.append(file) + return res) ,[])) + return accum + + return vfiler diff --git a/vfs.gd b/vfs.gd new file mode 100644 index 0000000..4639b97 --- /dev/null +++ b/vfs.gd @@ -0,0 +1,136 @@ +## Virtual File Access +## +## File access across different methods sucks. +## Here's something to abstract that away and suck a little less. + +class_name VFileAccess extends RefCounted + +## Built-in VFileAccess factories (Learn to make your own with these) +const CREATE:GDScript = preload("create_vfs.gd") +## File importer functions. +const IMPORTS:GDScript = preload("vfs_loaders.gd") + +## Path prefix. How this is handled depends on the file loader itself. +var root:String = "" + +## Callable(bytes:PackedByteArray, [...])->Variant +var supported_files:Dictionary[String,Callable] = IMPORTS.DEFAULT_SUPPORTED_FILES + +## Get any stuff we might be using in our closures. +var _get_stuff:Callable = func()->Variant: return null +## How can we write to a file? +var _write_file:Callable = func(_abs_path:String, data:Variant)->Error: return ERR_CANT_OPEN +## How do we get bytes from this? +var _get_buffer:Callable = func(_abs_path:String)->PackedByteArray: return PackedByteArray() +## What determines if a file exists? +var _file_exists:Callable = func(_abs_path:String)->bool: return false +## How do we get files at a subdirectory? +var _get_files_at:Callable = func(_abs_path:String)->Array[String]: return [] +## Shutdown code here. +var _close:Callable = func()->void: pass + +## Default +func _init(root_dir:String = "./")->void: + self.root = root_dir + +## I never won awards for naming things correctly. +func get_stuff()->Variant: + return _get_stuff.call() + +## Add automatic file loaders to this virtual file system. +func add_supported_files(file_loaders:Dictionary[String,Callable])->Dictionary[String,bool]: + var results:Dictionary[String,bool] = {} + for key:String in file_loaders.keys(): + var loader := file_loaders[key] + results[key] = false + if not IMPORTS.validate_loader(loader): continue + supported_files[key] = loader + results[key] = true + return results + + +## Get all files supported for autoloading. +func get_supported_files()->Array[String]: + return supported_files.keys() + + +## Load a supported file. If [param path]'s extension matches a supported file, +## it will use that loader and return whatever it's supposed to. +## [param ext_override] allows for selecting a specific loader by key. +## Returns null if no matching loader is found. +func load_supported(path:String, ext_override:String = "")->Variant: + var abs_path := get_absolute_path(path) + var ext := abs_path.get_extension() \ + if ext_override.is_empty() else ext_override + + if not ext in supported_files.keys(): + push_error("File extension '%s' not supported by this loader!" % ext) + return null + + var buffer := get_buffer(path) + var result:Variant = supported_files[ext].call(buffer) + if not is_instance_valid(result): + push_warning("%s loader tried loading '%s' but received null. Does the file exist?" + % [ext, abs_path]) + + return result + + +## [param parse] Callable(buffer:PackedByteArray)->Variant +func load_and_parse(path:String, parse:Callable)->Variant: + var abs_path:String = get_absolute_path(path) + var buffer:PackedByteArray = get_buffer(abs_path) + var result:Variant = parse.call(buffer) + return result + + +## Get bytes from this file loader. +func get_buffer(path:String)->PackedByteArray: + return get_multiple_buffers([path])[path] + + +## Get multiple file buffers. +## Returns Dictionary[String,PackedByteArray] +## which uses the path given as the key for the respective data buffer. +func get_multiple_buffers(paths:Array[String])->Dictionary[String,PackedByteArray]: + var output:Dictionary[String,PackedByteArray] = {} + for path:String in paths: + var abs_path := get_absolute_path(path) + if _file_exists.call(abs_path): + output[path] = _get_buffer.call(abs_path) + if output[path].size() == 0: + push_warning("File '%s' found but is 0 bytes long. Intentional?" % abs_path) + else: + push_error("File '%s' does not exist!" % abs_path) + output[path] = PackedByteArray() + return output + + +## Get files in a directory +func get_files_at(path:String)->Array[String]: + var abs_path := get_absolute_path(path) + return _get_files_at.call(abs_path) + + +## Write buffer to file, if possible. +func write_file(path:String, buffer:PackedByteArray)->Error: + return _write_file.call(path, buffer) + + +## Deallocate stuff. +func close()->void: + _close.call() + + +## Check if this file exists within this loader. +## If [param ignore_root] is true, file root will not be prefixed to [param path] +func file_exists(path:String, ignore_root := false)->bool: + return _file_exists.call(path) \ + if ignore_root else _file_exists.call(get_absolute_path(path)) + + +## Gets the internal absolute path based on this filesystem's root. +func get_absolute_path(relative_path:String)->String: + if root.is_empty() or root.ends_with("/"): + return root + relative_path + return "%s/%s" % [root, relative_path] diff --git a/vfs_loaders.gd b/vfs_loaders.gd new file mode 100644 index 0000000..7b161d4 --- /dev/null +++ b/vfs_loaders.gd @@ -0,0 +1,50 @@ + + +static func validate_loader(loader:Callable)->bool: + if loader.get_unbound_arguments_count() != 1: + push_error("Cannot add loader for %s as only one String arg can be taken.") + return false + elif loader.get_bound_arguments()[0] is not String \ + or loader.get_bound_arguments()[0] is not StringName: + push_error("Cannot add loader for %s as only one String arg can be taken.") + return false + return true + + +static func load_bin(buffer:PackedByteArray)->PackedByteArray: return buffer + +static func load_png(buffer:PackedByteArray)->Image: + var img:Image + img.load_png_from_buffer(buffer) + return img + +static func load_mp3(buffer:PackedByteArray)->AudioStreamMP3: + var sfx := AudioStreamMP3.new() + sfx.data = buffer + return sfx + +static func load_ogg(buffer:PackedByteArray)->AudioStreamOggVorbis: + var sfx := AudioStreamOggVorbis.new() + sfx.data = buffer + return sfx + +static func load_txt(buffer:PackedByteArray)->String: + var txt:String = buffer.get_string_from_utf8() + return txt + +# can't use CONST since Callables are technically instanced dynamically + +static var DEFAULT_SUPPORTED_FILES:Dictionary[String,Callable] = { + "bin": load_bin, + "png": load_png, + "mp3": load_mp3, + "ogg": load_ogg, + "txt": load_txt, +} + + + +static var AUDIO_FILES:Dictionary[String,Callable] = { + "mp3": load_mp3, + "ogg": load_ogg, +}