6public sealed class NdsImageEditor
11 private readonly Dictionary<int, byte[]> _replacements = [];
17 private ushort? _secureAreaCrc;
21 internal NdsImageEditor(
NdsImage image)
31 public IReadOnlyList<NdsFileChange>
Changes => _replacements
32 .OrderBy(
static pair => pair.Key)
33 .Select(pair => CreateChange(pair.Key, pair.Value))
43 public NdsImageEditor
ReplaceFile(
string path, ReadOnlySpan<byte> contents) =>
44 ReplaceFile(_image.FileSystem.GetFile(path), contents);
52 ArgumentNullException.ThrowIfNull(file);
53 NdsFile sourceFile = _image.FileSystem.GetFile(file.
Id);
54 if (!ReferenceEquals(sourceFile, file))
56 throw new ArgumentException(
"The file belongs to a different image.", nameof(file));
68 ArgumentOutOfRangeException.ThrowIfNegative(fileId);
69 if (fileId >= _image.FileSystem.Allocations.Count)
71 throw new ArgumentOutOfRangeException(nameof(fileId),
"The FAT file ID does not exist.");
74 _replacements[fileId] = contents.ToArray();
81 public bool Revert(
int fileId) => _replacements.Remove(fileId);
88 ArgumentNullException.ThrowIfNull(banner);
89 _bannerReplacement = banner;
114 NdsBanner banner = _bannerReplacement ?? _image.Banner ??
115 throw new InvalidOperationException(
"The image has no banner checksum fields to repair.");
130 ArgumentNullException.ThrowIfNull(keyTable);
132 if (!inspection.
IsTransformable || inspection.CalculatedCrc is not ushort calculated)
134 throw new InvalidOperationException($
"Secure-area state {inspection.State} cannot produce a verified CRC repair.");
137 _secureAreaCrc = calculated;
150 CancellationToken cancellationToken =
default)
152 ArgumentException.ThrowIfNullOrWhiteSpace(path);
155 string output = Path.GetFullPath(path);
156 string? directory = Path.GetDirectoryName(output);
157 if (!
string.IsNullOrEmpty(directory))
159 Directory.CreateDirectory(directory);
162 if (File.Exists(output) && !options.OverwriteDestination)
164 throw new IOException($
"Destination already exists: {output}");
167 string temporary = output +
".ndsforge-" + Guid.NewGuid().ToString(
"N");
170 NdsSaveResult result;
171 var stream =
new FileStream(
174 FileAccess.ReadWrite,
177 FileOptions.Asynchronous | FileOptions.SequentialScan);
178 await
using (stream.ConfigureAwait(
false))
180 result = await
SaveAsync(stream, options, cancellationToken).ConfigureAwait(
false);
183 File.Move(temporary, output, options.OverwriteDestination);
188 File.Delete(temporary);
200 CancellationToken cancellationToken =
default)
202 ArgumentNullException.ThrowIfNull(destination);
203 if (!destination.CanRead || !destination.CanWrite || !destination.CanSeek)
205 throw new ArgumentException(
"The destination stream must be readable, writable, and seekable.", nameof(destination));
210 destination.Position = 0;
211 destination.SetLength(0);
212 using (Stream source = _image.OpenRead(
new(0, _image.Length)))
214 await source.CopyToAsync(destination, cancellationToken).ConfigureAwait(
false);
217 var allocations = _image.FileSystem.Allocations
218 .Select(
static allocation => allocation.Data)
220 long usedSize = Math.Max(
221 _image.Header.UsedImageSize,
222 allocations.Length == 0 ? 0 : allocations.Max(
static allocation => allocation.End));
224 foreach ((
int fileId,
byte[] contents) in _replacements.OrderBy(
static pair => pair.Key))
226 NdsRegion original = allocations[fileId];
227 long offset = original.Offset;
228 if (contents.LongLength > original.Length)
230 offset = Align(usedSize, options.RelocatedFileAlignment);
231 await FillGapAsync(destination, offset, options.PaddingByte, cancellationToken).ConfigureAwait(
false);
232 usedSize = checked(offset + contents.LongLength);
236 destination.Position = offset;
237 await destination.WriteAsync(contents, cancellationToken).ConfigureAwait(
false);
238 allocations[fileId] =
new(offset, contents.LongLength);
241 uint bannerOffset = _image.Header.BannerOffset;
242 if (_bannerReplacement is not
null)
244 long originalLength = _image.Banner?.RawData.Length ?? 0;
245 long offset = bannerOffset;
246 if (bannerOffset == 0 || _bannerReplacement.RawData.Length > originalLength)
248 offset = Align(usedSize, options.RelocatedFileAlignment);
249 await FillGapAsync(destination, offset, options.PaddingByte, cancellationToken).ConfigureAwait(
false);
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);
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);
264 if (options.VerifyOutput)
266 await VerifyAsync(destination, cancellationToken).ConfigureAwait(
false);
269 destination.Position = physicalSize;
270 return new(_replacements.Count, relocated, usedSize, physicalSize);
277 private NdsFileChange CreateChange(
int fileId,
byte[] replacement)
279 NdsFileAllocation allocation = _image.FileSystem.Allocations[fileId];
280 _image.FileSystem.TryGetFile(fileId, out
NdsFile? file);
284 allocation.Data.Length,
285 replacement.LongLength,
286 replacement.LongLength > allocation.Data.Length);
295 private async ValueTask WriteMetadataAsync(
297 NdsRegion[] allocations,
300 CancellationToken cancellationToken)
302 if (usedSize > uint.MaxValue)
304 throw new InvalidDataException(
"The rebuilt image exceeds the Nintendo DS 32-bit address space.");
307 byte[] fat =
new byte[checked(allocations.Length * 8)];
308 for (
int fileId = 0; fileId < allocations.Length; fileId++)
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));
315 destination.Position = _image.Header.FileAllocationTable.Offset;
316 await destination.WriteAsync(fat, cancellationToken).ConfigureAwait(
false);
317 byte[] header = _image.Header.RawData.ToArray();
319 BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x68), bannerOffset);
320 BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x80), checked((uint)usedSize));
321 if (_secureAreaCrc is ushort secureAreaCrc)
323 BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(0x6C), secureAreaCrc);
328 BinaryPrimitives.WriteUInt16LittleEndian(
329 header.AsSpan(0x15C),
330 NdsChecksums.ComputeCrc16(header.AsSpan(0xC0, 156)));
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)
342 BinaryPrimitives.WriteUInt16LittleEndian(
343 header.AsSpan(0x15E),
344 NdsChecksums.ComputeCrc16(header.AsSpan(0, 0x15E)));
346 destination.Position = 0;
347 await destination.WriteAsync(header, cancellationToken).ConfigureAwait(
false);
353 private async ValueTask VerifyAsync(Stream destination, CancellationToken cancellationToken)
355 destination.Position = 0;
356 using NdsImage output = await NdsImage.OpenAsync(
359 cancellationToken: cancellationToken).ConfigureAwait(
false);
360 NdsValidationResult validation = output.Validate();
361 if (!validation.IsValid)
363 throw new InvalidDataException(
364 $
"Output verification failed: {string.Join(";
", validation.Diagnostics.Select(static value => value.Message))}");
367 foreach ((
int fileId,
byte[] expected) in _replacements)
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))
375 throw new InvalidDataException($
"Output verification failed for FAT file ID {fileId}.");
379 if (_bannerReplacement is not
null &&
380 (output.Banner is
null || !output.Banner.RawData.Span.SequenceEqual(_bannerReplacement.RawData.Span)))
382 throw new InvalidDataException(
"Output verification failed for the banner.");
391 private static async ValueTask FillGapAsync(
395 CancellationToken cancellationToken)
397 if (targetOffset <= destination.Length)
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)
408 int count = (int)Math.Min(buffer.Length, remaining);
409 await destination.WriteAsync(buffer.AsMemory(0, count), cancellationToken).ConfigureAwait(
false);
418 private static byte CalculateDeviceCapacity(
long usedSize,
byte original)
420 byte exponent = original;
421 long capacity = 128L * 1024L << exponent;
422 while (capacity < usedSize && exponent < 31)
425 capacity = 128L * 1024L << exponent;
435 private static long Align(
long value,
int alignment) =>
436 checked((value + alignment - 1) & -alignment);