NdsForge.NET 1.0.1
Read, validate, edit, compare, and build Nintendo DS and DSi images from .NET
Loading...
Searching...
No Matches
NdsImageEditor.cs
1using System.Buffers.Binary;
2
3namespace NdsForge;
4
6public sealed class NdsImageEditor
7{
9 private readonly NdsImage _image;
11 private readonly Dictionary<int, byte[]> _replacements = [];
13 private NdsBanner? _bannerReplacement;
15 private NdsRepairKind _repairs;
17 private ushort? _secureAreaCrc;
18
21 internal NdsImageEditor(NdsImage image)
22 {
23 _image = image;
24 Header = new(image.Header);
25 }
26
28 public NdsHeaderEdit Header { get; }
29
31 public IReadOnlyList<NdsFileChange> Changes => _replacements
32 .OrderBy(static pair => pair.Key)
33 .Select(pair => CreateChange(pair.Key, pair.Value))
34 .ToArray();
35
37 public NdsEditPlan Plan => new(Changes, Header.HasChanges, _bannerReplacement is not null, _repairs);
38
43 public NdsImageEditor ReplaceFile(string path, ReadOnlySpan<byte> contents) =>
44 ReplaceFile(_image.FileSystem.GetFile(path), contents);
45
50 public NdsImageEditor ReplaceFile(NdsFile file, ReadOnlySpan<byte> contents)
51 {
52 ArgumentNullException.ThrowIfNull(file);
53 NdsFile sourceFile = _image.FileSystem.GetFile(file.Id);
54 if (!ReferenceEquals(sourceFile, file))
55 {
56 throw new ArgumentException("The file belongs to a different image.", nameof(file));
57 }
58
59 return ReplaceAllocation(file.Id, contents);
60 }
61
66 public NdsImageEditor ReplaceAllocation(int fileId, ReadOnlySpan<byte> contents)
67 {
68 ArgumentOutOfRangeException.ThrowIfNegative(fileId);
69 if (fileId >= _image.FileSystem.Allocations.Count)
70 {
71 throw new ArgumentOutOfRangeException(nameof(fileId), "The FAT file ID does not exist.");
72 }
73
74 _replacements[fileId] = contents.ToArray();
75 return this;
76 }
77
81 public bool Revert(int fileId) => _replacements.Remove(fileId);
82
86 public NdsImageEditor ReplaceBanner(NdsBanner banner)
87 {
88 ArgumentNullException.ThrowIfNull(banner);
89 _bannerReplacement = banner;
90 return this;
91 }
92
95 public NdsImageEditor RepairHeaderCrc()
96 {
97 _repairs |= NdsRepairKind.HeaderCrc;
98 return this;
99 }
100
103 public NdsImageEditor RepairNintendoLogoCrc()
104 {
105 _repairs |= NdsRepairKind.NintendoLogoCrc | NdsRepairKind.HeaderCrc;
106 return this;
107 }
108
112 public NdsImageEditor RepairBannerCrcs()
113 {
114 NdsBanner banner = _bannerReplacement ?? _image.Banner ??
115 throw new InvalidOperationException("The image has no banner checksum fields to repair.");
116 _bannerReplacement = banner.WithRepairedCrcs();
117 _repairs |= NdsRepairKind.BannerCrcs;
118 return this;
119 }
120
128 public NdsImageEditor RepairSecureAreaCrc(NdsKey1KeyTable keyTable)
129 {
130 ArgumentNullException.ThrowIfNull(keyTable);
131 NdsSecureAreaInspection inspection = NdsSecureArea.Inspect(_image, keyTable);
132 if (!inspection.IsTransformable || inspection.CalculatedCrc is not ushort calculated)
133 {
134 throw new InvalidOperationException($"Secure-area state {inspection.State} cannot produce a verified CRC repair.");
135 }
136
137 _secureAreaCrc = calculated;
138 _repairs |= NdsRepairKind.SecureAreaCrc | NdsRepairKind.HeaderCrc;
139 return this;
140 }
141
147 public async ValueTask<NdsSaveResult> SaveAsync(
148 string path,
149 NdsWriteOptions? options = null,
150 CancellationToken cancellationToken = default)
151 {
152 ArgumentException.ThrowIfNullOrWhiteSpace(path);
153 options ??= NdsWriteOptions.Default;
154 options.Validate();
155 string output = Path.GetFullPath(path);
156 string? directory = Path.GetDirectoryName(output);
157 if (!string.IsNullOrEmpty(directory))
158 {
159 Directory.CreateDirectory(directory);
160 }
161
162 if (File.Exists(output) && !options.OverwriteDestination)
163 {
164 throw new IOException($"Destination already exists: {output}");
165 }
166
167 string temporary = output + ".ndsforge-" + Guid.NewGuid().ToString("N");
168 try
169 {
170 NdsSaveResult result;
171 var stream = new FileStream(
172 temporary,
173 FileMode.CreateNew,
174 FileAccess.ReadWrite,
175 FileShare.None,
176 64 * 1024,
177 FileOptions.Asynchronous | FileOptions.SequentialScan);
178 await using (stream.ConfigureAwait(false))
179 {
180 result = await SaveAsync(stream, options, cancellationToken).ConfigureAwait(false);
181 }
182
183 File.Move(temporary, output, options.OverwriteDestination);
184 return result;
185 }
186 finally
187 {
188 File.Delete(temporary);
189 }
190 }
191
197 public async ValueTask<NdsSaveResult> SaveAsync(
198 Stream destination,
199 NdsWriteOptions? options = null,
200 CancellationToken cancellationToken = default)
201 {
202 ArgumentNullException.ThrowIfNull(destination);
203 if (!destination.CanRead || !destination.CanWrite || !destination.CanSeek)
204 {
205 throw new ArgumentException("The destination stream must be readable, writable, and seekable.", nameof(destination));
206 }
207
208 options ??= NdsWriteOptions.Default;
209 options.Validate();
210 destination.Position = 0;
211 destination.SetLength(0);
212 using (Stream source = _image.OpenRead(new(0, _image.Length)))
213 {
214 await source.CopyToAsync(destination, cancellationToken).ConfigureAwait(false);
215 }
216
217 var allocations = _image.FileSystem.Allocations
218 .Select(static allocation => allocation.Data)
219 .ToArray();
220 long usedSize = Math.Max(
221 _image.Header.UsedImageSize,
222 allocations.Length == 0 ? 0 : allocations.Max(static allocation => allocation.End));
223 int relocated = 0;
224 foreach ((int fileId, byte[] contents) in _replacements.OrderBy(static pair => pair.Key))
225 {
226 NdsRegion original = allocations[fileId];
227 long offset = original.Offset;
228 if (contents.LongLength > original.Length)
229 {
230 offset = Align(usedSize, options.RelocatedFileAlignment);
231 await FillGapAsync(destination, offset, options.PaddingByte, cancellationToken).ConfigureAwait(false);
232 usedSize = checked(offset + contents.LongLength);
233 relocated++;
234 }
235
236 destination.Position = offset;
237 await destination.WriteAsync(contents, cancellationToken).ConfigureAwait(false);
238 allocations[fileId] = new(offset, contents.LongLength);
239 }
240
241 uint bannerOffset = _image.Header.BannerOffset;
242 if (_bannerReplacement is not null)
243 {
244 long originalLength = _image.Banner?.RawData.Length ?? 0;
245 long offset = bannerOffset;
246 if (bannerOffset == 0 || _bannerReplacement.RawData.Length > originalLength)
247 {
248 offset = Align(usedSize, options.RelocatedFileAlignment);
249 await FillGapAsync(destination, offset, options.PaddingByte, cancellationToken).ConfigureAwait(false);
250 }
251
252 destination.Position = offset;
253 await destination.WriteAsync(_bannerReplacement.RawData, cancellationToken).ConfigureAwait(false);
254 usedSize = Math.Max(usedSize, offset + _bannerReplacement.RawData.Length);
255 bannerOffset = checked((uint)offset);
256 }
257
258 usedSize = Math.Max(usedSize, _image.Header.UsedImageSize);
259 long physicalSize = Math.Max(_image.Length, usedSize);
260 destination.SetLength(physicalSize);
261 await WriteMetadataAsync(destination, allocations, usedSize, bannerOffset, cancellationToken).ConfigureAwait(false);
262 await destination.FlushAsync(cancellationToken).ConfigureAwait(false);
263
264 if (options.VerifyOutput)
265 {
266 await VerifyAsync(destination, cancellationToken).ConfigureAwait(false);
267 }
268
269 destination.Position = physicalSize;
270 return new(_replacements.Count, relocated, usedSize, physicalSize);
271 }
272
277 private NdsFileChange CreateChange(int fileId, byte[] replacement)
278 {
279 NdsFileAllocation allocation = _image.FileSystem.Allocations[fileId];
280 _image.FileSystem.TryGetFile(fileId, out NdsFile? file);
281 return new(
282 fileId,
283 file?.FullPath,
284 allocation.Data.Length,
285 replacement.LongLength,
286 replacement.LongLength > allocation.Data.Length);
287 }
288
295 private async ValueTask WriteMetadataAsync(
296 Stream destination,
297 NdsRegion[] allocations,
298 long usedSize,
299 uint bannerOffset,
300 CancellationToken cancellationToken)
301 {
302 if (usedSize > uint.MaxValue)
303 {
304 throw new InvalidDataException("The rebuilt image exceeds the Nintendo DS 32-bit address space.");
305 }
306
307 byte[] fat = new byte[checked(allocations.Length * 8)];
308 for (int fileId = 0; fileId < allocations.Length; fileId++)
309 {
310 NdsRegion allocation = allocations[fileId];
311 BinaryPrimitives.WriteUInt32LittleEndian(fat.AsSpan(fileId * 8), checked((uint)allocation.Offset));
312 BinaryPrimitives.WriteUInt32LittleEndian(fat.AsSpan((fileId * 8) + 4), checked((uint)allocation.End));
313 }
314
315 destination.Position = _image.Header.FileAllocationTable.Offset;
316 await destination.WriteAsync(fat, cancellationToken).ConfigureAwait(false);
317 byte[] header = _image.Header.RawData.ToArray();
318 Header.Apply(header);
319 BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x68), bannerOffset);
320 BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x80), checked((uint)usedSize));
321 if (_secureAreaCrc is ushort secureAreaCrc)
322 {
323 BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(0x6C), secureAreaCrc);
324 }
325
326 if ((_repairs & NdsRepairKind.NintendoLogoCrc) != 0)
327 {
328 BinaryPrimitives.WriteUInt16LittleEndian(
329 header.AsSpan(0x15C),
330 NdsChecksums.ComputeCrc16(header.AsSpan(0xC0, 156)));
331 }
332
333 byte capacity = CalculateDeviceCapacity(usedSize, _image.Header.DeviceCapacityExponent);
334 header[0x14] = capacity;
335 bool commonHeaderChanged = Header.HasChanges ||
336 bannerOffset != _image.Header.BannerOffset ||
337 usedSize != _image.Header.UsedImageSize ||
338 capacity != _image.Header.DeviceCapacityExponent ||
339 (_repairs & (NdsRepairKind.HeaderCrc | NdsRepairKind.NintendoLogoCrc | NdsRepairKind.SecureAreaCrc)) != 0;
340 if (commonHeaderChanged)
341 {
342 BinaryPrimitives.WriteUInt16LittleEndian(
343 header.AsSpan(0x15E),
344 NdsChecksums.ComputeCrc16(header.AsSpan(0, 0x15E)));
345 }
346 destination.Position = 0;
347 await destination.WriteAsync(header, cancellationToken).ConfigureAwait(false);
348 }
349
353 private async ValueTask VerifyAsync(Stream destination, CancellationToken cancellationToken)
354 {
355 destination.Position = 0;
356 using NdsImage output = await NdsImage.OpenAsync(
357 destination,
358 leaveOpen: true,
359 cancellationToken: cancellationToken).ConfigureAwait(false);
360 NdsValidationResult validation = output.Validate();
361 if (!validation.IsValid)
362 {
363 throw new InvalidDataException(
364 $"Output verification failed: {string.Join("; ", validation.Diagnostics.Select(static value => value.Message))}");
365 }
366
367 foreach ((int fileId, byte[] expected) in _replacements)
368 {
369 NdsRegion region = output.FileSystem.Allocations[fileId].Data;
370 using Stream actual = output.OpenRead(region);
371 byte[] observed = new byte[expected.Length];
372 await actual.ReadExactlyAsync(observed, cancellationToken).ConfigureAwait(false);
373 if (!observed.AsSpan().SequenceEqual(expected))
374 {
375 throw new InvalidDataException($"Output verification failed for FAT file ID {fileId}.");
376 }
377 }
378
379 if (_bannerReplacement is not null &&
380 (output.Banner is null || !output.Banner.RawData.Span.SequenceEqual(_bannerReplacement.RawData.Span)))
381 {
382 throw new InvalidDataException("Output verification failed for the banner.");
383 }
384 }
385
391 private static async ValueTask FillGapAsync(
392 Stream destination,
393 long targetOffset,
394 byte paddingByte,
395 CancellationToken cancellationToken)
396 {
397 if (targetOffset <= destination.Length)
398 {
399 return;
400 }
401
402 destination.Position = destination.Length;
403 byte[] buffer = new byte[64 * 1024];
404 buffer.AsSpan().Fill(paddingByte);
405 long remaining = targetOffset - destination.Length;
406 while (remaining > 0)
407 {
408 int count = (int)Math.Min(buffer.Length, remaining);
409 await destination.WriteAsync(buffer.AsMemory(0, count), cancellationToken).ConfigureAwait(false);
410 remaining -= count;
411 }
412 }
413
418 private static byte CalculateDeviceCapacity(long usedSize, byte original)
419 {
420 byte exponent = original;
421 long capacity = 128L * 1024L << exponent;
422 while (capacity < usedSize && exponent < 31)
423 {
424 exponent++;
425 capacity = 128L * 1024L << exponent;
426 }
427
428 return exponent;
429 }
430
435 private static long Align(long value, int alignment) =>
436 checked((value + alignment - 1) & -alignment);
437}
Represents a versioned menu icon, localized titles, and optional DSi animation.
Definition NdsBanner.cs:9
NdsBanner WithRepairedCrcs()
Returns a byte-preserving banner copy with only the version-defined CRC fields recalculated....
Definition NdsBanner.cs:196
Describes semantic preservation edits before any destination is created or truncated.
Connects a byte-preserving FNT path and stable FAT identifier to lazily read cartridge bytes.
Definition NdsFile.cs:5
int Id
Indexes the FAT allocation and is also the identifier referenced by overlay table entries.
Definition NdsFile.cs:33
Collects validated mutable identity and card-control header fields.
NdsImageEditor ReplaceFile(string path, ReadOnlySpan< byte > contents)
Replaces a named NitroFS file.
async ValueTask< NdsSaveResult > SaveAsync(string path, NdsWriteOptions? options=null, CancellationToken cancellationToken=default)
Saves to a new or atomically replaced filesystem path.
NdsEditPlan Plan
Snapshots all pending semantic changes and named repairs for review before a destination is opened.
async ValueTask< NdsSaveResult > SaveAsync(Stream destination, NdsWriteOptions? options=null, CancellationToken cancellationToken=default)
Saves to a distinct caller-owned readable, writable, seekable stream.
NdsImageEditor ReplaceBanner(NdsBanner banner)
Replaces or adds the menu banner.
NdsImageEditor RepairSecureAreaCrc(NdsKey1KeyTable keyTable)
Selects secure-area CRC repair after the explicit KEY1 table proves whether source bytes are encrypte...
NdsImageEditor RepairBannerCrcs()
Replaces the current banner with a copy whose version-defined CRC slots are repaired in place.
NdsImageEditor RepairNintendoLogoCrc()
Selects the dedicated logo checksum and the dependent common header checksum for repair.
NdsImageEditor ReplaceFile(NdsFile file, ReadOnlySpan< byte > contents)
Replaces a named NitroFS file.
IReadOnlyList< NdsFileChange > Changes
Gets pending changes in ascending FAT file-ID order.
bool Revert(int fileId)
Removes a pending replacement while leaving the source allocation unchanged.
NdsHeaderEdit Header
Gets editable identity and card-control header fields.
NdsImageEditor ReplaceAllocation(int fileId, ReadOnlySpan< byte > contents)
Replaces any FAT allocation, including an unnamed overlay payload.
NdsImageEditor RepairHeaderCrc()
Selects only the common header checksum for repair; other damaged checksums remain untouched.
Provides structured, random-access inspection of a Nintendo DS-family image.
Definition NdsImage.cs:5
NdsHeader Header
Preserves both typed DS/DSi fields and the raw bytes required for checksums and lossless edits.
Definition NdsImage.cs:35
Holds the 18 round words and four 256-word substitution boxes consumed by the DS KEY1 algorithm....
Identifies a bounded range of bytes in an image.
Definition NdsRegion.cs:11
Reports secure-area presence, encryption state, and checksum evidence without modifying image bytes.
bool IsTransformable
Indicates that the interval can be passed to the matching encrypt or decrypt operation.
Provides pure inspection and KEY1 transformations for the conventional 0x4000-0x7FFF cartridge interv...
static NdsSecureAreaInspection Inspect(NdsImage image, NdsKey1KeyTable? keyTable=null)
Inspects a loaded image and verifies encrypted state or CRC only when enough explicit key material ex...
Controls preservation-oriented image saves.
static NdsWriteOptions Default
Uses 512-byte relocation alignment, 0xFF padding, verification, and no implicit path overwrite.
NdsRepairKind
Names independently requested checksum repairs so an edit plan never hides a broad implicit fix opera...
@ SecureAreaCrc
Recalculates the encrypted-representation secure-area checksum stored at header offset 0x6C.
@ NintendoLogoCrc
Recalculates the dedicated Nintendo-logo checksum stored at header offset 0x15C.