NdsForge.NET 1.0.1
Read, validate, edit, compare, and build Nintendo DS and DSi images from .NET
Loading...
Searching...
No Matches
NdsFileSystemBuilder.cs
1using System.Collections.ObjectModel;
2namespace NdsForge;
3
13public sealed class NdsFileSystemBuilder
14{
19 private readonly SortedSet<string> _directories = new(StringComparer.Ordinal) { "/" };
20
24 private readonly Dictionary<string, NdsBuildFile> _files = new(StringComparer.Ordinal);
25
30 public IReadOnlyCollection<string> Directories => new ReadOnlyCollection<string>(_directories.ToArray());
31
39 public IReadOnlyCollection<NdsBuildFile> Files => new ReadOnlyCollection<NdsBuildFile>(
40 _files.Values.OrderBy(static file => file.Path, StringComparer.Ordinal).ToArray());
41
51 public async ValueTask<NdsDirectoryImportResult> ImportDirectoryAsync(
52 string sourceDirectory,
53 string destinationDirectory = "/",
54 NdsDirectoryImportOptions? options = null,
55 CancellationToken cancellationToken = default)
56 {
57 ArgumentException.ThrowIfNullOrWhiteSpace(sourceDirectory);
58 string destination = NormalizePath(destinationDirectory, allowRoot: true);
60 options.Validate();
61 NdsHostDirectorySnapshot snapshot = await NdsHostDirectoryImporter.StageAsync(
62 sourceDirectory,
63 destination,
64 options,
65 cancellationToken).ConfigureAwait(false);
66 return ApplyImport(snapshot, options.CollisionPolicy);
67 }
68
73 public NdsBuildFile GetFile(string path)
74 {
75 string normalized = NormalizePath(path, allowRoot: false);
76 return _files.TryGetValue(normalized, out NdsBuildFile? file)
77 ? file
78 : throw new FileNotFoundException($"NitroFS file was not found: {normalized}", normalized);
79 }
80
91 {
92 string normalized = NormalizePath(path, allowRoot: true);
93 EnsureParents(normalized);
94 _directories.Add(normalized);
95 return this;
96 }
97
108 public NdsFileSystemBuilder AddFile(string path, ReadOnlySpan<byte> contents)
109 {
110 string normalized = NormalizePath(path, allowRoot: false);
111 if (_files.ContainsKey(normalized) || _directories.Contains(normalized))
112 {
113 throw new IOException($"NitroFS entry already exists: {normalized}");
114 }
115
116 string parent = GetParent(normalized);
117 EnsureParents(parent);
118 _directories.Add(parent);
119 _files.Add(normalized, new(normalized, contents.ToArray()));
120 return this;
121 }
122
130 public NdsFileSystemBuilder SetFile(string path, ReadOnlySpan<byte> contents)
131 {
132 string normalized = NormalizePath(path, allowRoot: false);
133 if (_directories.Contains(normalized))
134 {
135 throw new IOException($"A directory already exists at {normalized}.");
136 }
137
138 string parent = GetParent(normalized);
139 EnsureParents(parent);
140 _directories.Add(parent);
141 _files[normalized] = new(normalized, contents.ToArray());
142 return this;
143 }
144
152 {
153 string normalized = NormalizePath(path, allowRoot: false);
154 if (!_files.Remove(normalized))
155 {
156 throw new FileNotFoundException($"NitroFS file was not found: {normalized}", normalized);
157 }
158
159 return this;
160 }
161
170 public NdsFileSystemBuilder MoveFile(string sourcePath, string destinationPath)
171 {
172 string source = NormalizePath(sourcePath, allowRoot: false);
173 string destination = NormalizePath(destinationPath, allowRoot: false);
174 if (!_files.TryGetValue(source, out NdsBuildFile? file))
175 {
176 throw new FileNotFoundException($"NitroFS file was not found: {source}", source);
177 }
178
179 if (_files.ContainsKey(destination) || _directories.Contains(destination))
180 {
181 throw new IOException($"NitroFS entry already exists: {destination}");
182 }
183
184 string parent = GetParent(destination);
185 EnsureParents(parent);
186 _directories.Add(parent);
187 _files.Remove(source);
188 file.Path = destination;
189 _files.Add(destination, file);
190 return this;
191 }
192
201 public NdsFileSystemBuilder MoveDirectory(string sourcePath, string destinationPath)
202 {
203 string source = NormalizePath(sourcePath, allowRoot: false);
204 string destination = NormalizePath(destinationPath, allowRoot: false);
205 if (!_directories.Contains(source))
206 {
207 throw new DirectoryNotFoundException($"NitroFS directory was not found: {source}");
208 }
209
210 if (destination.StartsWith(source + "/", StringComparison.Ordinal))
211 {
212 throw new IOException("A directory cannot be moved into its own subtree.");
213 }
214
215 if (_directories.Contains(destination) || _files.ContainsKey(destination))
216 {
217 throw new IOException($"NitroFS entry already exists: {destination}");
218 }
219
220 EnsureParents(GetParent(destination));
221 string[] affectedDirectories = _directories
222 .Where(path => path == source || path.StartsWith(source + "/", StringComparison.Ordinal))
223 .ToArray();
224 NdsBuildFile[] affectedFiles = _files.Values
225 .Where(file => file.Path.StartsWith(source + "/", StringComparison.Ordinal))
226 .ToArray();
227 foreach (string directory in affectedDirectories)
228 {
229 _directories.Remove(directory);
230 }
231
232 foreach (NdsBuildFile file in affectedFiles)
233 {
234 _files.Remove(file.Path);
235 }
236
237 foreach (string directory in affectedDirectories)
238 {
239 _directories.Add(destination + directory[source.Length..]);
240 }
241
242 foreach (NdsBuildFile file in affectedFiles)
243 {
244 file.Path = destination + file.Path[source.Length..];
245 _files.Add(file.Path, file);
246 }
247
248 return this;
249 }
250
259 {
260 string normalized = NormalizePath(path, allowRoot: false);
261 if (!_directories.Contains(normalized))
262 {
263 throw new DirectoryNotFoundException($"NitroFS directory was not found: {normalized}");
264 }
265
266 string prefix = normalized + "/";
267 if (_directories.Any(value => value.StartsWith(prefix, StringComparison.Ordinal)) ||
268 _files.Keys.Any(value => value.StartsWith(prefix, StringComparison.Ordinal)))
269 {
270 throw new IOException($"NitroFS directory is not empty: {normalized}");
271 }
272
273 _directories.Remove(normalized);
274 return this;
275 }
276
283 internal NdsFileSystemBuildSnapshot BuildSnapshot(int firstFileId = 0)
284 {
285 string[] directories = _directories.Order(StringComparer.Ordinal).ToArray();
286 if (directories.Length > 4096 || firstFileId < 0 || firstFileId + _files.Count > ushort.MaxValue + 1)
287 {
288 throw new InvalidDataException("NitroFS exceeds its 12-bit directory or 16-bit file-ID space.");
289 }
290
291 return NdsFileNameTableWriter.Write(directories, _files.Values.ToArray(), firstFileId);
292 }
293
298 private NdsDirectoryImportResult ApplyImport(
299 NdsHostDirectorySnapshot snapshot,
300 NdsFileCollisionPolicy collisionPolicy)
301 {
302 foreach (string directory in snapshot.Directories)
303 {
304 if (_files.ContainsKey(directory) ||
305 _files.Keys.Any(file => directory.StartsWith(file + "/", StringComparison.Ordinal)))
306 {
307 throw new IOException($"A file blocks imported directory path {directory}.");
308 }
309 }
310
311 foreach (NdsHostFileSnapshot file in snapshot.Files)
312 {
313 if (_directories.Contains(file.Path) ||
314 _files.Keys.Any(existing => file.Path.StartsWith(existing + "/", StringComparison.Ordinal)))
315 {
316 throw new IOException($"A directory or parent file conflicts with imported payload {file.Path}.");
317 }
318
319 if (collisionPolicy == NdsFileCollisionPolicy.Fail && _files.ContainsKey(file.Path))
320 {
321 throw new IOException($"A NitroFS file already exists at imported path {file.Path}.");
322 }
323 }
324
325 int directoryCount = snapshot.Directories.Count(directory => !_directories.Contains(directory));
326 foreach (string directory in snapshot.Directories)
327 {
328 CreateDirectory(directory);
329 }
330
331 int fileCount = 0;
332 int skipped = snapshot.SkippedLinks;
333 long bytes = 0;
334 foreach (NdsHostFileSnapshot file in snapshot.Files)
335 {
336 if (_files.ContainsKey(file.Path) && collisionPolicy == NdsFileCollisionPolicy.KeepExisting)
337 {
338 skipped++;
339 continue;
340 }
341
342 SetFile(file.Path, file.Contents);
343 fileCount++;
344 bytes = checked(bytes + file.Contents.LongLength);
345 }
346
347 return new(fileCount, directoryCount, bytes, skipped);
348 }
349
355 private void EnsureParents(string path)
356 {
357 if (path == "/")
358 {
359 return;
360 }
361
362 if (_files.ContainsKey(path))
363 {
364 throw new IOException($"A file already occupies required directory path {path}.");
365 }
366
367 string parent = GetParent(path);
368 EnsureParents(parent);
369 _directories.Add(parent);
370 }
371
379 internal static string NormalizePath(string path, bool allowRoot)
380 {
381 ArgumentException.ThrowIfNullOrWhiteSpace(path);
382 string normalized = path.Replace('\\', '/');
383 if (!normalized.StartsWith('/'))
384 {
385 normalized = "/" + normalized;
386 }
387
388 if ((!allowRoot && normalized == "/") ||
389 (normalized.Length > 1 && normalized.EndsWith('/')) ||
390 normalized.Contains("//", StringComparison.Ordinal))
391 {
392 throw new ArgumentException("NitroFS path is empty or ambiguous.", nameof(path));
393 }
394
395 foreach (string segment in normalized.Split('/', StringSplitOptions.RemoveEmptyEntries))
396 {
397 if (segment is "." or ".." || segment.Length > 127 ||
398 segment.Any(static value => value > 0xFF || value is '/' or '\\'))
399 {
400 throw new ArgumentException("NitroFS path contains a name that cannot be represented as one-byte FNT data.", nameof(path));
401 }
402 }
403
404 return normalized;
405 }
406
410 private static string GetParent(string path)
411 {
412 int separator = path.LastIndexOf('/');
413 return separator == 0 ? "/" : path[..separator];
414 }
415}
Holds one NitroFS payload while an image is being assembled, independently of any source ROM.
string Path
Identifies the file in NitroFS using a leading slash and one-byte path segments; Latin-1 code points ...
Bounds host-directory materialization and makes merge and link behavior explicit.
static NdsDirectoryImportOptions Default
Returns a fresh conservative policy so one caller cannot mutate another import's defaults.
Models structural NitroFS changes before ROM offsets and file identifiers are assigned.
async ValueTask< NdsDirectoryImportResult > ImportDirectoryAsync(string sourceDirectory, string destinationDirectory="/", NdsDirectoryImportOptions? options=null, CancellationToken cancellationToken=default)
Stages and transactionally merges a host directory into this NitroFS recipe. Calling the method repea...
NdsFileSystemBuilder RemoveFile(string path)
Removes a payload while leaving its parent directories available for later files or empty output.
IReadOnlyCollection< string > Directories
Provides a stable view of every directory that will appear in the FNT, including empty ones.
NdsBuildFile GetFile(string path)
Resolves a builder-owned payload so other recipe components can retain its identity across path moves...
NdsFileSystemBuilder RemoveDirectory(string path)
Omits an explicitly declared directory after proving that no descendants would become orphaned.
IReadOnlyCollection< NdsBuildFile > Files
Provides a path-sorted snapshot of payloads currently destined for the image.
NdsFileSystemBuilder MoveDirectory(string sourcePath, string destinationPath)
Re-roots an entire directory subtree while preserving every payload byte and relative child path.
NdsFileSystemBuilder AddFile(string path, ReadOnlySpan< byte > contents)
Adds a payload that must not already exist, creating its parent directories as needed.
NdsFileSystemBuilder SetFile(string path, ReadOnlySpan< byte > contents)
Defines the payload at a path, replacing an existing file while preserving directory validity.
NdsFileSystemBuilder CreateDirectory(string path)
Declares a directory, retaining it even when no descendant files are added.
NdsFileSystemBuilder MoveFile(string sourcePath, string destinationPath)
Changes a file's NitroFS identity without copying or transforming its payload.
NdsFileCollisionPolicy
Controls how a host-directory import handles a file path already present in the NitroFS recipe.