Custom resource formats: loading and saving
Complete C# example: handler registration, UTF-8, errors and resource caching.
ResourceFormatLoader turns a custom-format file into a Resource; ResourceFormatSaver performs the reverse operation. Both support C# overrides, so this does not require rebuilding the engine.
Use them when a format must participate in ResourceLoader/ResourceSaver. To read ordinary JSON or user settings, FileAccess and a parser may be sufficient. To convert a source asset during editor import, use EditorImportPlugin, rather than only a runtime loader.
Complete example: a custom note format
Use a C# project with Reckfell SDK 1.0.0. Create the four files with the names below. Attach NoteExample.cs to an Entity in the level you run.
The .rfnote format contains the ASCII header RFNOTE1 followed by LF, then UTF-8 text. The total size, including the header, is limited to 65536 bytes. This is a sample format, not a replacement for .rdata serialization of arbitrary resources.
NoteResource.cs
using Reckfell;
public partial class NoteResource : Resource
{
public string Text { get; set; } = "";
}NoteLoader.cs
using System;
using System.Text;
using Reckfell;
public partial class NoteLoader : ResourceFormatLoader
{
public const int MaxBytes = 65536;
public const string Header = "RFNOTE1\n";
public override string[] _GetRecognizedExtensions() => new[] { "rfnote" };
public override bool _HandlesType(StringName type) => type == "Resource";
public override string _GetResourceType(string path) =>
path.EndsWith(".rfnote", StringComparison.OrdinalIgnoreCase) ? "Resource" : "";
public override Variant _Load(string path, string originalPath,
bool useSubThreads, int cacheMode)
{
using var file = FileAccess.Open(path, FileAccess.ModeFlags.Read);
if (file is null)
return (int)FileAccess.GetOpenError();
ulong length = file.GetLength();
if (length < (ulong)Header.Length || length > MaxBytes)
return (int)Error.FileCorrupt;
byte[] bytes = file.GetBuffer((long)length);
if ((ulong)bytes.Length != length)
return (int)Error.FileCantRead;
string text;
try { text = new UTF8Encoding(false, true).GetString(bytes); }
catch (DecoderFallbackException) { return (int)Error.FileCorrupt; }
if (!text.StartsWith(Header, StringComparison.Ordinal))
return (int)Error.FileCorrupt;
return new NoteResource { Text = text[Header.Length..] };
}
}NoteSaver.cs
using System.Text;
using Reckfell;
public partial class NoteSaver : ResourceFormatSaver
{
public override bool _Recognize(Resource resource) => resource is NoteResource;
public override string[] _GetRecognizedExtensions(Resource resource) =>
resource is NoteResource ? new[] { "rfnote" } : System.Array.Empty<string>();
public override Error _Save(Resource resource, string path, uint flags)
{
if (resource is not NoteResource note)
return Error.InvalidParameter;
if (flags != 0)
return Error.Unavailable;
if (note.Text.Length > NoteLoader.MaxBytes)
return Error.InvalidParameter;
byte[] bytes;
try { bytes = new UTF8Encoding(false, true).GetBytes(NoteLoader.Header + note.Text); }
catch (EncoderFallbackException) { return Error.InvalidData; }
if (bytes.Length > NoteLoader.MaxBytes)
return Error.InvalidParameter;
using var file = FileAccess.Open(path, FileAccess.ModeFlags.Write);
if (file is null)
return FileAccess.GetOpenError();
file.StoreBuffer(bytes);
file.Flush();
return file.GetError();
}
}NoteExample.cs
using Reckfell;
public partial class NoteExample : Entity
{
private NoteLoader? _loader;
private NoteSaver? _saver;
public override void _Ready()
{
_loader = new NoteLoader();
_saver = new NoteSaver();
ResourceLoader.AddResourceFormatLoader(_loader);
ResourceSaver.AddResourceFormatSaver(_saver);
const string path = "user://example.rfnote";
using var original = new NoteResource { Text = "Hello / Привет 😀" };
Error error = ResourceSaver.Save(original, path);
if (error != Error.Ok)
{
ReckfellApi.PrintErr($"Save failed: {error}");
return;
}
using var loaded = ResourceLoader.Load<NoteResource>(
path, "Resource", ResourceLoader.CacheMode.Ignore);
if (loaded is null)
ReckfellApi.PrintErr("Load failed; check the engine log.");
else
ReckfellApi.Print(loaded.Text);
}
public override void _ExitTree()
{
if (_saver is not null)
{
ResourceSaver.RemoveResourceFormatSaver(_saver);
_saver.Dispose();
_saver = null;
}
if (_loader is not null)
{
ResourceLoader.RemoveResourceFormatLoader(_loader);
_loader.Dispose();
_loader = null;
}
}
}Expected result
Running creates user://example.rfnote and prints Hello / Привет 😀. Running again overwrites the file. user:// is the application's data directory, not the project source directory.
The loader returns either a NoteResource object or an integer Error code inside a Variant. Do not return Error.Ok instead of the object on success: an integer is interpreted as an error code and creates no resource. The saver returns Error directly.
_GetResourceType reports the built-in base type Resource. It does not register NoteResource as a global editor class. The returned object is still a NoteResource, so its Text property remains accessible. Our saver stores Text; the property is not exported to the Inspector and does not gain automatic serialization in other formats.
Register before the first file access; unregister before disposing the handlers. For application-wide use, give one persistent service ownership of the loader and saver. This example owns them only while NoteExample is in the tree: once that Entity leaves, new loads require another registration. Already-loaded resources do not disappear.
Caching and resource mutation
An ordinary repeated load can return the same Resource. Changing Text then affects every holder of that object; resources are not required to be immutable or stateless. Writing a file again does not by itself update the in-memory resource.
The example uses CacheMode.Ignore to read an independent top-level resource from disk. This neither updates old references nor deeply bypasses dependency caches. A format with dependencies must implement their loading and honor the intended cache behavior separately. This format has no dependencies; the callback does not use cacheMode internally, while ResourceLoader handles top-level resource selection.
Errors and sample limits
| Symptom | Action |
|---|---|
ResourceLoader does not recognize .rfnote | Register NoteLoader before Load; check that its owning Entity is still in the tree |
| ResourceSaver returns FileUnrecognized | Check the extension, NoteSaver registration and actual resource type |
| Load returned null | Inspect the log; the callback can return FileCorrupt, FileCantRead or an open error |
| The note text is empty | Empty text after the header is valid; an empty file is not |
| A write is followed by an old value | Check the cache and existing references; use Ignore for an independent read |
| Save was rejected | Check UTF-8 size including the header, valid UTF-16 and unsupported flags |
The example does not perform atomic replacement: opening in Write mode can truncate the existing file before the write finishes. Important saves need a temporary file and a separate replacement/backup strategy. It also does not implement UIDs, dependencies, editor import, parallel loading or custom save flags. Check numerical errors; engine logs do not replace result checks.
Editor use needs a separate plugin lifecycle that registers handlers on enable and unregisters them on disable. These classes alone do not prove that a third-party format will be included in an export: check inclusion filters and an actual game build. This example creates its file in user:// at runtime.
When implementing a native C++ module
The contract differs from C#. In current Source, loading returns Ref<Resource> and takes const String &p_path, const String &p_original_path, Error *r_error, bool p_use_sub_threads, float *r_progress, CacheMode p_cache_mode. Saving returns Error and takes const Ref<Resource> &p_resource, const String &p_path, uint32_t p_flags.
Do not copy the C# Variant/Error return contract into a C++ signature or use the old three-argument load. Set the error through a non-null r_error, return an empty reference on failure and do not return a successful Resource after parsing fails. A native module needs its own build and registration; the managed example above does not.
Reference: ResourceFormatLoader, ResourceFormatSaver, ResourceLoader, ResourceSaver.