File IO Utils

48 min read

A validated, typed-error file I/O facade that handles save data, user configs, screenshots, and large binary transfers the same way across every platform your game ships on.

Summary

The Scylla file I/O utilities replace ad-hoc File.ReadAllText calls with a validated, typed-error, settings-driven facade. ScyllaFileUtil is the entry point: a static class with sync and async overloads for text, binary, streamed, serialized, compressed, encrypted, and image I/O, every method validated against the same path discipline and every failure surfaced as a FileException carrying a structured FileErrorCode. FilePathUtil complements it with cross-platform path operations: combination, normalization, validation, platform-specific roots (Documents, AppData, persistent data, streaming assets, temporary cache), and safe-filename generation.

Most read and write methods come in three flavours that share the same parameter shape: a throwing variant (ReadText), a non-throwing Try variant (TryReadText returning bool and an out value), and a Task-returning async variant (ReadTextAsync) that takes a CancellationToken. Every variant accepts an optional FileSettings instance for controlling encoding, buffer size, share mode, BOM detection, and directory auto-creation. Six ready-made FileSettings presets (Default, Async, TextFile, BinaryFile, LargeFile, LogFile) cover the common cases; custom instances are simple to build for the rest. Streamed operations additionally accept an IProgress<FileProgress> so long-running transfers can for example update a UI indicator.

Higher-level helpers chain the lower-level building blocks into one call per pattern. SaveObject<T> serializes through ScyllaSerialization to JSON and writes the bytes; LoadObject<T> returns a SerializationResult<T> with a structured success/failure outcome. SaveCompressed and LoadCompressed route through ScyllaCompression. SaveEncrypted and LoadEncrypted route through ScyllaCrypto with PBKDF2 (Password-Based Key Derivation Function 2) password derivation. SaveSecureObject<T> combines all three into a single serialize + compress + encrypt pipeline – the canonical shape for save games and account data. SaveImage and LoadImage handle Texture2D round-trips through PNG, JPG, WEBP, EXR, and TGA, with ImageFileSettings presets for HighQuality, FastCompression, WebOptimized, Screenshot, and HDR scenarios.

The reason all of this matters is that file I/O is the most common place for subtle bugs to ship: missing directory checks, swallowed exceptions, mismatched encodings, path traversal vulnerabilities, naive synchronous calls blocking the main thread, half-written files left over after a crash. Routing every read and write through the facade collapses each of those into a configuration knob or a single error code, makes the same operation work identically across Windows, macOS, Linux, and Android (where path conventions differ), and surfaces every failure mode as a typed exception that downstream code can branch on instead of a generic IOException.

Use the file I/O utilities for:

  • Save and load game state with one call (SaveSecureObject<T> / LoadSecureObject<T>) that serializes, compresses, and encrypts.
  • Read and write user-editable text, JSON, or config files with validated paths and proper encoding handling.
  • Stream large binary files (replays, video, addressable bundles) with progress reporting and cancellation.
  • Persist screenshots, render captures, and procedural textures as PNG, JPG, WEBP, EXR, or TGA.
  • Resolve cross-platform paths (PersistentDataPath, DocumentsPath, AppDataPath) without re-implementing Unity / Environment.SpecialFolder plumbing.
  • Maintain log files with size-based rotation, age-based pruning, and disk-space pre-checks.
  • Replace one-off File.WriteAllText calls with a uniform error model so failure recovery is the same everywhere.

Features

  • ScyllaFileUtil facade. One static class covers text, binary, streams, serialized objects, compressed, encrypted, image, and maintenance operations. Same parameter shape across every method; no per-format class to learn.
  • Three-flavour API. Most operations come in throwing (ReadText), Try (TryReadText), and async (ReadTextAsync) variants. Pick the flavour that matches the call site: throw when missing is a bug, Try when missing is normal first-run state, async for anything large enough to stall a frame.
  • FileSettings presets. Six ready-made configurations (Default, Async, TextFile, BinaryFile, LargeFile with 256 KB buffer, LogFile with append-friendly shared write) cover the common cases; custom instances are just new FileSettings { ... } for the rest.
  • Cross-platform FilePathUtil. Combine, normalize, validate, and slash-convert paths plus eleven platform-specific roots (PersistentDataPath, DocumentsPath, AppDataPath, StreamingAssetsPath, TempCachePath, …) so cross-platform code never re-implements Environment.SpecialFolder plumbing.
  • High-level save pipelines. SaveObject<T>/LoadObject<T> route through Serialization; SaveCompressed/LoadCompressed add Compression; SaveEncrypted/LoadEncrypted add Crypto with PBKDF2; SaveSecureObject<T>/LoadSecureObject<T> combine all three into the canonical save-game pipeline.
  • SerializationResult<T> on object loads. Object-loading methods return a result struct with IsSuccess and Error rather than throwing. Calling code branches cleanly on parse-vs-IO failures without parsing exception messages.
  • Streamed transfers with progress. Stream-based reads, writes, and copies accept an IProgress<FileProgress> callback that reports bytes processed, total, percent, and the operation label. Drives a UI bar without polling.
  • Image I/O across five formats. SaveImage and LoadImage handle Texture2D round-trips through PNG, JPG, WEBP, EXR, and TGA. Six ImageFileSettings presets cover HighQuality, FastCompression, WebOptimized, Screenshot, and HDR scenarios.
  • Structured error contract. FileException carries a FileErrorCode (21 values covering path validity, existence, access, I/O, image, and pipeline failures) plus the FilePath. Recovery handlers branch on the enum rather than on exception messages.
  • Fluent extension methods. byte[], string, Texture2D, IEnumerable<string>, and any T : class gain .SaveToFile(path) and .SaveToFileAsync(path) extensions. Strings additionally gain path operations (path.NormalizePath(), path.IsValidPath(), …).
  • ConfigFileUtil for the config tier paths. Builds the canonical {BasePath}/Config/{name}.json paths for the User Documents and Application tiers consumed by the Configuration system. Non-throwing load returns null on failure for the four-tier fallback chain.
  • Safe and unique filename helpers. GetSafeFileName strips illegal characters from user-provided names; GetUniqueFileName appends an integer suffix when a collision would occur. Prevents one entire class of “user typed a weird character” bug.
  • Cooperative cancellation. Every async overload accepts a CancellationToken. Long-running saves and downloads can be cancelled cleanly without leaking half-written files (the helpers stage writes through a temp file and rename on success).

Components and error model

The file I/O surface is split across one facade class, one path utility, two extension classes, one config-file utility, three settings/progress types, and the typed exception family. Knowing what each one is for keeps later sections short and predictable.

