From f0896c2c78da364a88fe0067b932525ac9ca01c9 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:32:45 +0200 Subject: [PATCH 1/9] feat: write .sds.patch files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine consults a patch provider for every archive it streams and, on a hit, applies a delta during that archive's own load: dropping base resources, rebuilding others from a binary delta, and appending new ones. Only the read side existed, and only natively. Adds the write side, managed and self-contained: SdsBlockStream the UEzl chunk stream, read and write. The terminator is five bytes, not four — a reader consumes length and flag together and stops on a zero length, which is why an empty stream is fourteen bytes. SdsPatchFile the container. Sorts the index lists on write, so callers need not. SdsPatchBuilder resolves ordinals against a base archive and refuses the edits known to break the engine. Replacement is expressed as skip-plus-append rather than as a binary delta, which needs no delta encoder and is one of the two forms the game's own patches use. Two failure modes are encoded as guards rather than left as comments, because both were found by crashing the game: - An empty patch faults the engine well after the load, in the nav graph. Rockstar ships one (prazdna.sds.patch, 42 bytes) but never loads it. Validate() refuses to write one. - Deleting every Texture while leaving the Mipmap entries behind faults the loader on a null resource manager. This is not derivable from the type table: Parent there is a load-order tier, and Mipmap carries Parent = 0 while still depending on Texture. Reading stops short of walking records when a patch carries deltas. A delta record's header size is the size of the reconstructed resource, not the bytes present — italy_z declares 902,664 inside a 497,153-byte payload — and the delta stream self-terminates, so records cannot be walked without decoding an encoding that is not modelled here. Patches written by this code never contain deltas and always round-trip. Headless entry points: --build-patch and --dump-patch. Verified against the 71 city patches shipped in Joe's Adventures, and in-game: a generated 46-byte patch deleting one district's collision resource drops the player through Little Italy, and is byte-identical to the hand-built patch that first proved it. --- .../Archive/SdsBlockStream.cs | 154 +++++++++++ .../Archive/SdsPatchBuilder.cs | 142 ++++++++++ src/Illusion.Formats/Archive/SdsPatchFile.cs | 259 ++++++++++++++++++ src/Illusion/Diagnostics/ProbeRunner.cs | 9 + .../Diagnostics/Probes/PatchProbes.cs | 139 ++++++++++ 5 files changed, 703 insertions(+) create mode 100644 src/Illusion.Formats/Archive/SdsBlockStream.cs create mode 100644 src/Illusion.Formats/Archive/SdsPatchBuilder.cs create mode 100644 src/Illusion.Formats/Archive/SdsPatchFile.cs create mode 100644 src/Illusion/Diagnostics/Probes/PatchProbes.cs diff --git a/src/Illusion.Formats/Archive/SdsBlockStream.cs b/src/Illusion.Formats/Archive/SdsBlockStream.cs new file mode 100644 index 0000000..3630471 --- /dev/null +++ b/src/Illusion.Formats/Archive/SdsBlockStream.cs @@ -0,0 +1,154 @@ +using System.IO.Compression; +using Illusion.Formats.IO; + +namespace Illusion.Formats.Archive; + +/// +/// The UEzl block stream that carries an archive's (or a patch's) resource payloads: a short +/// header, then length-prefixed chunks that are individually zlib-deflated, then a terminator. +/// +/// +/// Layout, little-endian throughout: +/// +/// 'UEzl' u32 signature +/// chunkSize u32 0x4000 in every archive seen +/// version u8 4 +/// per chunk: +/// blockLength u32 payload bytes that follow (compressed: 32-byte header + deflated data) +/// compressed u8 1 = zlib, 0 = stored +/// [compressed only] 32-byte chunk header, then the deflated bytes +/// [stored only] the raw bytes +/// terminator u32 0 +/// u8 0 +/// +/// The terminator is five bytes, not four: a reader consumes length and flag together and stops on a +/// zero length, so the trailing flag byte is part of the stream. An empty stream is therefore exactly +/// fourteen bytes, which is what Mafia II's own no-op patch contains. +/// +public static class SdsBlockStream +{ + /// Stream signature, 'UEzl' read little-endian. + public const uint Signature = 0x6C7A4555; + + /// Uncompressed bytes per chunk. + public const int ChunkSize = 0x4000; + + private const byte StreamVersion = 4; + private const int CompressedChunkHeaderSize = 32; + + /// An empty block stream — the payload of a patch that carries no resources. + public static byte[] Empty + { + get + { + using var buffer = new MemoryStream(); + Write(buffer, ReadOnlySpan.Empty); + return buffer.ToArray(); + } + } + + /// Writes as a block stream. + public static void Write(Stream output, ReadOnlySpan payload) + { + ArgumentNullException.ThrowIfNull(output); + + output.Write(Signature, bigEndian: false); + output.Write((uint)ChunkSize, bigEndian: false); + output.WriteByte(StreamVersion); + + for (var offset = 0; offset < payload.Length; offset += ChunkSize) + { + var chunk = payload.Slice(offset, Math.Min(ChunkSize, payload.Length - offset)); + var deflated = Deflate(chunk); + + // A chunk that does not get smaller is stored verbatim; the engine reads both forms. + if (deflated.Length + CompressedChunkHeaderSize < chunk.Length) + { + output.Write((uint)(deflated.Length + CompressedChunkHeaderSize), bigEndian: false); + output.WriteByte(1); + WriteCompressedChunkHeader(output, chunk.Length, deflated.Length); + output.Write(deflated, 0, deflated.Length); + } + else + { + output.Write((uint)chunk.Length, bigEndian: false); + output.WriteByte(0); + output.Write(chunk); + } + } + + output.Write(0u, bigEndian: false); + output.WriteByte(0); + } + + /// Reads a block stream written by or shipped by the game. + public static byte[] Read(Stream input) + { + ArgumentNullException.ThrowIfNull(input); + + if (input.ReadUInt32(bigEndian: false) != Signature) + { + throw new InvalidDataException("Not a UEzl block stream."); + } + + input.ReadUInt32(bigEndian: false); + input.ReadByte(); + + using var payload = new MemoryStream(); + while (true) + { + var blockLength = input.ReadUInt32(bigEndian: false); + var compressed = input.ReadByte(); + if (blockLength == 0) + { + break; + } + + if (compressed == 1) + { + var header = new byte[CompressedChunkHeaderSize]; + input.ReadExactly(header); + + var deflated = new byte[blockLength - CompressedChunkHeaderSize]; + input.ReadExactly(deflated); + + using var source = new MemoryStream(deflated); + using var inflater = new ZLibStream(source, CompressionMode.Decompress); + inflater.CopyTo(payload); + } + else + { + var stored = new byte[blockLength]; + input.ReadExactly(stored); + payload.Write(stored); + } + } + + return payload.ToArray(); + } + + private static byte[] Deflate(ReadOnlySpan chunk) + { + using var buffer = new MemoryStream(); + using (var deflater = new ZLibStream(buffer, CompressionLevel.SmallestSize, leaveOpen: true)) + { + deflater.Write(chunk); + } + + return buffer.ToArray(); + } + + // Constant apart from the two lengths in every archive and patch inspected; the engine reads the + // raw length from it and ignores the rest, but it is reproduced so output matches shipped files. + private static void WriteCompressedChunkHeader(Stream output, int rawLength, int deflatedLength) + { + output.Write((uint)rawLength, bigEndian: false); + output.Write(0x20u, bigEndian: false); + output.Write(0x14000u, bigEndian: false); + output.Write((ushort)0x0001, bigEndian: false); + output.WriteByte(0x0F); + output.WriteByte(0x08); + output.Write((uint)deflatedLength, bigEndian: false); + output.Write(new byte[12]); + } +} diff --git a/src/Illusion.Formats/Archive/SdsPatchBuilder.cs b/src/Illusion.Formats/Archive/SdsPatchBuilder.cs new file mode 100644 index 0000000..869e51e --- /dev/null +++ b/src/Illusion.Formats/Archive/SdsPatchBuilder.cs @@ -0,0 +1,142 @@ +namespace Illusion.Formats.Archive; + +/// +/// Builds a against a base archive, resolving ordinals and refusing the +/// edits that are known to break the engine. +/// +/// +/// Replacement is expressed as skip-plus-append rather than as a binary delta: the base resource is +/// dropped and its successor appended. That needs no delta encoder, and it is one of the two forms +/// Rockstar's own patches use. +/// +public sealed class SdsPatchBuilder +{ + /// + /// Resource kinds that resolve another kind at load time, and so cannot outlive it. + /// Deleting every Texture of a district while leaving its Mipmap entries behind + /// faults the loader on a null resource manager, well after the archive itself has been read. + /// + /// + /// This is not derivable from the archive's own type table: the Parent column there is a + /// load-order tier, and Mipmap carries Parent = 0 while still depending on + /// Texture. + /// + private static readonly Dictionary DependsOn = new(StringComparer.Ordinal) + { + ["Mipmap"] = "Texture", + }; + + private readonly SdsArchive _baseArchive; + private readonly SortedSet _skipped = new(); + private readonly List _appended = new(); + + /// Starts a patch against . + public SdsPatchBuilder(SdsArchive baseArchive) + { + ArgumentNullException.ThrowIfNull(baseArchive); + _baseArchive = baseArchive; + } + + /// Ordinals of every resource of , in file order. + public IReadOnlyList OrdinalsOfType(string typeName) + { + var ordinals = new List(); + for (var i = 0; i < _baseArchive.Entries.Count; i++) + { + if (string.Equals(TypeNameOf(i), typeName, StringComparison.Ordinal)) + { + ordinals.Add(i); + } + } + + return ordinals; + } + + /// Drops one base resource. + public SdsPatchBuilder Delete(int ordinal) + { + if (ordinal < 0 || ordinal >= _baseArchive.Entries.Count) + { + throw new ArgumentOutOfRangeException( + nameof(ordinal), + ordinal, + $"The base archive has {_baseArchive.Entries.Count} resources."); + } + + _skipped.Add(ordinal); + return this; + } + + /// Drops every base resource of a type. + public SdsPatchBuilder DeleteType(string typeName) + { + foreach (var ordinal in OrdinalsOfType(typeName)) + { + Delete(ordinal); + } + + return this; + } + + /// Drops a base resource and appends its successor. + public SdsPatchBuilder Replace(int ordinal, ResourceEntry replacement) + { + ArgumentNullException.ThrowIfNull(replacement); + Delete(ordinal); + _appended.Add(replacement); + return this; + } + + /// Appends a resource the base archive does not have. + public SdsPatchBuilder Append(ResourceEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + _appended.Add(entry); + return this; + } + + /// + /// Produces the patch. Throws when it would carry no changes, or when a delete would strand a + /// resource that depends on what is being removed. + /// + public SdsPatchFile Build() + { + GuardStrandedDependents(); + + var patch = new SdsPatchFile(); + patch.SkippedEntryIndices.AddRange(_skipped); + patch.Entries.AddRange(_appended); + patch.Validate(); + return patch; + } + + private void GuardStrandedDependents() + { + foreach (var (dependent, required) in DependsOn) + { + var requiredOrdinals = OrdinalsOfType(required); + if (requiredOrdinals.Count == 0 || requiredOrdinals.Any(ordinal => !_skipped.Contains(ordinal))) + { + continue; + } + + var survivors = OrdinalsOfType(dependent).Count(ordinal => !_skipped.Contains(ordinal)); + if (survivors > 0) + { + throw new InvalidOperationException( + $"Deleting every '{required}' resource would strand {survivors} '{dependent}' " + + $"resource(s) that resolve one at load time, and the engine faults on the missing " + + $"manager. Delete '{dependent}' as well, or keep at least one '{required}'."); + } + } + } + + // TypeId indexes the type table positionally, the same way the archive's own extract path reads it. + private string TypeNameOf(int ordinal) + { + var typeId = _baseArchive.Entries[ordinal].TypeId; + return typeId >= 0 && typeId < _baseArchive.ResourceTypes.Count + ? _baseArchive.ResourceTypes[typeId].Name + : string.Empty; + } +} diff --git a/src/Illusion.Formats/Archive/SdsPatchFile.cs b/src/Illusion.Formats/Archive/SdsPatchFile.cs new file mode 100644 index 0000000..efddcbf --- /dev/null +++ b/src/Illusion.Formats/Archive/SdsPatchFile.cs @@ -0,0 +1,259 @@ +using Illusion.Formats.Hashing; +using Illusion.Formats.IO; + +namespace Illusion.Formats.Archive; + +/// +/// A Mafia II .sds.patch: a delta applied to one base .sds as the engine streams it. +/// +/// +/// +/// The engine consults a patch provider for every archive it loads and, on a hit, applies the patch +/// during the archive's own load. Three operations are expressed, all keyed by 0-based ordinals +/// into the base archive's resource-entry list, in file order: +/// +/// +/// — the base resource is dropped before a +/// resource manager is even resolved, so it never reaches a slot or a scene. +/// — the base resource's body is rebuilt from an +/// old/new binary delta carried in the payload. Version 2 only. +/// — resources appended to the archive, indistinguishable +/// downstream from ones the archive shipped with. +/// +/// +/// Replacement can also be expressed without a delta encoder: skip the base ordinal and append the +/// successor. Rockstar's own district patches do both — italy_z skips three nav resources and +/// delta-replaces four more. +/// +/// +public sealed class SdsPatchFile +{ + /// File signature. + public const uint Signature = 0x0D010F0F; + + /// Second signature, immediately after the version. + public const uint Marker = 0xF0F0010D; + + /// Bytes of a resource record that precede its payload: a 26-byte header and a checksum. + private const int ResourceHeaderSize = 30; + + /// Format version. Every patch shipped with the game is 2; the delta list needs 2. + public uint Version { get; set; } = 2; + + /// + /// Types introduced by the patch, for resources whose type the base archive does not declare. + /// A type id the base already declares is silently discarded by the engine, so this cannot be + /// used to redefine one. Empty in every patch the game ships. + /// + public List ResourceTypes { get; } = new(); + + /// Base-archive ordinals to drop. Sorted ascending on write. + public List SkippedEntryIndices { get; } = new(); + + /// Base-archive ordinals to rebuild from a binary delta. Sorted ascending on write. + public List DeltaEntryIndices { get; } = new(); + + /// Resources carried by the patch: appended resources, and the bodies for the delta list. + /// + /// Populated on read only when is empty — see . + /// + public List Entries { get; } = new(); + + /// + /// The decompressed payload exactly as it was read, and the only usable form when the patch + /// carries binary deltas. + /// + /// + /// A delta record's header size is the size of the reconstructed resource, not the + /// bytes the patch actually holds — Rockstar's italy_z patch declares 902,664 for a record + /// inside a 497,153-byte payload. The delta stream self-terminates, so records cannot be walked + /// without decoding it, and that encoding is not modelled here. Patches this class writes never + /// contain deltas, so they always round-trip. + /// + public byte[] Payload { get; private set; } = []; + + /// Resource count declared in the header, whether or not the records were parsed. + public uint DeclaredResourceCount { get; private set; } + + /// Reads a patch. The byte-flipped console form is refused, as it is on the read side of the toolkit. + public static SdsPatchFile Load(Stream input) + { + ArgumentNullException.ThrowIfNull(input); + + var patch = new SdsPatchFile(); + + var signature = input.ReadUInt32(bigEndian: false); + FormatAssert.Ensure(signature == Signature, "Not an SDS patch: signature 0x{0:X8}.", signature); + + patch.Version = input.ReadUInt32(bigEndian: false); + + var marker = input.ReadUInt32(bigEndian: false); + FormatAssert.Ensure(marker == Marker, "SDS patch marker 0x{0:X8} is wrong.", marker); + + var typeCount = input.ReadUInt32(bigEndian: false); + for (var i = 0u; i < typeCount; i++) + { + var id = input.ReadUInt32(bigEndian: false); + var nameLength = input.ReadUInt32(bigEndian: false); + var name = input.ReadString(nameLength); + var parent = input.ReadUInt32(bigEndian: false); + patch.ResourceTypes.Add(new SdsResourceTypeEntry { Id = id, Name = name, Parent = parent }); + } + + ReadIndexList(input, patch.SkippedEntryIndices); + if (patch.Version >= 2) + { + ReadIndexList(input, patch.DeltaEntryIndices); + } + + patch.DeclaredResourceCount = input.ReadUInt32(bigEndian: false); + patch.Payload = SdsBlockStream.Read(input); + + if (patch.DeltaEntryIndices.Count == 0) + { + ReadResources(patch.Payload, patch.DeclaredResourceCount, patch.Entries); + } + + return patch; + } + + /// Writes the patch. Index lists are sorted here, so callers need not pre-sort them. + public void Save(Stream output) + { + ArgumentNullException.ThrowIfNull(output); + Validate(); + + var skipped = SkippedEntryIndices.Order().ToList(); + var delta = DeltaEntryIndices.Order().ToList(); + + output.Write(Signature, bigEndian: false); + output.Write(Version, bigEndian: false); + output.Write(Marker, bigEndian: false); + + output.Write((uint)ResourceTypes.Count, bigEndian: false); + foreach (var type in ResourceTypes) + { + output.Write(type.Id, bigEndian: false); + output.Write((uint)EndianStreamExtensions.DefaultEncoding.GetByteCount(type.Name), bigEndian: false); + output.WriteString(type.Name); + output.Write(type.Parent, bigEndian: false); + } + + WriteIndexList(output, skipped); + if (Version >= 2) + { + WriteIndexList(output, delta); + } + + output.Write((uint)Entries.Count, bigEndian: false); + SdsBlockStream.Write(output, BuildResourcePayload()); + } + + /// + /// Throws when the patch would be rejected or would misbehave in the engine. The empty case is + /// the important one: a patch with nothing in it crashes the game, some distance from the load. + /// + public void Validate() + { + if (SkippedEntryIndices.Count == 0 && DeltaEntryIndices.Count == 0 && Entries.Count == 0) + { + throw new InvalidOperationException( + "An empty patch crashes the engine — write no patch when a diff produces no changes."); + } + + if (Version < 2 && DeltaEntryIndices.Count > 0) + { + throw new InvalidOperationException("Binary-delta replacement needs version 2."); + } + + var overlap = SkippedEntryIndices.Intersect(DeltaEntryIndices).Order().ToList(); + if (overlap.Count > 0) + { + throw new InvalidOperationException( + $"Ordinals appear in both the skip and delta lists: {string.Join(", ", overlap)}."); + } + + var negative = SkippedEntryIndices.Concat(DeltaEntryIndices).Any(index => index < 0); + if (negative) + { + throw new InvalidOperationException("Resource ordinals cannot be negative."); + } + + if (SkippedEntryIndices.Distinct().Count() != SkippedEntryIndices.Count || + DeltaEntryIndices.Distinct().Count() != DeltaEntryIndices.Count) + { + throw new InvalidOperationException("Resource ordinals must be unique within a list."); + } + } + + private byte[] BuildResourcePayload() + { + using var payload = new MemoryStream(); + foreach (var entry in Entries) + { + var data = entry.Data ?? []; + + var header = new byte[26]; + using (var headerStream = new MemoryStream(header)) + { + headerStream.Write((uint)entry.TypeId, bigEndian: false); + headerStream.Write((uint)(data.Length + ResourceHeaderSize), bigEndian: false); + headerStream.Write(entry.Version, bigEndian: false); + headerStream.Write(entry.SlotRamRequired, bigEndian: false); + headerStream.Write(entry.SlotVramRequired, bigEndian: false); + headerStream.Write(entry.OtherRamRequired, bigEndian: false); + headerStream.Write(entry.OtherVramRequired, bigEndian: false); + } + + payload.Write(header); + payload.Write(Fnv32.Hash(header, 0, header.Length), bigEndian: false); + payload.Write(data); + } + + return payload.ToArray(); + } + + private static void ReadResources(byte[] payload, uint count, List entries) + { + using var source = new MemoryStream(payload); + for (var i = 0u; i < count; i++) + { + var entry = new ResourceEntry + { + TypeId = (int)source.ReadUInt32(bigEndian: false), + }; + + var size = source.ReadUInt32(bigEndian: false); + entry.Version = source.ReadUInt16(bigEndian: false); + entry.SlotRamRequired = source.ReadUInt32(bigEndian: false); + entry.SlotVramRequired = source.ReadUInt32(bigEndian: false); + entry.OtherRamRequired = source.ReadUInt32(bigEndian: false); + entry.OtherVramRequired = source.ReadUInt32(bigEndian: false); + + // The per-resource checksum is validated by neither the engine nor the toolkit's reader. + source.ReadUInt32(bigEndian: false); + + entry.Data = new byte[size - ResourceHeaderSize]; + source.ReadExactly(entry.Data); + entries.Add(entry); + } + } + + private static void ReadIndexList(Stream input, List target) + { + var count = input.ReadUInt32(bigEndian: false); + for (var i = 0u; i < count; i++) + { + target.Add(input.ReadInt32(bigEndian: false)); + } + } + + private static void WriteIndexList(Stream output, List values) + { + output.Write((uint)values.Count, bigEndian: false); + foreach (var value in values) + { + output.Write(value, bigEndian: false); + } + } +} diff --git a/src/Illusion/Diagnostics/ProbeRunner.cs b/src/Illusion/Diagnostics/ProbeRunner.cs index 1aac7a5..0f47676 100644 --- a/src/Illusion/Diagnostics/ProbeRunner.cs +++ b/src/Illusion/Diagnostics/ProbeRunner.cs @@ -19,6 +19,15 @@ public static bool TryRun(string[] args) switch (args[0]) { + // Author a .sds.patch headlessly: + // Illusion.exe --build-patch [--delete-type ]... [--delete ]... + case "--build-patch": + PatchProbes.RunBuildPatch(args); + return true; + // Report what a .sds.patch does, without applying it. + case "--dump-patch": + PatchProbes.RunDumpPatch(args); + return true; // SDS read chain: Illusion.exe --probe-sds [path.sds] case "--probe-sds": ArchiveProbes.RunSdsProbe(args.Length >= 2 ? args[1] : null); diff --git a/src/Illusion/Diagnostics/Probes/PatchProbes.cs b/src/Illusion/Diagnostics/Probes/PatchProbes.cs new file mode 100644 index 0000000..28d98c3 --- /dev/null +++ b/src/Illusion/Diagnostics/Probes/PatchProbes.cs @@ -0,0 +1,139 @@ +using System.IO; +using System.Text; +using Illusion.Formats.Archive; + +namespace Illusion.Diagnostics.Probes; + +/// +/// Headless .sds.patch authoring and inspection, so a patch can be produced and checked +/// without the editor: Illusion.exe --build-patch and --dump-patch. +/// +internal static class PatchProbes +{ + /// + /// --build-patch <base.sds> <out.sds.patch> [--delete-type Name]... [--delete <ordinal>]... + /// + public static void RunBuildPatch(string[] args) + { + if (args.Length < 3) + { + Report("usage: --build-patch [--delete-type ]... [--delete ]..."); + return; + } + + string basePath = args[1]; + string outputPath = args[2]; + + if (!File.Exists(basePath)) + { + Report($"base archive not found: {basePath}"); + return; + } + + SdsArchive archive = SdsArchive.Open(basePath); + var builder = new SdsPatchBuilder(archive); + var log = new StringBuilder(); + + log.AppendLine($"base : {basePath}"); + log.AppendLine($"resources: {archive.Entries.Count}"); + + for (int i = 3; i < args.Length; i++) + { + switch (args[i]) + { + case "--delete-type" when i + 1 < args.Length: + { + string typeName = args[++i]; + var ordinals = builder.OrdinalsOfType(typeName); + if (ordinals.Count == 0) + { + Report($"no '{typeName}' resources in {Path.GetFileName(basePath)}"); + return; + } + + builder.DeleteType(typeName); + log.AppendLine($"delete : {ordinals.Count} x {typeName} (ordinals {ordinals[0]}..{ordinals[^1]})"); + break; + } + + case "--delete" when i + 1 < args.Length && int.TryParse(args[++i], out int ordinal): + { + builder.Delete(ordinal); + log.AppendLine($"delete : ordinal {ordinal}"); + break; + } + + default: + Report($"unrecognised argument: {args[i]}"); + return; + } + } + + SdsPatchFile patch; + try + { + patch = builder.Build(); + } + catch (InvalidOperationException ex) + { + Report($"refused: {ex.Message}"); + return; + } + + using (var output = File.Create(outputPath)) + { + patch.Save(output); + } + + // Read the file back so what is reported is what landed on disk, not what was intended. + using var written = File.OpenRead(outputPath); + SdsPatchFile reloaded = SdsPatchFile.Load(written); + + log.AppendLine($"output : {outputPath} ({new FileInfo(outputPath).Length} bytes)"); + log.AppendLine($"verified: version {reloaded.Version}, {reloaded.SkippedEntryIndices.Count} skipped, " + + $"{reloaded.DeltaEntryIndices.Count} delta, {reloaded.Entries.Count} carried"); + + Report(log.ToString().TrimEnd()); + } + + /// --dump-patch <file.sds.patch> — what a patch does, without applying it. + public static void RunDumpPatch(string[] args) + { + if (args.Length < 2 || !File.Exists(args[1])) + { + Report("usage: --dump-patch "); + return; + } + + using var input = File.OpenRead(args[1]); + SdsPatchFile patch = SdsPatchFile.Load(input); + + var log = new StringBuilder(); + log.AppendLine($"patch : {args[1]} ({new FileInfo(args[1]).Length} bytes)"); + log.AppendLine($"version : {patch.Version}"); + log.AppendLine($"types : {patch.ResourceTypes.Count}"); + log.AppendLine($"skipped : {patch.SkippedEntryIndices.Count} {Format(patch.SkippedEntryIndices)}"); + log.AppendLine($"delta : {patch.DeltaEntryIndices.Count} {Format(patch.DeltaEntryIndices)}"); + log.AppendLine($"carried : {patch.DeclaredResourceCount} declared, {patch.Payload.Length} payload bytes"); + if (patch.DeltaEntryIndices.Count > 0) + { + log.AppendLine(" records not walked: this patch carries binary deltas"); + } + + foreach (var entry in patch.Entries.Take(16)) + { + log.AppendLine($" type {entry.TypeId}, version {entry.Version}, {entry.Data?.Length ?? 0} bytes"); + } + + Report(log.ToString().TrimEnd()); + } + + private static string Format(List ordinals) => + ordinals.Count == 0 ? "" : "[" + string.Join(", ", ordinals.Take(24)) + (ordinals.Count > 24 ? ", ..." : "") + "]"; + + private static void Report(string text) + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_patch.txt"); + File.WriteAllText(outFile, text); + } +} From 5293e033c69c5d3dfb3f12998cb88a0da49dd822 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:38:46 +0200 Subject: [PATCH 2/9] feat: remove world objects and emit the patch that does it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The patch container could express whole-resource operations; this is the operation a map editor actually performs — take named objects out of a district and ship the difference. ScenePatchAuthor parses the archive's FrameResource and Collisions, deletes the selected frames (children follow their parent), drops the collision placements that belong to them, re-serialises both, and expresses the result as skip-plus-append: the base ordinals are dropped and the edited resources carried. Render and collision are separate worlds in this engine — a frame holds the geometry, the district's Collisions resource holds the physics placements — so deleting only the frame leaves an invisible wall. Both are edited together, which is why removal has to be a scene-level operation rather than a resource-level one. Collision placements are matched to a removed frame by hash first and then by proximity, because an instance carries its mesh hash rather than the frame's name hash. The radius is a parameter; the result reports what each pass removed rather than assuming it worked. Selectors are a frame name, a trailing-'*' family prefix, or an 0x name hash for frames that carry no name. Headless: --list-frames, --remove-frames. On italy_z: 445 frames and 491 collision placements removed, into a 2.2 MB patch carrying the edited FrameResource and Collisions. --- .../Archive/ScenePatchAuthor.cs | 260 ++++++++++++++++++ src/Illusion/Diagnostics/ProbeRunner.cs | 9 + .../Diagnostics/Probes/PatchProbes.cs | 119 ++++++++ 3 files changed, 388 insertions(+) create mode 100644 src/Illusion.Formats/Archive/ScenePatchAuthor.cs diff --git a/src/Illusion.Formats/Archive/ScenePatchAuthor.cs b/src/Illusion.Formats/Archive/ScenePatchAuthor.cs new file mode 100644 index 0000000..169d5ce --- /dev/null +++ b/src/Illusion.Formats/Archive/ScenePatchAuthor.cs @@ -0,0 +1,260 @@ +using System.Globalization; +using System.Numerics; +using Illusion.Formats.Collisions; +using Illusion.Formats.Frames; +using Illusion.Formats.Frames.ObjectTypes; + +namespace Illusion.Formats.Archive; + +/// What a removal actually did, so a caller can report it rather than assume it. +/// Frames matched by the requested selectors. +/// Frames removed, including children pulled down with a parent. +/// Collision placements removed alongside them. +/// Selectors that matched nothing. +public readonly record struct RemovalResult( + int MatchedFrames, + int DeletedFrames, + int DeletedCollisionInstances, + IReadOnlyList Unmatched); + +/// +/// Removes world objects from a district and expresses the result as a : +/// the archive's edited FrameResource and Collisions replace the originals, and every +/// other resource is left untouched. +/// +/// +/// +/// Render and collision are separate worlds in this engine. A frame carries the geometry; the +/// district's Collisions resource carries the physics placements. Deleting only the frame +/// leaves an invisible wall, so both are edited together. +/// +/// +/// Replacement is expressed as skip-plus-append — the base resource is dropped by ordinal and the +/// edited one appended — which needs no binary-delta encoder. +/// +/// +public sealed class ScenePatchAuthor +{ + private readonly SdsArchive _archive; + private readonly int _frameResourceOrdinal; + private readonly int _collisionsOrdinal; + + private FrameResource? _frames; + private CollisionFile? _collisions; + private bool _framesEdited; + private bool _collisionsEdited; + + /// Opens for editing. + /// The archive carries no FrameResource. + public ScenePatchAuthor(SdsArchive archive) + { + ArgumentNullException.ThrowIfNull(archive); + _archive = archive; + + _frameResourceOrdinal = OrdinalOfType("FrameResource"); + _collisionsOrdinal = OrdinalOfType("Collisions"); + + if (_frameResourceOrdinal < 0) + { + throw new InvalidOperationException("The archive has no FrameResource, so it holds no scene to edit."); + } + } + + /// Every named frame in the scene, for discovery before choosing what to remove. + public IReadOnlyList FrameNames() + { + var names = new List(); + foreach (var frame in Frames().FrameObjects.Values.OfType()) + { + if (!string.IsNullOrEmpty(frame.Name.String)) + { + names.Add(frame.Name.String); + } + } + + names.Sort(StringComparer.OrdinalIgnoreCase); + return names; + } + + /// + /// Removes the frames named by and the collision placements that go + /// with them. A selector is a frame name, or an 0x-prefixed FNV-1/64 name hash for a frame + /// that carries no name. + /// + /// + /// How far from a removed frame a collision placement is taken to belong to it. Placements are + /// matched by hash first; this catches the ones that carry a mesh hash instead of a name hash. + /// Zero disables positional matching. + /// + public RemovalResult RemoveFrames(IEnumerable selectors, float collisionRadius = 5.0f) + { + ArgumentNullException.ThrowIfNull(selectors); + + var frames = Frames(); + var unmatched = new List(); + var targets = new List(); + + foreach (var selector in selectors) + { + var matches = Match(frames, selector).ToList(); + if (matches.Count == 0) + { + unmatched.Add(selector); + continue; + } + + targets.AddRange(matches); + } + + // Positions and hashes have to be read before deletion, while the frames still resolve. + var removedHashes = new HashSet(); + var removedPositions = new List(); + foreach (var frame in targets) + { + removedHashes.Add(frame.Name.Hash); + removedPositions.Add(frame.WorldTransform.Translation); + } + + var before = frames.FrameObjects.Count; + foreach (var frame in targets) + { + // A parent may already have taken this one down with it. + if (frames.FrameObjects.ContainsKey(frame.RefID)) + { + frames.DeleteFrame(frame); + } + } + + var deletedFrames = before - frames.FrameObjects.Count; + if (deletedFrames > 0) + { + _framesEdited = true; + } + + var deletedCollisions = RemoveCollisions(removedHashes, removedPositions, collisionRadius); + + return new RemovalResult(targets.Count, deletedFrames, deletedCollisions, unmatched); + } + + /// Builds the patch. Throws when nothing was removed. + public SdsPatchFile Build() + { + if (!_framesEdited && !_collisionsEdited) + { + throw new InvalidOperationException( + "Nothing was removed — write no patch rather than an empty one, which crashes the engine."); + } + + var patch = new SdsPatchFile(); + + if (_framesEdited) + { + patch.SkippedEntryIndices.Add(_frameResourceOrdinal); + patch.Entries.Add(Rebuild(_frameResourceOrdinal, Frames().WriteToStream())); + } + + if (_collisionsEdited) + { + patch.SkippedEntryIndices.Add(_collisionsOrdinal); + patch.Entries.Add(Rebuild(_collisionsOrdinal, Collisions()!.ToBytes())); + } + + patch.Validate(); + return patch; + } + + private int RemoveCollisions(HashSet hashes, List positions, float radius) + { + var collisions = Collisions(); + if (collisions is null) + { + return 0; + } + + var radiusSquared = radius * radius; + var removed = collisions.Instances.RemoveAll(instance => + hashes.Contains(instance.Hash) || + (radius > 0.0f && positions.Any(p => Vector3.DistanceSquared(p, instance.Position) <= radiusSquared))); + + if (removed > 0) + { + _collisionsEdited = true; + } + + return removed; + } + + private static IEnumerable Match(FrameResource frames, string selector) + { + var all = frames.FrameObjects.Values.OfType(); + + if (selector.StartsWith("0x", StringComparison.OrdinalIgnoreCase) && + ulong.TryParse(selector.AsSpan(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hash)) + { + return all.Where(frame => frame.Name.Hash == hash); + } + + // A trailing '*' selects a family — districts name their parts by prefix. + if (selector.EndsWith('*')) + { + var prefix = selector[..^1]; + return all.Where(frame => frame.Name.String.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + } + + return all.Where(frame => string.Equals(frame.Name.String, selector, StringComparison.OrdinalIgnoreCase)); + } + + // The edited resource keeps the base entry's type, version and budget accounting; only the bytes change. + private ResourceEntry Rebuild(int ordinal, byte[] data) + { + var source = _archive.Entries[ordinal]; + return new ResourceEntry + { + TypeId = source.TypeId, + Version = source.Version, + SlotRamRequired = source.SlotRamRequired, + SlotVramRequired = source.SlotVramRequired, + OtherRamRequired = source.OtherRamRequired, + OtherVramRequired = source.OtherVramRequired, + Data = data, + }; + } + + private FrameResource Frames() + { + if (_frames is null) + { + _frames = new FrameResource(); + using var source = new MemoryStream(_archive.Entries[_frameResourceOrdinal].Data ?? []); + _frames.ReadFromFile(source); + } + + return _frames; + } + + private CollisionFile? Collisions() + { + if (_collisions is null && _collisionsOrdinal >= 0) + { + using var source = new MemoryStream(_archive.Entries[_collisionsOrdinal].Data ?? []); + _collisions = CollisionFile.Read(source); + } + + return _collisions; + } + + private int OrdinalOfType(string typeName) + { + for (var i = 0; i < _archive.Entries.Count; i++) + { + var typeId = _archive.Entries[i].TypeId; + if (typeId >= 0 && typeId < _archive.ResourceTypes.Count && + string.Equals(_archive.ResourceTypes[typeId].Name, typeName, StringComparison.Ordinal)) + { + return i; + } + } + + return -1; + } +} diff --git a/src/Illusion/Diagnostics/ProbeRunner.cs b/src/Illusion/Diagnostics/ProbeRunner.cs index 0f47676..66367a5 100644 --- a/src/Illusion/Diagnostics/ProbeRunner.cs +++ b/src/Illusion/Diagnostics/ProbeRunner.cs @@ -24,6 +24,15 @@ public static bool TryRun(string[] args) case "--build-patch": PatchProbes.RunBuildPatch(args); return true; + // Named frames of a district's scene: Illusion.exe --list-frames [filter] + case "--list-frames": + PatchProbes.RunListFrames(args); + return true; + // Remove world objects and emit the patch that does it: + // Illusion.exe --remove-frames --frame ... + case "--remove-frames": + PatchProbes.RunRemoveFrames(args); + return true; // Report what a .sds.patch does, without applying it. case "--dump-patch": PatchProbes.RunDumpPatch(args); diff --git a/src/Illusion/Diagnostics/Probes/PatchProbes.cs b/src/Illusion/Diagnostics/Probes/PatchProbes.cs index 28d98c3..0d601fa 100644 --- a/src/Illusion/Diagnostics/Probes/PatchProbes.cs +++ b/src/Illusion/Diagnostics/Probes/PatchProbes.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.IO; using System.Text; using Illusion.Formats.Archive; @@ -128,6 +129,124 @@ public static void RunDumpPatch(string[] args) Report(log.ToString().TrimEnd()); } + + /// --list-frames <base.sds> [filter] — named frames in a district's scene. + public static void RunListFrames(string[] args) + { + if (args.Length < 2 || !File.Exists(args[1])) + { + Report("usage: --list-frames [filter]"); + return; + } + + SdsArchive archive = SdsArchive.Open(args[1]); + var author = new ScenePatchAuthor(archive); + var names = author.FrameNames(); + string? filter = args.Length >= 3 ? args[2] : null; + + var shown = filter is null + ? names + : names.Where(n => n.Contains(filter, StringComparison.OrdinalIgnoreCase)).ToList(); + + var log = new StringBuilder(); + log.AppendLine($"archive : {args[1]}"); + log.AppendLine($"frames : {names.Count} named" + (filter is null ? "" : $", {shown.Count} matching '{filter}'")); + foreach (string name in shown) + { + log.AppendLine(" " + name); + } + + Report(log.ToString().TrimEnd()); + } + + /// + /// --remove-frames <base.sds> <out.sds.patch> --frame <name|0xhash>... [--collision-radius <r>] + /// + public static void RunRemoveFrames(string[] args) + { + if (args.Length < 5) + { + Report("usage: --remove-frames --frame ... [--collision-radius ]"); + return; + } + + string basePath = args[1]; + string outputPath = args[2]; + + if (!File.Exists(basePath)) + { + Report($"base archive not found: {basePath}"); + return; + } + + var selectors = new List(); + float radius = 5.0f; + + for (int i = 3; i < args.Length; i++) + { + if (args[i] == "--frame" && i + 1 < args.Length) + { + selectors.Add(args[++i]); + } + else if (args[i] == "--collision-radius" && i + 1 < args.Length + && float.TryParse(args[++i], NumberStyles.Float, CultureInfo.InvariantCulture, out float parsed)) + { + radius = parsed; + } + else + { + Report($"unrecognised argument: {args[i]}"); + return; + } + } + + if (selectors.Count == 0) + { + Report("no --frame selectors given"); + return; + } + + var log = new StringBuilder(); + try + { + SdsArchive archive = SdsArchive.Open(basePath); + var author = new ScenePatchAuthor(archive); + RemovalResult result = author.RemoveFrames(selectors, radius); + + log.AppendLine($"base : {basePath}"); + log.AppendLine($"selectors: {selectors.Count}, matched {result.MatchedFrames}"); + log.AppendLine($"frames : {result.DeletedFrames} removed (children included)"); + log.AppendLine($"collision: {result.DeletedCollisionInstances} placements removed (radius {radius})"); + foreach (string missed in result.Unmatched) + { + log.AppendLine($" no match: {missed}"); + } + + SdsPatchFile patch = author.Build(); + using (var output = File.Create(outputPath)) + { + patch.Save(output); + } + + using var written = File.OpenRead(outputPath); + SdsPatchFile reloaded = SdsPatchFile.Load(written); + + log.AppendLine($"output : {outputPath} ({new FileInfo(outputPath).Length} bytes)"); + log.AppendLine($"verified : {reloaded.SkippedEntryIndices.Count} skipped " + + $"{Format(reloaded.SkippedEntryIndices)}, {reloaded.Entries.Count} carried"); + foreach (var entry in reloaded.Entries) + { + log.AppendLine($" type {entry.TypeId}, {entry.Data?.Length ?? 0} bytes"); + } + } + catch (InvalidOperationException ex) + { + log.AppendLine($"refused: {ex.Message}"); + } + + Report(log.ToString().TrimEnd()); + } + private static string Format(List ordinals) => ordinals.Count == 0 ? "" : "[" + string.Join(", ", ordinals.Take(24)) + (ordinals.Count > 24 ? ", ..." : "") + "]"; From 062791e6a1a067b2c5dda1e07e56c94d1071cd0a Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:43:27 +0200 Subject: [PATCH 3/9] feat: export edits as a patch, leaving the game's archives alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build SDS replaces the game's archive in place. That is the wrong shape for shipping a map: the original stops being original, and what gets distributed is a whole district rather than the change to it. Adds File > Export Patch…, with a save dialog for where the .sds.patch goes. The game's .sds files are opened read-only — the edited archive is packed in memory from the extracted folder and diffed against the original, so nothing is written, moved or backed up next to it. The build list is left intact, so Build still works afterwards for anyone who does want the archives replaced. SdsPatchDiff pairs resources by type name and by position within that type, which is how the packer lays an archive out. A pair whose bytes differ becomes a skip plus an append; a base resource with no counterpart becomes a bare skip; an edited one with no counterpart becomes a bare append. So export covers every edit the toolkit can make, not only removals. A carried resource is renumbered into the base archive's type table before it is written. The engine resolves a patch resource's type id against the base archive's dictionary, not the edited archive's, and the packer is free to number the two differently — carrying the edited id would hand the resource to the wrong manager. A type the base never declared is introduced by the patch under an id that cannot collide with one already in use, since the engine discards a duplicate rather than redefining it. --- src/Illusion.Assets/Sds/PatchExporter.cs | 97 +++++++++++ src/Illusion.Formats/Archive/SdsPatchDiff.cs | 173 +++++++++++++++++++ src/Illusion/Views/MainWindow.xaml | 1 + src/Illusion/Views/MainWindow.xaml.cs | 94 ++++++++++ 4 files changed, 365 insertions(+) create mode 100644 src/Illusion.Assets/Sds/PatchExporter.cs create mode 100644 src/Illusion.Formats/Archive/SdsPatchDiff.cs diff --git a/src/Illusion.Assets/Sds/PatchExporter.cs b/src/Illusion.Assets/Sds/PatchExporter.cs new file mode 100644 index 0000000..c6dd185 --- /dev/null +++ b/src/Illusion.Assets/Sds/PatchExporter.cs @@ -0,0 +1,97 @@ +using Illusion.Formats; +using Illusion.Formats.Archive; + +namespace Illusion.Assets.Sds; + +/// One archive's export: where the patch went, and what it carries. +/// The game archive the patch applies to. +/// The file written. +/// What the diff found. +public readonly record struct PatchExportResult(string Archive, string PatchPath, PatchDiffResult Result); + +/// +/// Exports this session's edits as .sds.patch files instead of repacking the game's archives. +/// +/// +/// The game's .sds files are opened read-only and never written, moved or backed up — the +/// edited archive is packed in memory from the extracted folder and diffed against the original. +/// This is the difference between exporting and , +/// which replaces the archive in place. +/// +public static class PatchExporter +{ + /// The extension every exported patch carries, matching the game's own convention. + public const string PatchExtension = ".sds.patch"; + + /// The name a patch for should be given. + public static string SuggestFileName(FileInfo sds) + { + ArgumentNullException.ThrowIfNull(sds); + return Path.GetFileNameWithoutExtension(sds.Name) + PatchExtension; + } + + /// + /// Writes a patch for to , expressing every + /// edit made to it this session. + /// + /// The archive was never extracted, so there is nothing to diff. + /// Nothing changed, so there is no patch to write. + public static PatchExportResult Export(FileInfo sds, string outputPath) + { + ArgumentNullException.ThrowIfNull(sds); + ArgumentException.ThrowIfNullOrWhiteSpace(outputPath); + + string extracted = MafiaEnvironment.ExtractedDir(sds); + if (!File.Exists(Path.Combine(extracted, "SDSContent.xml"))) + { + throw new FileNotFoundException( + $"Extracted content not found for {sds.Name} — nothing to diff.", + Path.Combine(extracted, "SDSContent.xml")); + } + + // The edited archive is built in memory. Nothing is written next to the game file, and the + // original is only ever read. + SdsArchive edited = SdsArchive.Pack(extracted, GameProfile.MafiaII); + SdsArchive original = SdsArchive.Open(sds.FullName); + + (SdsPatchFile patch, PatchDiffResult result) = SdsPatchDiff.Between(original, edited); + + string? directory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + using (FileStream output = File.Create(outputPath)) + { + patch.Save(output); + } + + return new PatchExportResult(sds.FullName, outputPath, result); + } + + /// + /// Exports one patch per archive into , naming each after its + /// archive. Archives that turn out to be unchanged are skipped rather than failing the export. + /// + public static IReadOnlyList ExportAll(IEnumerable archives, string targetFolder) + { + ArgumentNullException.ThrowIfNull(archives); + ArgumentException.ThrowIfNullOrWhiteSpace(targetFolder); + + var exported = new List(); + foreach (FileInfo sds in archives) + { + try + { + exported.Add(Export(sds, Path.Combine(targetFolder, SuggestFileName(sds)))); + } + catch (InvalidOperationException) + { + // Identical to the original — nothing to ship for this one. + } + } + + return exported; + } +} diff --git a/src/Illusion.Formats/Archive/SdsPatchDiff.cs b/src/Illusion.Formats/Archive/SdsPatchDiff.cs new file mode 100644 index 0000000..769aefd --- /dev/null +++ b/src/Illusion.Formats/Archive/SdsPatchDiff.cs @@ -0,0 +1,173 @@ +namespace Illusion.Formats.Archive; + +/// What a diff produced, so a caller can report it instead of guessing. +/// Resources present in both but with different bytes. +/// Resources the edited archive no longer has. +/// Resources the edited archive gained. +public readonly record struct PatchDiffResult(int Changed, int Removed, int Added) +{ + /// Whether the two archives differ at all. + public bool HasChanges => Changed > 0 || Removed > 0 || Added > 0; +} + +/// +/// Expresses the difference between a pristine archive and an edited copy of it as a +/// , so edits can ship as a patch instead of overwriting the original. +/// +/// +/// +/// Resources are paired by type and by position within that type, which is how the packer lays an +/// archive out. A pair whose bytes differ becomes a skip of the base ordinal plus an append of the +/// edited resource; a base resource with no counterpart becomes a bare skip; an edited resource with +/// no counterpart becomes a bare append. +/// +/// +/// Nothing here writes the base archive — it is only read. +/// +/// +public static class SdsPatchDiff +{ + /// Diffs against . + /// The patch, and what went into it. + /// The archives are identical — there is no patch to write. + public static (SdsPatchFile Patch, PatchDiffResult Result) Between(SdsArchive baseArchive, SdsArchive edited) + { + ArgumentNullException.ThrowIfNull(baseArchive); + ArgumentNullException.ThrowIfNull(edited); + + var patch = new SdsPatchFile(); + var changed = 0; + var removed = 0; + var added = 0; + + var baseByType = GroupByTypeName(baseArchive); + var editedByType = GroupByTypeName(edited); + + // A carried resource's TypeId is resolved against the BASE archive's type table, not the + // edited one, so every appended entry is renumbered into the base's numbering. A type the + // base never declared is introduced by the patch, with an id that cannot collide with one + // the base already uses (the engine discards a duplicate rather than redefining it). + var typeIds = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < baseArchive.ResourceTypes.Count; i++) + { + typeIds[baseArchive.ResourceTypes[i].Name] = (uint)i; + } + + uint NextTypeId(string typeName) + { + if (typeIds.TryGetValue(typeName, out var existing)) + { + return existing; + } + + var id = (uint)(baseArchive.ResourceTypes.Count + patch.ResourceTypes.Count); + patch.ResourceTypes.Add(new SdsResourceTypeEntry { Id = id, Name = typeName, Parent = 0 }); + typeIds[typeName] = id; + return id; + } + + foreach (var (typeName, baseOrdinals) in baseByType) + { + editedByType.TryGetValue(typeName, out var editedEntries); + editedEntries ??= []; + + for (var i = 0; i < baseOrdinals.Count; i++) + { + var ordinal = baseOrdinals[i]; + + if (i >= editedEntries.Count) + { + patch.SkippedEntryIndices.Add(ordinal); + removed++; + continue; + } + + var editedEntry = edited.Entries[editedEntries[i]]; + if (SameBytes(baseArchive.Entries[ordinal].Data, editedEntry.Data)) + { + continue; + } + + patch.SkippedEntryIndices.Add(ordinal); + patch.Entries.Add(Clone(editedEntry, NextTypeId(typeName))); + changed++; + } + + for (var i = baseOrdinals.Count; i < editedEntries.Count; i++) + { + patch.Entries.Add(Clone(edited.Entries[editedEntries[i]], NextTypeId(typeName))); + added++; + } + } + + // A whole type the base archive never had. + foreach (var (typeName, editedEntries) in editedByType) + { + if (baseByType.ContainsKey(typeName)) + { + continue; + } + + foreach (var index in editedEntries) + { + patch.Entries.Add(Clone(edited.Entries[index], NextTypeId(typeName))); + added++; + } + } + + var result = new PatchDiffResult(changed, removed, added); + if (!result.HasChanges) + { + throw new InvalidOperationException( + "The archives are identical — write no patch rather than an empty one, which crashes the engine."); + } + + patch.Validate(); + return (patch, result); + } + + private static bool SameBytes(byte[]? left, byte[]? right) + { + if (left is null || right is null) + { + return left is null && right is null; + } + + return left.AsSpan().SequenceEqual(right); + } + + private static ResourceEntry Clone(ResourceEntry source, uint typeId) => new() + { + TypeId = (int)typeId, + Version = source.Version, + SlotRamRequired = source.SlotRamRequired, + SlotVramRequired = source.SlotVramRequired, + OtherRamRequired = source.OtherRamRequired, + OtherVramRequired = source.OtherVramRequired, + Data = source.Data, + }; + + // Keyed by type name rather than type id: the two archives declare their own type tables, and the + // packer is free to number them differently. + private static Dictionary> GroupByTypeName(SdsArchive archive) + { + var groups = new Dictionary>(StringComparer.Ordinal); + for (var i = 0; i < archive.Entries.Count; i++) + { + var typeId = archive.Entries[i].TypeId; + var typeName = typeId >= 0 && typeId < archive.ResourceTypes.Count + ? archive.ResourceTypes[typeId].Name + : string.Empty; + + if (!groups.TryGetValue(typeName, out var ordinals)) + { + ordinals = []; + groups[typeName] = ordinals; + } + + ordinals.Add(i); + } + + return groups; + } +} diff --git a/src/Illusion/Views/MainWindow.xaml b/src/Illusion/Views/MainWindow.xaml index bf07be6..958ae00 100644 --- a/src/Illusion/Views/MainWindow.xaml +++ b/src/Illusion/Views/MainWindow.xaml @@ -29,6 +29,7 @@ +