Skip to content

Commit 572ea35

Browse files
committed
Moved from ISaveable to ISaveable<TState> to provide compile-time type safety, safer JSON deserialization (no type names), and clearer save/restore contracts.
1 parent 4770a4a commit 572ea35

File tree

9 files changed

+493
-591
lines changed

9 files changed

+493
-591
lines changed

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,34 @@
11
# Changelog
22

3+
## [0.11.0] – 2025-09-22
4+
5+
### Breaking Change
6+
Moved from `ISaveable` to `ISaveable<TState>` to provide compile-time type safety, safer JSON deserialization (no type names), and clearer save/restore contracts. All saveables must now define a serializable `TState` and implement `CaptureState(): TState` and `RestoreState(TState state)`.
7+
8+
### Serialization & Safety
9+
10+
- JSON now deserializes directly to each saveable’s `TState` (no type names emitted), reducing polymorphic-deserialization risk.
11+
- Existing save files that match the new `TState` shape should continue to load; files that relied on polymorphic `object` states will need a one-time migration.
12+
13+
### Async/Awaitable & Concurrency
14+
15+
- Replaced single-iteration “while not cancelled” wrappers with clear guard clauses.
16+
- Fixed a rare operation-queue race by making enqueue/start atomic behind a lock; `IsBusy` is reset in a `finally` block.
17+
- Broader cancellation: operations link `destroyCancellationToken` with `Application.exitCancellationToken`.
18+
- Exception surfacing: errors are logged and rethrown so callers can observe faults when awaiting.
19+
- Logging helpers include a Unity context only when on the main thread; background-thread logs remain safe.
20+
21+
### Miscellaneous
22+
23+
- `FileHandler` keeps the public API and formatting; small internal cleanups (consistent use of computed `fullPath`, clearer errors on delete).
24+
- Updated all Samples to the `ISaveable<TState>` pattern.
25+
26+
### Migration notes
27+
28+
1. Replace ISaveable with `ISaveable<TState>` and define a serializable `TState` (struct or class).
29+
2. Update methods to `TState CaptureState()` and `void RestoreState(TState state)`.
30+
31+
332
## [0.10.1] - 2025-08-08
433
- Background threads are now disabled by default. Exceptions are not always logged properly when using background threads, causing SaveManager methods to sometimes fail silently if bad data gets passed in (such as a `NullReferenceException` inside of an ISaveable.CaptureState() method). While background threads do increase performance, this should be considered an experimental feature until it's possible to properly catch exceptions while on a background thread.
534
- Added try/catch blocks around SaveManager methods to ensure exceptions are logged for async methods.

Runtime/FileHandler.cs

Lines changed: 47 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ public class FileHandler : ScriptableObject
1414
/// Stores the persistent data path for later use, which can only be accessed on the main thread.
1515
/// </summary>
1616
protected string m_persistentDataPath;
17-
17+
1818
/// <summary>
1919
/// The suffix to append to the filename. By default, it is "_editor" when in the Unity Editor, and an empty string in builds.
2020
/// </summary>
@@ -24,15 +24,15 @@ protected virtual string FilenameSuffix
2424
#else
2525
=> string.Empty;
2626
#endif
27-
27+
2828
/// <summary>
2929
/// The file extension to use for the files. By default, it is ".dat".
3030
/// </summary>
3131
protected virtual string FileExtension => ".dat";
32-
32+
3333
protected virtual void OnEnable()
3434
=> m_persistentDataPath = Application.persistentDataPath;
35-
35+
3636
/// <summary>
3737
/// Validates that the given path or filename is safe and well-formed.
3838
/// Throws an exception if the path contains invalid characters, is absolute, or attempts directory traversal.
@@ -44,15 +44,13 @@ protected virtual void ValidatePath(string pathOrFilename)
4444
if (string.IsNullOrWhiteSpace(pathOrFilename))
4545
throw new ArgumentException("[Save Async] FileHandler.ValidatePath() - Path or filename cannot be null, empty, or whitespace.", nameof(pathOrFilename));
4646

47-
// Prevent directory traversal
4847
if (pathOrFilename.Contains("..") || Path.IsPathRooted(pathOrFilename))
4948
throw new ArgumentException("[Save Async] FileHandler.ValidatePath() - Path contains invalid characters or is absolute.", nameof(pathOrFilename));
5049