A few terms appear repeatedly and are worth defining up front. Throwing vs Try. Most methods come in two synchronous flavours: a throwing variant (ReadText) that raises FileException on failure, and a Try variant (TryReadText) that returns bool with an out value and never throws on expected errors (missing file, parse failure). Use throwing variants when a missing file is a bug; use Try when missing is a normal first-run state. Async vs sync. Async variants (ReadTextAsync, WriteFromStreamAsync, etc.) return Task or Task<T> and accept a CancellationToken. They open the underlying FileStream with the async flag for non-blocking I/O and are the right pick for any read or write large enough to stall a frame. FileSettings. A small POCO (plain old C# object) that bundles encoding, buffer size, share mode, BOM detection, directory auto-creation, and async-IO toggle. Six factory presets cover the common shapes; building a custom instance is just new FileSettings { ... }. FileProgress. A readonly struct that streams pass to an IProgress<FileProgress> callback during large transfers (bytes processed, total, percent, operation label).

ComponentRoleNotes
ScyllaFileUtilHigh-level static facade for every file operationText, binary, streams, objects, compressed, encrypted, images, maintenance. Every entry point validates the path, raises FileException on failure, and accepts an optional FileSettings for fine-grained control.
FilePathUtilCross-platform path operations and platform-specific rootsCombine, Normalize, IsValidPath, slash conversion, parent/filename/extension extraction, safe-filename generation, plus eleven platform path properties (PersistentDataPath, DocumentsPath, AppDataPath, etc.).
FileExtensionsExtension methods on common types for fluent save callsbyte[], string, Texture2D, IEnumerable<string>, and T : class all gain .SaveToFile(path) / .SaveToFileAsync(path) extensions, plus format-specific shortcuts (SaveAsPNG, SaveAsJPG, SaveAsWEBP, SaveAsEXR, SaveAsTGA).
FilePathExtensionsExtension methods on string for path operationspath.NormalizePath(), path.IsValidPath(), path.GetPathFileName(), etc. Useful when chaining path operations in builder-style code; backed by FilePathUtil.
ConfigFileUtilLoad and save ConfigFile JSON in the canonical four-tier locationsConstructs the standard {BasePath}/Config/{name}.json paths for the User Documents and Application tiers used by the Configuration system. Non-throwing on load; returns null on failure.
FileSettingsI/O configuration POCO (encoding, buffer size, share, BOM, async, overwrite, mkdir)Six presets: Default, Async, TextFile, BinaryFile, LargeFile (256 KB buffer), LogFile (append-friendly shared-write). Pass null to use Default.
ImageFileSettingsImage I/O configuration (format, quality, mipmaps, filter mode, anisotropic level, EXR flags)Six presets: Default, HighQuality, FastCompression, WebOptimized, Screenshot, HDR. Format defaults to PNG; Quality ignored by lossless formats (PNG, EXR, TGA).
FileProgressreadonly struct reporting bytes processed, total, percent, file path, operation labelEmitted to IProgress<FileProgress> callbacks on stream-based methods. Percent rounds to 0..100; IsComplete flips when BytesProcessed >= TotalBytes.
FileExceptionSingle typed exception type for every failure modeCarries ErrorCode (a FileErrorCode value) and the FilePath involved. Wraps the underlying .NET exception as InnerException so the low-level cause is preserved.
FileErrorCodeTwenty-one error codes covering path validity, existence, access, I/O, image, and data pipelinesIncludes NullData, FileNotFound, DirectoryNotFound, AccessDenied, FileExists, InvalidSettings, OperationCancelled, InsufficientSpace, Timeout, InvalidPath, FileLocked, IOError, PathTooLong, plus image and pipeline codes.

Every throwing method raises FileException on failure. Catch blocks can switch on ex.ErrorCode for category-specific recovery (retry on FileLocked after a delay, prompt the user on AccessDenied, fall back to a backup on FileNotFound) without parsing exception messages. Try variants return bool and never throw on expected failures. Object-loading methods return a SerializationResult<T> rather than throwing on serialization-level failures, so you can inspect result.IsSuccess and result.Error cleanly.

Recipes

Each recipe below is a self-contained answer to a single I/O problem, covering every public method on the facade and every supporting type. You can jump straight to the one that matches what you are building. The first recipe sets up the path conventions that every later recipe reuses, so start there if you are new to the API.

Build cross-platform paths for your save and config files

Problem. You want to write a save file or config to the right place on every platform your game targets, without getting bitten by separator mismatches, illegal characters in player-supplied names, or a path that collides with a file already on disk. Getting the path right is step one before any disk access happens.

Solution. FilePathUtil.Combine joins fragments with the correct separator for the platform, Normalize collapses redundant separators, and the validity helpers reject malformed input before any disk access happens. GetSafeFileName strips illegal characters from user-provided names; GetUniqueFileName appends an integer suffix when a collision would occur.

using Scylla.Core.Util.File;

/* Combine fragments. The variadic overload accepts any number of parts. */
string path = FilePathUtil.Combine(
    FilePathUtil.PersistentDataPath,
    "saves",
    "slot-3.dat"
);

/* Combine a directory, file name, and extension separately. The helper trims
 * any leading dot on the extension and any trailing separator on the directory. */
string logPath = FilePathUtil.CombineWithExtension(
    FilePathUtil.PersistentDataPath,
    "session",
    ".log"
);

/* Normalize. Resolves "a/./b/../c" to "a/c", collapses double slashes, etc. */
string canonical = FilePathUtil.Normalize(path);

/* Slash conversion. Useful when the path is logged, hashed, or compared. */
string posix   = FilePathUtil.ToForwardSlashes(canonical);
string windows = FilePathUtil.ToBackSlashes(canonical);

/* Validity checks. IsValidPath returns false for traversal sequences, illegal
 * characters, and over-length paths; ContainsInvalidPathChars / ContainsInvalidFileNameChars
 * return the more specific verdicts. */
if (!FilePathUtil.IsValidPath(path))
{
    Log.Warning($"Refusing to write to invalid path: {path}", LogCategory.Core);
    return;
}

/* Parent/filename/extension/root extraction. Backed by Path.* internally but
 * tolerant of forward and back slashes on either platform. */
string parent   = FilePathUtil.GetParent(path);
string name     = FilePathUtil.GetFileName(path);
string bareName = FilePathUtil.GetFileNameWithoutExtension(path);
string ext      = FilePathUtil.GetExtension(path);
string root     = FilePathUtil.GetRoot(path);

/* Sanitize a name supplied by the player. Any illegal character is replaced
 * with the supplied replacement character (default '_'). Prevents the entire
 * class of "player typed CON or NUL as their save name" crash on Windows. */
string playerInput = "save: latest/!.dat";
string safeName    = FilePathUtil.GetSafeFileName(playerInput);

/* Resolve to a name that does not yet exist on disk. Appends " (1)", " (2)", ...
 * before the extension as needed. Use for screenshots so capture 001 never
 * silently overwrites capture 001 from a previous session. */
string uniqueFullPath = FilePathUtil.GetUniqueFileName(
    FilePathUtil.PersistentDataPath,
    "screenshot.png"
);

/* Make a relative path between two absolute paths. Returns the second path
 * expressed relative to the first; useful for portable manifest entries. */
string rel = FilePathUtil.MakeRelative(
    FilePathUtil.PersistentDataPath,
    Path.Combine(FilePathUtil.PersistentDataPath, "saves", "slot-3.dat")
); /* "saves/slot-3.dat" */

/* Inverse: resolve a relative path against a base. */
string abs = FilePathUtil.MakeAbsolute(FilePathUtil.PersistentDataPath, rel);

/* Swap an extension. Useful for derived sidecar files (.dat -> .meta). */
string metaPath = FilePathUtil.ChangeExtension(path, ".meta");

/* All path helpers are also available as string extensions via FilePathExtensions. */
string fluent = path.NormalizePath().ToForwardSlashes();

Pick the right directory for each type of file

Problem. Anyone who has shipped a cross-platform game has hit the moment when “where do save files go” turns out to have eight different answers depending on the OS, the publisher, and whether the game is sandboxed. You need the right directory for each type of content – game-internal saves, user-editable configs, ephemeral scratch – without re-implementing the underlying Unity or .NET plumbing yourself.

Solution. Eleven properties on FilePathUtil return platform-correct directories. Pick the one that matches the file’s purpose: PersistentDataPath for game-internal save data, DocumentsPath for user-editable files the player should be able to find, AppDataPath for roaming application state, TemporaryCachePath for ephemeral scratch files.

/* Game-internal save data. Survives uninstall/reinstall on Android/iOS only via
 * platform backup mechanisms; cleared on macOS/Linux on uninstall. Use this for
 * saves and config the user is not expected to edit by hand. */
string saveRoot = FilePathUtil.PersistentDataPath;

/* User-facing files: configs the user can open with a text editor, exported
 * screenshots, modding output. The standard Scylla config-file convention puts
 * the high-priority config tier under Documents/{ProductName}/Config/. */
string documents = FilePathUtil.DocumentsPath;

/* Application install folder. Read-only on most platforms; useful for shipping
 * default config files or templates alongside the game executable. */
string appRoot = FilePathUtil.ApplicationPath;

/* Unity-specific roots. */
string assets   = FilePathUtil.DataPath;             /* Application.dataPath */
string streamed = FilePathUtil.StreamingAssetsPath;  /* Application.streamingAssetsPath */
string tmp      = FilePathUtil.TemporaryCachePath;   /* Application.temporaryCachePath */
string console  = FilePathUtil.ConsoleLogPath;       /* Application.consoleLogPath */

/* OS user roots. */
string desktop = FilePathUtil.DesktopPath;          /* null on non-desktop platforms */
string home    = FilePathUtil.UserProfilePath;      /* user home directory */
string roaming = FilePathUtil.AppDataPath;          /* Windows AppData/Roaming; Library on Mac */
string local   = FilePathUtil.LocalAppDataPath;     /* Windows AppData/Local */

/* Directory-separator characters and invalid-character arrays are exposed as
 * static readonly fields for callers that need to construct paths manually. */
char   sep     = FilePathUtil.DirectorySeparator;
char   altSep  = FilePathUtil.AltDirectorySeparator;
char[] badPath = FilePathUtil.InvalidPathChars;
char[] badName = FilePathUtil.InvalidFileNameChars;

Read and write text files

Problem. Most file I/O in a typical project is text: JSON saves, config files, hand-edited content data, log lines. You want the right encoding, lazy enumeration for large logs, and easy appending for telemetry without having to manage stream lifetimes yourself.

Solution. Text I/O comes in seven shapes: the three sync read variants (ReadText, TryReadText, ReadLines), the corresponding async variants, the line-streaming EnumerateLines, plus the symmetric write/append family (WriteText, WriteLines, AppendLine, AppendLines, AppendText). Every method accepts an optional FileSettings overload for encoding, buffer size, and share-mode control. UTF-8 is the default; BOM detection is on by default, so files saved by external editors with a BOM are read correctly.

/* Throwing read with default settings (UTF-8, 64 KB buffer, BOM detection on). */
string contents = ScyllaFileUtil.ReadText(path);

/* Settings-driven read: pick a different encoding or buffer size. */
string utf16 = ScyllaFileUtil.ReadText(path, new FileSettings { Encoding = Encoding.Unicode });

/* Encoding-only convenience overload, no FileSettings instance required. */
string ascii = ScyllaFileUtil.ReadText(path, Encoding.ASCII);

/* Async read. Yields control on the underlying ReadAsync; CancellationToken
 * surfaces as FileException with FileErrorCode.OperationCancelled. */
string asyncContents = await ScyllaFileUtil.ReadTextAsync(path, cancellationToken);

/* Try variant: returns false on any expected failure (missing file, locked,
 * invalid path). No exception. The out parameter is null on failure.
 * The right call for a first-run config that may not exist yet. */
if (ScyllaFileUtil.TryReadText(path, out string maybe))
{
    Log.Info(maybe, LogCategory.Core);
}
else
{
    Log.Info($"No config at {path}; using defaults.", LogCategory.Core);
}

/* Read all lines into an array (eager, in-memory). */
string[] lines = ScyllaFileUtil.ReadLines(path);

/* Enumerate lines lazily without loading the file into memory. The IEnumerable
 * is iterator-based and yields each line as it is read; ideal for large log files
 * where loading the whole thing would dominate working set. */
foreach (string line in ScyllaFileUtil.EnumerateLines(path))
{
    if (line.StartsWith("ERROR"))
    {
        ReportError(line);
    }
}

/* Async line read. */
string[] linesAsync = await ScyllaFileUtil.ReadLinesAsync(path);

/* Write text. Default settings overwrite and auto-create parent directories. */
ScyllaFileUtil.WriteText(path, "fresh content");
await ScyllaFileUtil.WriteTextAsync(path, "fresh content", cancellationToken);

/* Write a list of lines, each followed by Environment.NewLine. */
ScyllaFileUtil.WriteLines(path, new[] { "alpha", "bravo", "charlie" });

/* Append a single line. Creates the file if absent. */
ScyllaFileUtil.AppendLine(logPath, $"[{DateTime.UtcNow:O}] Player spawned.");

/* Append multiple lines in a single open/close cycle. Cheaper than calling
 * AppendLine in a loop for batched log writes. */
ScyllaFileUtil.AppendLines(logPath, batchedEvents);

/* Append raw text (no trailing newline) for builders that already produce one. */
ScyllaFileUtil.AppendText(logPath, alreadyTerminatedBuffer);

Read and write binary data

Problem. Sometimes the file is not text but a binary blob: a save header, a packed asset bundle, a screenshot’s raw bytes, a custom mod format. You need the same throwing/Try/async pattern as text, without having to manage FileStream lifetimes.

Solution. Binary I/O mirrors the text family: throwing read, Try read, async, plus settings overloads. The default behaviour reads the entire file into a byte[]; for files large enough that the array would dominate the working set, switch to the stream-based methods in the next recipe.

/* Throwing read. */
byte[] bytes = ScyllaFileUtil.ReadBytes(blobPath);

/* Async with cancellation. */
byte[] asyncBytes = await ScyllaFileUtil.ReadBytesAsync(blobPath, cancellationToken);

/* Try variant: false on missing/locked/invalid, no exception. */
if (ScyllaFileUtil.TryReadBytes(blobPath, out byte[] payload))
{
    Process(payload);
}

/* Write. Auto-creates parent directories by default. */
ScyllaFileUtil.WriteBytes(blobPath, payload);
await ScyllaFileUtil.WriteBytesAsync(blobPath, payload, cancellationToken);

/* Custom settings: use the LargeFile preset (256 KB buffer) for big payloads. */
ScyllaFileUtil.WriteBytes(blobPath, payload, FileSettings.LargeFile);

Stream large files with progress reporting

Problem. You are streaming a replay file, a video clip, or an addressables bundle that is too large to load into a byte[] all at once. You also want a progress bar so the player knows something is happening during the transfer.

Solution. The stream-based methods read from a Stream source or write to a Stream destination. The settings-driven overload additionally accepts an IProgress<FileProgress> callback that fires repeatedly during the transfer with bytes processed, total bytes, and a percent-complete value. The callback is invoked on the same thread the call is currently running on.

/* Sync read into a destination stream. */
using (var memory = new MemoryStream())
{
    ScyllaFileUtil.ReadToStream(replayPath, memory);
    ProcessReplay(memory.ToArray());
}

/* Sync write from a source stream. */
using (var source = File.OpenRead(uploadPath))
{
    ScyllaFileUtil.WriteFromStream(destinationPath, source);
}

/* Progress-reporting async copy from a download stream to disk. */
var progressBar = new Progress<FileProgress>(p =>
{
    /* p.PercentComplete is 0..100 (double). p.BytesProcessed and p.TotalBytes
     * are byte counts. p.Operation is the operation label ("Reading", "Writing",
     * or "Copying"); p.FilePath is the file the report refers to. */
    UpdateUI(p.Operation, p.PercentComplete, p.FilePath);
});

await ScyllaFileUtil.WriteFromStreamAsync(
    destinationPath,
    downloadStream,
    FileSettings.LargeFile,
    progressBar,
    cancellationToken
);

Save and load a typed object as JSON

Problem. You have a typed C# config or save object that you want to persist as JSON on disk and deserialize back later. You want the I/O step and the serialization step to stay separate and you want a clean way to branch on “file was corrupt” vs “file was missing” without parsing exception messages.

Solution. SaveObject<T> and LoadObject<T> chain Serialization together with the file pipeline. The serializer converts the object to a UTF-8 JSON byte buffer and writes it; load reads the bytes back, deserializes them, and returns a SerializationResult<T> that you inspect via IsSuccess, Value, and Error. The settings-driven overloads accept a SerializationSettings instance for indentation, polymorphism, and reference handling control.

[System.Serializable]
public sealed class GameConfig
{
    public string PlayerName = "Hero";
    public int    Level      = 1;
    public float  Volume     = 0.8f;
}

/* Save with default serialization settings (pretty-printed JSON, UTF-8). */
var config = new GameConfig { PlayerName = "Alice", Level = 12, Volume = 0.5f };
ScyllaFileUtil.SaveObject(configPath, config);

/* Load. The return type is SerializationResult<T>, not T directly, so you
 * can branch on success without try/catch. */
SerializationResult<GameConfig> result = ScyllaFileUtil.LoadObject<GameConfig>(configPath);

if (result.IsSuccess)
{
    GameConfig loaded = result.Value;
    Log.Info($"Loaded {loaded.PlayerName} at level {loaded.Level}.", LogCategory.Core);
}
else
{
    Log.Warning($"Config load failed: {result.Error}", LogCategory.Core);
    /* Fall back to defaults; the file is corrupt or absent. */
}

/* Async with cancellation. */
await ScyllaFileUtil.SaveObjectAsync(configPath, config, cancellationToken);
SerializationResult<GameConfig> asyncResult =
    await ScyllaFileUtil.LoadObjectAsync<GameConfig>(configPath, cancellationToken);

/* Custom serialization settings: compact JSON for archived saves, or polymorphic
 * type information when loading a base-typed collection of subclasses. */
var compact = new SerializationSettings { Indent = false };
ScyllaFileUtil.SaveObject(archivePath, config, compact);

Protect and compress save game data

Problem. You are shipping a save system and you want the bytes on disk to be both small (compressed, so cloud-sync upload time stays low) and not casually editable (encrypted, so the local save file resists tampering from players who would rather not grind the late game).

Solution. Three higher-level methods chain compression, encryption, or both into the file pipeline. SaveCompressed / LoadCompressed route through Compression with Compression.Default settings (GZip on most platforms); custom settings select a different codec or level. SaveEncrypted / LoadEncrypted route through Crypto with PBKDF2 password derivation; the password is stretched to a key before AES-GCM or AES-CBC-HMAC is applied, so a weak password is the only practical attack surface. SaveSecureObject<T> / LoadSecureObject<T> combine serialization, compression, and encryption into a single call: the canonical shape for save games and account data.

/* Compress and save raw bytes. Default codec is GZip. */
ScyllaFileUtil.SaveCompressed(replayPath, replayBuffer);
byte[] back = ScyllaFileUtil.LoadCompressed(replayPath);

/* Async with cancellation. */
await ScyllaFileUtil.SaveCompressedAsync(replayPath, replayBuffer, cancellationToken);
byte[] asyncBack = await ScyllaFileUtil.LoadCompressedAsync(replayPath, cancellationToken);

/* Custom compression settings (e.g. Deflate with level 9 for archives). */
var compressionMax = new CompressionSettings { Level = CompressionLevel.SmallestSize };
ScyllaFileUtil.SaveCompressed(archivePath, replayBuffer, compressionMax);

/* Encrypt with a password. PBKDF2 derives a key from the password and a fresh
 * random salt embedded in the output; AES-GCM (or AES-CBC-HMAC depending on
 * CryptoSettings) provides confidentiality and authentication. */
ScyllaFileUtil.SaveEncrypted(secretPath, sensitiveBytes, "p@ssw0rd-from-user");

/* Load. Wrong password raises FileException with FileErrorCode.EncryptionFailed.
 * Corrupt or tampered ciphertext raises the same code because the AEAD
 * (Authenticated Encryption with Associated Data) tag check fails identically. */
byte[] back2 = ScyllaFileUtil.LoadEncrypted(secretPath, "p@ssw0rd-from-user");

/* The full pipeline: serialize -> compress -> encrypt. The single canonical
 * call for save data that should be small, opaque, and resistant to tampering.
 * One call instead of three reduces the chance of a step being skipped. */
ScyllaFileUtil.SaveSecureObject(savePath, gameState, "session-derived-key");

/* Load, returning a SerializationResult<T>. The result is failed if the
 * password is wrong, the file is missing/locked, the ciphertext is corrupt,
 * the decompression fails, or the JSON does not deserialize. The structured
 * result lets you distinguish "no save" from "save corrupt". */
SerializationResult<GameState> secureResult =
    ScyllaFileUtil.LoadSecureObject<GameState>(savePath, "session-derived-key");

if (secureResult.IsSuccess)
{
    LoadGame(secureResult.Value);
}

/* Settings-driven variant: pick non-default settings for any stage. */
ScyllaFileUtil.SaveSecureObject(
    savePath,
    gameState,
    "session-derived-key",
    serializationSettings: SerializationSettings.Compact,
    compressionSettings:   CompressionSettings.Default,
    cryptoSettings:        CryptoSettings.AESGCM
);

/* Async forms exist for every variant. */
await ScyllaFileUtil.SaveSecureObjectAsync(savePath, gameState, "session-derived-key", cancellationToken);
var asyncSecure = await ScyllaFileUtil.LoadSecureObjectAsync<GameState>(savePath, "session-derived-key", cancellationToken);

Save and load screenshots and procedural textures

Problem. Your game captures screenshots for bug reports, bakes procedural textures at runtime, or lets players export their character portraits. You need to round-trip a Texture2D to and from disk in the right format without managing Unity’s encoding API yourself.

Solution. SaveImage and LoadImage handle Texture2D round-trips through PNG, JPG, WEBP, EXR, and TGA. The format-overload picks the encoder by ImageFileFormat; the settings overload accepts an ImageFileSettings instance with finer controls (quality 1-100, mipmaps, filter mode, anisotropic level, EXR flags). Six presets cover the common shapes (Default = PNG, HighQuality = lossless or quality 100, FastCompression = lower-quality JPG/WEBP, WebOptimized = WEBP, Screenshot = PNG with no mipmaps, HDR = EXR). DetectImageFormat peeks at the file or buffer header to identify the actual encoding; IsImageFormatSupported checks runtime availability (WEBP requires Unity 2021.2+).

/* Load with default settings (PNG, generates a readable Texture2D). */
Texture2D loaded = ScyllaFileUtil.LoadImage(screenshotPath);

/* Try variant: false on missing, unreadable, or unsupported format. */
if (ScyllaFileUtil.TryLoadImage(screenshotPath, out Texture2D maybe))
{
    UseTexture(maybe);
}

/* Async load (decodes off the main thread where possible). */
Texture2D loadedAsync = await ScyllaFileUtil.LoadImageAsync(screenshotPath, cancellationToken);

/* Save with default settings: PNG, readable, no mipmaps. */
ScyllaFileUtil.SaveImage(screenshotPath, screenshot);

/* Format-specific save. The path's extension does not need to match; the
 * format argument is authoritative. */
ScyllaFileUtil.SaveImage(jpgPath, screenshot, ImageFileFormat.JPG);

/* Quality control for lossy formats. Ignored by PNG/EXR/TGA (lossless). */
ScyllaFileUtil.SaveImage(jpgPath, screenshot, ImageFileFormat.JPG, quality: 75);

/* Use a preset for richer control. HighQuality keeps mipmaps and full anisotropy. */
ScyllaFileUtil.SaveImage(heroPath, heroPortrait, ImageFileSettings.HighQuality);

/* Web-optimized: WEBP, quality 85, no mipmaps. */
ScyllaFileUtil.SaveImage(webPath, screenshot, ImageFileSettings.WebOptimized);

/* HDR (High Dynamic Range) capture for environment lighting or panorama tooling. */
ScyllaFileUtil.SaveImage(hdrPath, hdrCapture, ImageFileSettings.HDR);

/* Detect a file's format from its bytes (magic-byte sniff). Returns null when
 * the format cannot be determined; TGA has no standardized magic bytes and
 * relies on the .tga path extension for the path-based overload. */
ImageFileFormat? detected  = ScyllaFileUtil.DetectImageFormat(screenshotPath);
ImageFileFormat? fromBytes = ScyllaFileUtil.DetectImageFormat(buffer);

/* Runtime availability check. Skip WEBP when not supported on the current
 * Unity version and fall back to PNG. */
if (!ScyllaFileUtil.IsImageFormatSupported(ImageFileFormat.WEBP))
{
    ScyllaFileUtil.SaveImage(fallbackPath, screenshot, ImageFileFormat.PNG);
}

/* Async save. */
await ScyllaFileUtil.SaveImageAsync(screenshotPath, screenshot, cancellationToken);

Check disk space, rotate logs, and manage files

Problem. Your game appends to a log file across a multi-hour session, takes periodic screenshots, and writes large save states. You need to check available disk space before a large write, clean up old logs, and do the surrounding bookkeeping (existence checks, size queries, copy, move, delete) through the same typed-error path as everything else.

Solution. The maintenance family covers all of the surrounding bookkeeping. Use these helpers in place of bare File.* and Directory.* calls so the same path validation, error codes, and settings-driven semantics apply throughout your codebase.

/* Existence checks. */
bool fileThere = ScyllaFileUtil.Exists(path);
bool dirThere  = ScyllaFileUtil.DirectoryExists(parentDir);

/* Metadata queries. */
long     sizeBytes = ScyllaFileUtil.GetFileSize(path);
DateTime wrote     = ScyllaFileUtil.GetLastWriteTime(path);
DateTime made      = ScyllaFileUtil.GetCreationTime(path);

/* Make sure a directory exists; create it (and parents) if absent. */
ScyllaFileUtil.EnsureDirectoryExists(parentDir);

/* Make sure a file's parent directory exists. Useful before opening a stream
 * to a path whose intermediate folders may not have been created yet. */
ScyllaFileUtil.EnsureParentDirectoryExists(path);

/* Path utilities exposed via ScyllaFileUtil for callers that prefer the facade
 * over reaching into FilePathUtil. */
string normalized = ScyllaFileUtil.NormalizePath(rawPath);
string combined   = ScyllaFileUtil.CombinePath(FilePathUtil.PersistentDataPath, "screenshots", "a.png");
string safe       = ScyllaFileUtil.GetSafeFileName("invalid: name/.txt");
string uniq       = ScyllaFileUtil.GetUniqueFileName(path);

/* Copy and move. Throwing variants; async variants accept a CancellationToken
 * and an IProgress<FileProgress> through the settings-driven overload. */
ScyllaFileUtil.CopyFile(sourcePath, backupPath);
ScyllaFileUtil.MoveFile(stagingPath, finalPath);

/* Async copy with progress. */
var progress = new Progress<FileProgress>(p => UpdateCopyUI(p.PercentComplete));
await ScyllaFileUtil.CopyFileAsync(sourcePath, backupPath, FileSettings.LargeFile, progress, cancellationToken);

/* Delete. Throws on missing/locked; TryDeleteFile returns bool and never throws. */
ScyllaFileUtil.DeleteFile(staleSavePath);
bool deleted = ScyllaFileUtil.TryDeleteFile(staleSavePath);

/* Disk-space queries. GetAvailableDiskSpace returns the number of free bytes
 * on the volume containing the path; HasSufficientDiskSpace is a single-call
 * pre-check before a large write. */
long free       = ScyllaFileUtil.GetAvailableDiskSpace(savePath);
bool roomEnough = ScyllaFileUtil.HasSufficientDiskSpace(savePath, requiredBytes: 50 * 1024 * 1024);

if (!roomEnough)
{
    /* Surface the condition to the player before failing mid-write. */
    NotifyOutOfSpace(savePath);
    return;
}

/* Log rotation. Renames game.log -> game.log.1, game.log.1 -> game.log.2, etc.,
 * keeping at most maxFiles. The active file is rotated when its size exceeds
 * maxFileSizeBytes. Use to keep a bounded log history that stays inspectable. */
ScyllaFileUtil.RotateFile(logPath, maxFiles: 5, maxFileSizeBytes: 5 * 1024 * 1024);

/* Bulk rotation over a directory matching a pattern. */
ScyllaFileUtil.RotateFiles(logDir, pattern: "*.log", maxFiles: 5);

/* Pick the oldest matching file (for "delete the oldest screenshot when over
 * quota" kinds of routines). */
string oldest = ScyllaFileUtil.GetOldestFile(screenshotDir, pattern: "*.png");

/* List files sorted by last-write time. */
string[] byAge = ScyllaFileUtil.GetFilesByAge(screenshotDir, pattern: "*.png", ascending: true);

Choose the right settings preset and fluent shortcuts

Problem. Most of your I/O calls use default settings and you want the shortest code possible. A handful of calls need specific tuning (large buffer, append-friendly share mode, no BOM detection). You also want to use the Config Files tier paths through the same consistent API.

Solution. FileSettings and ImageFileSettings each ship with named presets covering the common shapes; custom instances are simple to build for the rest. The FileExtensions and FilePathExtensions classes expose the same operations as extension methods on byte[], string, Texture2D, IEnumerable<string>, and T : class for fluent builder-style code. ConfigFileUtil is the small bridge between ScyllaFileUtil and the framework’s ConfigFile JSON layer; the Configuration system uses it to load and save the User Documents and Application-folder tiers.

/* FileSettings presets. Use as-is or as a base for Clone + tweak. */
ScyllaFileUtil.WriteText(path, contents, FileSettings.Default);    /* 64 KB, UTF-8, BOM detect */
ScyllaFileUtil.WriteText(path, contents, FileSettings.Async);      /* async-IO flag on */
ScyllaFileUtil.WriteText(path, contents, FileSettings.TextFile);   /* UTF-8 only, no BOM */
ScyllaFileUtil.WriteText(path, contents, FileSettings.BinaryFile); /* ASCII (irrelevant for binary), no BOM detect */
ScyllaFileUtil.WriteBytes(path, payload, FileSettings.LargeFile);  /* 256 KB buffer, async-IO on */
ScyllaFileUtil.AppendText(logPath, line, FileSettings.LogFile);    /* shared-write for tail-followers */

/* Build a custom instance with the constructor or copy-constructor. */
var custom = new FileSettings
{
    Encoding          = Encoding.UTF8,
    BufferSize        = 128 * 1024,
    OverwriteExisting = false,           /* refuse to overwrite */
    CreateDirectories = true,
    FlushAfterWrite   = true,            /* fsync on Stream.Dispose */
    ReadShareMode     = FileShare.Read,
    WriteShareMode    = FileShare.None
};
custom.Validate(); /* throws FileException if any property is out of range */

/* ImageFileSettings presets. */
ScyllaFileUtil.SaveImage(p, t, ImageFileSettings.Default);          /* PNG, no mipmaps */
ScyllaFileUtil.SaveImage(p, t, ImageFileSettings.HighQuality);      /* PNG + mipmaps + anisotropy */
ScyllaFileUtil.SaveImage(p, t, ImageFileSettings.FastCompression);  /* JPG quality 60 */
ScyllaFileUtil.SaveImage(p, t, ImageFileSettings.WebOptimized);     /* WEBP quality 85 */
ScyllaFileUtil.SaveImage(p, t, ImageFileSettings.Screenshot);       /* PNG, no mipmaps, point filter */
ScyllaFileUtil.SaveImage(p, t, ImageFileSettings.HDR);              /* EXR, ZIP-compressed */

/* Fluent extensions. byte[], string, Texture2D, IEnumerable<string>, and any
 * class-typed object gain SaveToFile / SaveToFileAsync, plus format-specific
 * shortcuts for images. */
buffer.SaveToFile(blobPath);
"hello world".SaveToFile(textPath);
texture.SaveAsPNG(screenshotPath);
texture.SaveAsJPG(jpgPath, quality: 80);
await texture.SaveAsWEBPAsync(webPath, quality: 85, cancellationToken);
texture.SaveAsEXR(hdrPath, flags: Texture2D.EXRFlags.CompressZIP);
texture.SaveAsTGA(legacyPath);
lines.SaveLinesToFile(logPath);
config.SaveToFile(configPath);                   /* via ScyllaSerialization */
config.SaveSecureToFile(savePath, "password");   /* serialize + compress + encrypt */
await config.SaveSecureToFileAsync(savePath, "password", cancellationToken);

/* FilePathExtensions: same operations, fluent on string. */
string canonical = rawPath.NormalizePath().ToForwardSlashes();
bool   ok        = canonical.IsValidPath();
string fname     = canonical.GetPathFileName();
string altExt    = canonical.ChangePathExtension(".bak");

/* ConfigFileUtil: load and save ConfigFile JSON in the framework's canonical
 * directories. ConfigFile itself is documented in config-files. */
string userPath = ConfigFileUtil.GetUserDocumentsConfigPath("Logger");
string appPath  = ConfigFileUtil.GetAppFolderConfigPath("Logger");

ConfigFile loaded = ConfigFileUtil.Load(userPath); /* returns null on any failure */
if (loaded == null)
{
    /* Fall back to the lower-priority Application tier. */
    loaded = ConfigFileUtil.Load(appPath);
}

bool saved = ConfigFileUtil.Save(userPath, configFile); /* returns false on failure */
if (!saved)
{
    Log.Warning($"Failed to save config to {userPath}", LogCategory.Core);
}

/* Try variant for explicit existence-vs-failure branching. */
if (ConfigFileUtil.TryLoad(userPath, out ConfigFile parsed))
{
    UseConfig(parsed);
}

API Sketch

The surface splits into the facade (ScyllaFileUtil), the path utility (FilePathUtil), the two extension classes, the config-file bridge, the settings/progress types, and the exception family. Every method on the facade has a matching *Async and (where applicable) Try* variant. Settings overloads exist for every read and write and accept either an instance or null (which uses the appropriate *.Default).

namespace Scylla.Core.Util.File
{
    /* High-level facade */
    public static class ScyllaFileUtil
    {
        /* Text */
        public static string ReadText(string path);
        public static string ReadText(string path, FileSettings settings);
        public static string ReadText(string path, Encoding encoding);
        public static Task<string> ReadTextAsync(string path, CancellationToken ct = default);
        public static Task<string> ReadTextAsync(string path, FileSettings settings, CancellationToken ct = default);
        public static bool TryReadText(string path, out string content);
        public static bool TryReadText(string path, FileSettings settings, out string content);

        public static void WriteText(string path, string content);
        public static void WriteText(string path, string content, FileSettings settings);
        public static void WriteText(string path, string content, Encoding encoding);
        public static Task WriteTextAsync(string path, string content, CancellationToken ct = default);
        public static Task WriteTextAsync(string path, string content, FileSettings settings, CancellationToken ct = default);

        public static string[] ReadLines(string path);
        public static string[] ReadLines(string path, FileSettings settings);
        public static Task<string[]> ReadLinesAsync(string path, CancellationToken ct = default);
        public static Task<string[]> ReadLinesAsync(string path, FileSettings settings, CancellationToken ct = default);
        public static IEnumerable<string> EnumerateLines(string path);
        public static IEnumerable<string> EnumerateLines(string path, FileSettings settings);

        public static void WriteLines(string path, IEnumerable<string> lines);
        public static void WriteLines(string path, IEnumerable<string> lines, FileSettings settings);
        public static Task WriteLinesAsync(string path, IEnumerable<string> lines, CancellationToken ct = default);

        public static void AppendText(string path, string text);
        public static void AppendLine(string path, string line);
        public static void AppendLines(string path, IEnumerable<string> lines);
        /* + async + settings overloads for each Append* */

        /* Binary */
        public static byte[] ReadBytes(string path);
        public static byte[] ReadBytes(string path, FileSettings settings);
        public static Task<byte[]> ReadBytesAsync(string path, CancellationToken ct = default);
        public static bool TryReadBytes(string path, out byte[] data);
        public static void WriteBytes(string path, byte[] data);
        public static Task WriteBytesAsync(string path, byte[] data, CancellationToken ct = default);

        /* Streams (with optional IProgress<FileProgress>) */
        public static void ReadToStream(string path, Stream destination);
        public static void ReadToStream(string path, Stream destination, FileSettings settings, IProgress<FileProgress> progress = null);
        public static Task ReadToStreamAsync(string path, Stream destination, CancellationToken ct = default);
        public static void WriteFromStream(string path, Stream source);
        public static void WriteFromStream(string path, Stream source, FileSettings settings, IProgress<FileProgress> progress = null);
        public static Task WriteFromStreamAsync(string path, Stream source, FileSettings settings, IProgress<FileProgress> progress = null, CancellationToken ct = default);

        /* Object I/O via ScyllaSerialization */
        public static void SaveObject<T>(string path, T obj);
        public static void SaveObject<T>(string path, T obj, SerializationSettings settings);
        public static SerializationResult<T> LoadObject<T>(string path);
        public static Task SaveObjectAsync<T>(string path, T obj, CancellationToken ct = default);
        public static Task<SerializationResult<T>> LoadObjectAsync<T>(string path, CancellationToken ct = default);

        /* Compressed I/O via ScyllaCompression */
        public static void SaveCompressed(string path, byte[] data);
        public static void SaveCompressed(string path, byte[] data, CompressionSettings settings);
        public static byte[] LoadCompressed(string path);
        public static Task SaveCompressedAsync(string path, byte[] data, CancellationToken ct = default);
        public static Task<byte[]> LoadCompressedAsync(string path, CancellationToken ct = default);

        /* Encrypted I/O via ScyllaCrypto + PBKDF2 */
        public static void SaveEncrypted(string path, byte[] data, string password);
        public static void SaveEncrypted(string path, byte[] data, string password, CryptoSettings settings);
        public static byte[] LoadEncrypted(string path, string password);
        public static Task SaveEncryptedAsync(string path, byte[] data, string password, CancellationToken ct = default);
        public static Task<byte[]> LoadEncryptedAsync(string path, string password, CancellationToken ct = default);

        /* Secure object I/O (serialize + compress + encrypt) */
        public static void SaveSecureObject<T>(string path, T obj, string password);
        public static void SaveSecureObject<T>(string path, T obj, string password,
            SerializationSettings ser, CompressionSettings comp, CryptoSettings crypto);
        public static SerializationResult<T> LoadSecureObject<T>(string path, string password);
        public static Task SaveSecureObjectAsync<T>(string path, T obj, string password, CancellationToken ct = default);
        public static Task<SerializationResult<T>> LoadSecureObjectAsync<T>(string path, string password, CancellationToken ct = default);

        /* Image I/O */
        public static Texture2D LoadImage(string path);
        public static Texture2D LoadImage(string path, ImageFileSettings settings);
        public static Task<Texture2D> LoadImageAsync(string path, CancellationToken ct = default);
        public static bool TryLoadImage(string path, out Texture2D texture);
        public static void SaveImage(string path, Texture2D texture);
        public static void SaveImage(string path, Texture2D texture, ImageFileSettings settings);
        public static void SaveImage(string path, Texture2D texture, ImageFileFormat format);
        public static void SaveImage(string path, Texture2D texture, ImageFileFormat format, int quality);
        public static Task SaveImageAsync(string path, Texture2D texture, CancellationToken ct = default);
        public static ImageFileFormat? DetectImageFormat(byte[] data);
        public static ImageFileFormat? DetectImageFormat(string path);
        public static bool IsImageFormatSupported(ImageFileFormat format);

        /* Maintenance */
        public static bool Exists(string path);
        public static bool DirectoryExists(string path);
        public static long GetFileSize(string path);
        public static DateTime GetLastWriteTime(string path);
        public static DateTime GetCreationTime(string path);
        public static long GetAvailableDiskSpace(string path);
        public static bool HasSufficientDiskSpace(string path, long requiredBytes);
        public static void EnsureDirectoryExists(string path);
        public static void EnsureParentDirectoryExists(string filePath);
        public static string NormalizePath(string path);
        public static string CombinePath(params string[] paths);
        public static string GetSafeFileName(string fileName);
        public static string GetUniqueFileName(string path);
        public static void CopyFile(string source, string destination);
        public static Task CopyFileAsync(string source, string destination, FileSettings settings, IProgress<FileProgress> progress = null, CancellationToken ct = default);
        public static void MoveFile(string source, string destination);
        public static void DeleteFile(string path);
        public static bool TryDeleteFile(string path);
        public static void RotateFile(string path, int maxFiles = 10, long maxFileSizeBytes = 1024 * 1024);
        public static void RotateFiles(string directory, string pattern, int maxFiles = 10);
        public static string GetOldestFile(string directory, string pattern);
        public static string[] GetFilesByAge(string directory, string pattern, bool ascending = true);
    }

    /* Cross-platform path utility */
    public static class FilePathUtil
    {
        public static readonly char DirectorySeparator;
        public static readonly char AltDirectorySeparator;
        public static readonly char[] InvalidPathChars;
        public static readonly char[] InvalidFileNameChars;

        /* Platform roots */
        public static string ApplicationPath      { get; }
        public static string DataPath             { get; }
        public static string PersistentDataPath   { get; }
        public static string StreamingAssetsPath  { get; }
        public static string TemporaryCachePath   { get; }
        public static string ConsoleLogPath       { get; }
        public static string DocumentsPath        { get; }
        public static string DesktopPath          { get; } /* nullable on non-desktop */
        public static string UserProfilePath      { get; }
        public static string AppDataPath          { get; }
        public static string LocalAppDataPath     { get; }

        /* Path operations */
        public static string Combine(params string[] paths);
        public static string CombineWithExtension(string directory, string fileName, string extension);
        public static string Normalize(string path);
        public static string ToForwardSlashes(string path);
        public static string ToBackSlashes(string path);
        public static string GetParent(string path);
        public static string GetFileName(string path);
        public static string GetFileNameWithoutExtension(string path);
        public static string GetExtension(string path);
        public static string GetRoot(string path);
        public static string ChangeExtension(string path, string newExtension);
        public static string MakeRelative(string basePath, string targetPath);
        public static string MakeAbsolute(string basePath, string relativePath);

        /* Validation */
        public static bool IsValidPath(string path);
        public static bool IsAbsolutePath(string path);
        public static bool IsRootPath(string path);
        public static bool ContainsInvalidPathChars(string path);
        public static bool ContainsInvalidFileNameChars(string fileName);

        /* Safe name generation */
        public static string GetSafeFileName(string fileName, char replacement = '_');
        public static string GetUniqueFileName(string directory, string fileName);
    }

    /* Fluent extensions on byte[], string, Texture2D, IEnumerable<string>, T : class */
    public static class FileExtensions
    {
        public static void SaveToFile(this byte[] data, string path);
        public static void SaveToFile(this string text, string path);
        public static void SaveToFile(this Texture2D texture, string path);
        public static void SaveToFile<T>(this T obj, string path) where T : class;
        public static void SaveLinesToFile(this IEnumerable<string> lines, string path);
        public static void SaveCompressedToFile(this byte[] data, string path);
        public static void SaveEncryptedToFile(this byte[] data, string path, string password);
        public static void SaveSecureToFile<T>(this T obj, string path, string password) where T : class;
        public static void SaveAsPNG(this Texture2D texture, string path);
        public static void SaveAsJPG(this Texture2D texture, string path, int quality = 90);
        public static void SaveAsWEBP(this Texture2D texture, string path, int quality = 90);
        public static void SaveAsEXR(this Texture2D texture, string path, Texture2D.EXRFlags flags = Texture2D.EXRFlags.None);
        public static void SaveAsTGA(this Texture2D texture, string path);
        public static void AppendToFile(this string text, string path);
        public static void AppendLinesToFile(this IEnumerable<string> lines, string path);
        /* + Async variants for every method */
    }

    /* Path extensions on string */
    public static class FilePathExtensions
    {
        public static string NormalizePath(this string path);
        public static string ToForwardSlashes(this string path);
        public static string ToBackSlashes(this string path);
        public static string GetPathParent(this string path);
        public static string GetPathFileName(this string path);
        public static string GetPathFileNameWithoutExtension(this string path);
        public static string GetPathExtension(this string path);
        public static string GetPathRoot(this string path);
        public static bool   IsValidPath(this string path);
        public static bool   IsAbsolutePath(this string path);
        public static bool   IsRootPath(this string path);
        public static bool   ContainsInvalidPathChars(this string path);
        public static bool   ContainsInvalidFileNameChars(this string fileName);
        public static string ToSafeFileName(this string fileName, char replacement = '_');
        public static string ChangePathExtension(this string path, string newExtension);
    }

    /* ConfigFile bridge */
    public static class ConfigFileUtil
    {
        public const string CONFIG_SUBFOLDER       = "Config";
        public const string CONFIG_FILE_EXTENSION  = ".json";

        public static ConfigFile Load(string path);                          /* returns null on failure */
        public static bool TryLoad(string path, out ConfigFile configFile);
        public static bool Save(string path, ConfigFile configFile);
        public static string GetUserDocumentsConfigPath(string configBaseName);
        public static string GetAppFolderConfigPath(string configBaseName);
    }

    /* Settings, progress, exception */
    public sealed class FileSettings
    {
        public const int  DEFAULT_BUFFER_SIZE   = 64 * 1024;
        public const int  LARGE_BUFFER_SIZE     = 256 * 1024;
        public const long LARGE_FILE_THRESHOLD  = 10 * 1024 * 1024;
        public const int  MIN_BUFFER_SIZE       = 1024;
        public const int  MAX_BUFFER_SIZE       = 16 * 1024 * 1024;

        public static FileSettings Default     { get; }
        public static FileSettings Async       { get; }
        public static FileSettings TextFile    { get; }
        public static FileSettings BinaryFile  { get; }
        public static FileSettings LargeFile   { get; }
        public static FileSettings LogFile     { get; }

        public Encoding   Encoding              { get; set; } /* default UTF-8 */
        public int        BufferSize            { get; set; } /* default 64 KB */
        public bool       UseAsyncIO            { get; set; }
        public bool       OverwriteExisting     { get; set; } /* default true */
        public bool       CreateDirectories     { get; set; } /* default true */
        public FileShare  ReadShareMode         { get; set; } /* default FileShare.Read */
        public FileShare  WriteShareMode        { get; set; } /* default FileShare.None */
        public bool       DetectEncodingFromBOM { get; set; } /* default true */
        public bool       PreserveTimestamps    { get; set; } /* default true */
        public bool       FlushAfterWrite       { get; set; }

        public FileSettings();
        public FileSettings(FileSettings other);
        public FileSettings Clone();
        public void Validate();
    }

    public readonly struct FileProgress
    {
        public long   BytesProcessed  { get; }
        public long   TotalBytes      { get; }
        public double PercentComplete  { get; } /* 0..100 */
        public bool   IsComplete      { get; }
        public string FilePath        { get; }
        public string Operation       { get; }
        public FileProgress(long bytesProcessed, long totalBytes, string filePath, string operation);
    }

    public class FileException : Exception
    {
        public FileErrorCode ErrorCode { get; }
        public string        FilePath  { get; }
        /* Constructors taking various combinations of ErrorCode, message, path, inner. */
    }

    public enum FileErrorCode
    {
        Unknown = 0, NullData = 1, FileNotFound = 2, DirectoryNotFound = 3,
        AccessDenied = 4, FileExists = 5, InvalidSettings = 6, OperationCancelled = 7,
        InsufficientSpace = 8, Timeout = 9, InvalidPath = 10, FileLocked = 11,
        IOError = 12, UnsupportedImageFormat = 13, TextureNotReadable = 14,
        ImageEncodingFailed = 15, ImageDecodingFailed = 16, PathTooLong = 17,
        UnsupportedEncoding = 18, SerializationFailed = 19, CompressionFailed = 20,
        EncryptionFailed = 21
    }

    /* Image types */
    public sealed class ImageFileSettings
    {
        public const int DEFAULT_QUALITY = 90;
        public const int MIN_QUALITY     = 1;
        public const int MAX_QUALITY     = 100;

        public static ImageFileSettings Default          { get; }
        public static ImageFileSettings HighQuality      { get; }
        public static ImageFileSettings FastCompression  { get; }
        public static ImageFileSettings WebOptimized     { get; }
        public static ImageFileSettings Screenshot       { get; }
        public static ImageFileSettings HDR              { get; }

        public ImageFileFormat       Format          { get; set; } /* default PNG */
        public int                   Quality         { get; set; } /* 1..100, default 90 */
        public bool                  GenerateMipMaps { get; set; }
        public bool                  MakeReadable    { get; set; } /* default true */
        public FilterMode            FilterMode      { get; set; } /* default Bilinear */
        public TextureWrapMode       WrapMode        { get; set; } /* default Clamp */
        public int                   AnisoLevel      { get; set; } /* default 1 */
        public Texture2D.EXRFlags    EXRFlags        { get; set; }

        public ImageFileSettings();
        public ImageFileSettings(ImageFileSettings other);
        public ImageFileSettings Clone();
        public void Validate();
    }

    public enum ImageFileFormat { PNG = 0, JPG = 1, WEBP = 2, EXR = 3, TGA = 4 }
}

See the API reference for full signatures, remarks, and exception contracts on every member.

Settings reference

The four small types below carry every dial the I/O facade exposes. FileSettings is the common case; ImageFileSettings adds Unity-texture controls; FileProgress is read-only output for callbacks; FileErrorCode is the catch-block selector.

FileSettings properties

PropertyTypeDefaultEffect
EncodingEncodingEncoding.UTF8Character encoding used for text reads and writes. UTF-8 is the safe default and matches every other Scylla text path.
BufferSizeint65536 (64 KB)Underlying FileStream buffer size in bytes. Range 1024..16777216 (1 KB to 16 MB). Larger buffers help on slow disks and large files; smaller buffers waste less memory on tiny files.
UseAsyncIOboolfalseSets the FileStream async flag (FileOptions.Asynchronous). Pair with *Async methods for non-blocking I/O on the OS layer.
OverwriteExistingbooltrueWhen false, write operations refuse to overwrite an existing file and raise FileException with FileErrorCode.FileExists.
CreateDirectoriesbooltrueWhen true, the parent directory of the target path is created automatically before the write opens the stream. When false, a missing parent raises FileErrorCode.DirectoryNotFound.
ReadShareModeFileShareFileShare.ReadShare mode for reads. Default allows concurrent reads from other processes; tighten to None when exclusive access is required.
WriteShareModeFileShareFileShare.NoneShare mode for writes. Default refuses concurrent access; LogFile preset uses ReadWrite so tail-followers can read while the game writes.
DetectEncodingFromBOMbooltrueWhen true, the StreamReader inspects the file’s leading bytes for a BOM (Byte Order Mark) and switches encoding if one is present. Files written by external editors usually round-trip correctly with this on.
PreserveTimestampsbooltrueWhen true, copy operations preserve the source file’s creation and last-write times on the destination. When false, the destination receives current-time timestamps.
FlushAfterWriteboolfalseWhen true, the underlying FileStream is explicitly flushed (Stream.Flush(true)) before disposal, forcing the OS to write through to disk rather than keeping the data in the disk cache. Slower but more durable.

FileSettings presets

PresetDistinguishing propertiesUse case
FileSettings.DefaultAll defaultsMost reads and writes. UTF-8, 64 KB buffer, auto-create directories.
FileSettings.AsyncUseAsyncIO = truePair with any *Async method for non-blocking OS-level I/O.
FileSettings.TextFileDetectEncodingFromBOM = falseReading and writing UTF-8 without BOM auto-detect; faster on small text.
FileSettings.BinaryFileDetectEncodingFromBOM = falseSame as TextFile but framed as the binary preset for clarity.
FileSettings.LargeFileBufferSize = 262144 (256 KB), UseAsyncIO = trueMulti-megabyte transfers, replays, video, addressables.
FileSettings.LogFileWriteShareMode = FileShare.ReadWriteActive log files that tail-followers (tail -f, log viewers) read concurrently.

ImageFileSettings properties

PropertyTypeDefaultEffect
FormatImageFileFormatPNGEncoder pick. PNG and TGA are lossless with alpha; JPG is lossy without alpha; WEBP supports both modes with alpha; EXR is HDR.
Qualityint90Lossy encoder quality (1..100). Ignored by PNG, EXR, TGA.
GenerateMipMapsboolfalseWhen loading: generate mipmaps on the resulting Texture2D. Useful for textures sampled at varied distances.
MakeReadablebooltrueWhen loading: mark the texture as readable so it can be saved or read back later.
FilterModeFilterModeBilinearTexture filter applied on load. Point for pixel-art, Bilinear for smooth, Trilinear with mipmaps.
WrapModeTextureWrapModeClampUV wrap mode on the loaded texture.
AnisoLevelint1Anisotropic filtering level (0..16). Higher values improve sharpness on glancing angles at GPU cost.
EXRFlagsTexture2D.EXRFlagsNoneEXR-specific compression. CompressZIP for lossless ZIP, CompressPIZ for wavelet, OutputAsFloat for 32-bit.

FileErrorCode summary

CodeTrigger
UnknownCatch-all for failures that do not map to a more specific code. Inspect InnerException.
NullDataA null or empty argument was passed where a path or buffer was required. Raised before any I/O.
FileNotFoundPath resolves but no file exists there. Maps to System.IO.FileNotFoundException.
DirectoryNotFoundA parent directory in the path is missing and CreateDirectories = false.
AccessDeniedThe OS refused access. EACCES on POSIX, ERROR_ACCESS_DENIED on Windows.
FileExistsWrite refused because the destination exists and OverwriteExisting = false.
InvalidSettingsA FileSettings property is out of range (negative buffer, null encoding).
OperationCancelledA CancellationToken fired before the operation completed.
InsufficientSpaceThe target volume ran out of disk space mid-write.
TimeoutThe operation did not complete within the allotted time. Caller may retry.
InvalidPathPath contains illegal characters or a .. traversal sequence.
FileLockedAnother process holds the file open in an incompatible share mode. Caller may retry after a delay.
IOErrorLow-level I/O error not covered by a more specific code. Inspect InnerException for the platform detail.
UnsupportedImageFormatThe file’s format is not in the supported list, or the runtime cannot encode the requested format.
TextureNotReadableThe Texture2D passed to SaveImage is not marked readable. Enable Read/Write in import settings.
ImageEncodingFailedThe Unity encoder produced an error (out of memory, unsupported pixel format).
ImageDecodingFailedThe file data is corrupt, truncated, or in a format the current Unity codec cannot decode.
PathTooLongThe full path exceeds the OS maximum (MAX_PATH 260 on Windows without long-path support).
UnsupportedEncodingThe requested text encoding is unavailable on the current platform.
SerializationFailedScyllaSerialization failed to serialize or deserialize. Inspect InnerException from the serializer.
CompressionFailedScyllaCompression failed. Often corrupt compressed data or out of memory.
EncryptionFailedScyllaCrypto failed. Most often a wrong password (PBKDF2 mismatch) or a tampered/corrupt AEAD ciphertext.

Best Practices

  • Always route through ScyllaFileUtil rather than System.IO.File. The facade gives you uniform path validation, typed errors, settings-driven behaviour, and identical semantics on every platform. One-off File.WriteAllText calls bypass every one of those and tend to grow inconsistent over time.
  • Use Try* for “may legitimately not exist” reads and throwing variants for “should always exist” reads. First-run config files, optional save slots, and missing screenshots are all Try* use cases. Engine asset bundles that ship with the build are throwing-variant use cases.
  • Pick the platform path on purpose, not by habit. PersistentDataPath is right for game-internal saves the player is not expected to edit. DocumentsPath is right for files the player should be able to find and open. AppDataPath is right for roaming application state. TemporaryCachePath is right for ephemeral scratch.
  • Async + cancellation for any I/O that could exceed one frame. Sync I/O on the main thread stalls the render loop. Every read or write of more than a megabyte should be *Async with a CancellationToken tied to the scene or window lifetime.
  • Use FileSettings.LargeFile for multi-megabyte transfers. The 256 KB buffer and async-IO flag together cut throughput overhead substantially compared to the 64 KB default. The difference on small files is negligible; on large files it is meaningful.
  • Surface disk-space failures with HasSufficientDiskSpace before a large write. A mid-write disk-full failure leaves a partial file on disk. The pre-check costs nothing and lets you refuse the write cleanly.
  • Catch FileException and branch on ErrorCode. Different codes call for different recovery: retry on FileLocked, prompt the player on AccessDenied, fall back on FileNotFound, log and abort on IOError. Catching Exception and discarding the type is the most common source of vague error reporting.
  • Use SaveSecureObject for save games. The serialize + compress + encrypt pipeline is one call, produces an opaque file that resists casual tampering, and surfaces every failure mode through a single SerializationResult<T>. Combining the three operations by hand is the source of every cross-platform save-corruption bug.
  • Sanitize user-supplied filenames before writing. GetSafeFileName replaces illegal characters with '_'; GetUniqueFileName resolves collisions. Skipping either is the most common cause of InvalidPath and AccessDenied on Windows, where reserved names (CON, NUL, AUX) are still illegal.
  • Rotate log files. A long-running game that appends to a single log file produces gigabytes of text by the end of a session. RotateFile with a 1 MB cap and 5-file history keeps the working set bounded and the recent context still inspectable.
  • Validate FileSettings instances when building them by hand. Validate raises FileException with FileErrorCode.InvalidSettings for any property out of range. Calling it at construction time catches mistakes before they reach a stream.
  • Prefer the fluent extensions for one-off saves. buffer.SaveToFile(path), texture.SaveAsPNG(path), and config.SaveSecureToFile(path, pw) read more naturally than the facade calls and produce identical results. Reserve the facade form for plumbing code that switches behaviour through FileSettings.

Pitfalls