51-
// Check for invalid path characters instead of filename characters
5250
if (pathOrFilename.IndexOfAny(Path.GetInvalidPathChars()) >= 0)
5351
throw new ArgumentException("[Save Async] FileHandler.ValidatePath() - Path contains invalid characters.", nameof(pathOrFilename));
5452
}
55-
53+
5654
/// <summary>
5755
/// Returns the path to a file using the given path or filename and appends the <see cref="FilenameSuffix"/> and
5856
/// <see cref="FileExtension"/> but does not include the persistent data path.
@@ -66,15 +64,15 @@ protected virtual void ValidatePath(string pathOrFilename)
6664
protected string GetPartialPath(string pathOrFilename)
6765
{
6866
ValidatePath(pathOrFilename);
69-
67+
7068
string path = $"{pathOrFilename}{FilenameSuffix}{FileExtension}";
71-
69+
7270
if (SaveManager.SaveSlotIndex > -1)
7371
return Path.Combine($"slot{SaveManager.SaveSlotIndex}", path);
7472

7573
return path;
7674
}
77-
75+
7876
/// <summary>
7977
/// Returns the full path to a file in the persistent data path using the given path or filename.
8078
/// <code>
@@ -112,16 +110,14 @@ public virtual async Task WriteFile(string pathOrFilename, string content, Cance
112110
{
113111
string fullPath = GetFullPath(pathOrFilename);
114112

115-
// Get the directory path from the full file path
116113
string directoryPath = Path.GetDirectoryName(fullPath);
117114

118-
// Create the directory structure if it doesn't exist
119115
if (!string.IsNullOrEmpty(directoryPath))
120116
Directory.CreateDirectory(directoryPath);
121-
117+
122118
await File.WriteAllTextAsync(fullPath, content, cancellationToken).ConfigureAwait(false);
123119
}
124-
120+
125121
/// <summary>
126122
/// Writes the given content to a file at the given path or filename.
127123
/// <code>
@@ -145,44 +141,29 @@ public virtual async Task WriteFile(string pathOrFilename, string content)
145141
/// <param name="cancellationToken">The cancellation token should be the same one from the calling MonoBehaviour.</param>
146142
public virtual async Task<string> ReadFile(string pathOrFilename, CancellationToken cancellationToken)
147143
{
148-
try
149-
{
150-
string fullPath = GetFullPath(pathOrFilename);
151-
152-
// If the file does not exist, return an empty string and log a warning.
153-
if (!Exists(pathOrFilename))
154-
{
155-
Debug.LogWarning($"[Save Async] FileHandler.ReadFile() - File does not exist at path \"{fullPath}\". This may be expected if the file has not been created yet.");
156-
return string.Empty;
157-
}
158-
159-
string fileContent = await File.ReadAllTextAsync(GetFullPath(pathOrFilename), cancellationToken).ConfigureAwait(false);
160-
161-
// If the file is empty, return an empty string and log a warning.
162-
if (string.IsNullOrEmpty(fileContent))
163-
{
164-
if (SaveManager.SaveSlotIndex > -1)
165-
Debug.LogWarning($"[Save Async] FileHandler.ReadFile() - The file \"{pathOrFilename}\" in slot index {SaveManager.SaveSlotIndex} was empty. This may be expected if the file has been erased.");
166-
else
167-
Debug.LogWarning($"[Save Async] FileHandler.ReadFile() - The file \"{pathOrFilename}\" was empty. This may be expected if the file has been erased.");
168-
169-
return string.Empty;
170-
}
171-
172-
return fileContent;
173-
}
174-
catch (UnauthorizedAccessException ex)
144+
string fullPath = GetFullPath(pathOrFilename);
145+
146+
if (!File.Exists(fullPath))
175147
{
176-
Debug.LogError($"[Save Async] FileHandler.ReadFile() - Access denied to file \"{pathOrFilename}\": {ex.Message}");
148+
Debug.LogWarning($"[Save Async] FileHandler.ReadFile() - File does not exist at path \"{fullPath}\". This may be expected if the file has not been created yet.");
177149
return string.Empty;
178150
}
179-
catch (IOException ex)
151+
152+
string fileContent = await File.ReadAllTextAsync(fullPath, cancellationToken).ConfigureAwait(false);
153+
154+
if (string.IsNullOrEmpty(fileContent))
180155
{
181-
Debug.LogError($"[Save Async] FileHandler.ReadFile() - IO error reading file \"{pathOrFilename}\": {ex.Message}");
156+
if (SaveManager.SaveSlotIndex > -1)
157+
Debug.LogWarning($"[Save Async] FileHandler.ReadFile() - The file \"{pathOrFilename}\" in slot index {SaveManager.SaveSlotIndex} was empty. This may be expected if the file has been erased.");
158+
else
159+
Debug.LogWarning($"[Save Async] FileHandler.ReadFile() - The file \"{pathOrFilename}\" was empty. This may be expected if the file has been erased.");
160+
182161
return string.Empty;
183162
}
163+
164+
return fileContent;
184165
}
185-
166+
186167
/// <summary>
187168
/// Returns the contents of a file at the given path or filename.
188169
/// <code>
@@ -193,7 +174,7 @@ public virtual async Task<string> ReadFile(string pathOrFilename, CancellationTo
193174
/// <param name="pathOrFilename">The path or filename of the file to read.</param>
194175
public virtual async Task<string> ReadFile(string pathOrFilename)
195176
=> await ReadFile(pathOrFilename, CancellationToken.None);
196-
177+
197178
/// <summary>
198179
/// Erases a file at the given path or filename. The file will still exist on disk, but it will be empty.
199180
/// Use <see cref="Delete(string)"/> to remove the file from disk.
@@ -206,7 +187,7 @@ public virtual async Task<string> ReadFile(string pathOrFilename)
206187
/// <param name="cancellationToken">The cancellation token should be the same one from the calling MonoBehaviour.</param>
207188
public virtual async Task Erase(string pathOrFilename, CancellationToken cancellationToken)
208189
=> await WriteFile(pathOrFilename, string.Empty, cancellationToken);
209-
190+
210191
/// <summary>
211192
/// Erases a file at the given path or filename. The file will still exist on disk, but it will be empty.
212193
/// Use <see cref="Delete(string)"/> to remove the file from disk.
@@ -232,20 +213,32 @@ public virtual async Task Erase(string pathOrFilename)
232213
public virtual async Task Delete(string pathOrFilename, CancellationToken cancellationToken)
233214
{
234215
string fullPath = GetFullPath(pathOrFilename);
235-
if (File.Exists(fullPath))
216+
217+
if (!File.Exists(fullPath))
218+
return;
219+
220+
try
221+
{
236222
await Task.Run(() => File.Delete(fullPath), cancellationToken).ConfigureAwait(false);
223+
}
224+
catch (UnauthorizedAccessException ex)
225+
{
226+
Debug.LogError($"[Save Async] FileHandler.Delete() - Access denied to file \"{pathOrFilename}\": {ex.Message}");
227+
throw;
228+
}
229+
catch (IOException ex)
230+
{
231+
Debug.LogError($"[Save Async] FileHandler.Delete() - IO error deleting file \"{pathOrFilename}\": {ex.Message}");
232+
throw;
233+
}
237234
}
238-
235+
239236
/// <summary>
240237
/// Deletes a file at the given path or filename. This will remove the file from disk.
241238
/// Use <see cref="Erase(string, CancellationToken)"/> to fill the file with an empty string without removing it from disk.
242-
/// <code>
243-
/// File example: "MyFile"
244-
/// Path example: "MyFolder/MyFile"
245-
/// </code>
246239
/// </summary>
247240
/// <param name="pathOrFilename">The path or filename of the file to delete.</param>
248241
public virtual async Task Delete(string pathOrFilename)
249242
=> await Delete(pathOrFilename, CancellationToken.None);
250243
}
251-
}
244+
}

Runtime/ISaveable.cs

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,37 +3,39 @@
33
namespace Buck.SaveAsync
44
{
55
/// <summary>
6-
/// Allows an object to be saved and loaded via the SaveManager class.
6+
/// Allows an object to be saved and loaded via the SaveManager class using a strongly-typed state.
7+
/// Implementations should define a serializable struct or class for TState.
78
/// </summary>
8-
public interface ISaveable
9+
/// <typeparam name="TState">A serializable type representing this object's save data.</typeparam>
10+
public interface ISaveable<TState>
911
{
1012
/// <summary>
1113
/// This is a unique string used to identify the object when saving and loading.
1214
/// Often this can just be the name of the class if there is only one instance, like "EnemyManager" or "Player"
1315
/// If you choose to use a Guid, it is recommended that it is backed by a
1416
/// serialized byte array that does not change.
1517
/// </summary>
16-
public string Key { get; }
17-
18+
string Key { get; }
19+
1820
/// <summary>
1921
/// This is the file name where this object's data will be saved.
2022
/// It is recommended to use a static class to store file paths as strings to avoid typos.
2123
/// </summary>
22-
public string Filename { get; }
23-
24+
string Filename { get; }
25+
2426
/// <summary>
2527
/// This is used by the SaveManager class to capture the state of a saveable object.
2628
/// Typically this is a struct defined by the ISaveable implementing class.
2729
/// The contents of the struct could be created at the time of saving, or cached in a variable.
2830
/// </summary>
29-
object CaptureState();
30-
31+
TState CaptureState();
32+
3133
/// <summary>
3234
/// This is used by the SaveManager class to restore the state of a saveable object.
33-
/// This will be called any time the game is loaded, so you need to handle cases where the "state" parameter is a null object.
35+
/// This will be called any time the game is loaded, so you need to handle cases where the "state" parameter is null.
3436
/// In cases where the state is null, you should initialize the object to a default state.
3537
/// You may also consider using this method to initialize any fields that are not saved (i.e. "resetting the object").
3638
/// </summary>
37-
void RestoreState(object state);
39+
void RestoreState(TState state);
3840
}
39-
}
41+
}

0 commit comments

Comments
 (0)