From c653dcc1da739efba7b6ff84541ff54ff391ab3f Mon Sep 17 00:00:00 2001 From: gaithern Date: Fri, 14 Aug 2026 14:26:07 -0500 Subject: [PATCH 1/5] Initial commit --- OpenKh.Kh1/Ard.cs | 142 ++++++++++++++++++ OpenKh.Patcher/Metadata.cs | 7 + OpenKh.Patcher/OpenKh.Patcher.csproj | 1 + OpenKh.Patcher/PatcherProcessor.cs | 31 ++++ OpenKh.Tests/Patcher/PatcherTests.cs | 168 ++++++++++++++++++++++ docs/tool/GUI.ModsManager/creatingMods.md | 31 ++++ 6 files changed, 380 insertions(+) create mode 100644 OpenKh.Kh1/Ard.cs diff --git a/OpenKh.Kh1/Ard.cs b/OpenKh.Kh1/Ard.cs new file mode 100644 index 000000000..a7cffe990 --- /dev/null +++ b/OpenKh.Kh1/Ard.cs @@ -0,0 +1,142 @@ +using OpenKh.Common; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace OpenKh.Kh1 +{ + /// + /// KH1 area data (.ard) archive. + /// + /// The header is a 32-bit entry count followed by entryCount+1 absolute offsets, + /// so entry i spans [offsets[i], offsets[i + 1]). Every *.ard file declares 32 + /// entries and the final offset is the end of the file. + /// + /// Entry 5 is the resource list: a flat array of fixed 0x20 byte, NULL padded ASCII + /// names generally laid out as (model, mset) pairs. + /// + public class Ard + { + /// Index of the entry holding the resource list. + public const int ResourceListIndex = 5; + + /// Size in bytes of a single name record. + public const int NameSize = 0x20; + + /// Longest name that fits a record, leaving room for the NUL terminator. + public const int MaxNameLength = NameSize - 1; + + private const int EntryCount = 32; + private const int HeaderSize = 4 + (EntryCount + 1) * 4; + + public static bool IsValid(Stream stream) => TryReadOffsets(stream, out _); + + /// + /// Reads the model/mset resource list. Empty slots come back as empty strings so + /// that indices always line up with the slots in the file. + /// + public static List ReadResourceList(Stream stream) + { + var (start, end) = GetResourceListRange(stream); + + stream.SetPosition(start); + var data = stream.ReadBytes(end - start); + + var names = new List((end - start) / NameSize); + for (var i = 0; i < data.Length; i += NameSize) + { + var length = 0; + while (length < NameSize && data[i + length] != 0) + length++; + + names.Add(Encoding.ASCII.GetString(data, i, length)); + } + + return names; + } + + /// + /// Overwrites the model/mset resource list in place. The list must keep its original + /// length: the entry size is baked into the header's offset table, so growing or + /// shrinking it would move every section that follows. + /// + public static void WriteResourceList(Stream stream, IReadOnlyList names) + { + var (start, end) = GetResourceListRange(stream); + var slotCount = (end - start) / NameSize; + + if (names.Count != slotCount) + throw new InvalidDataException( + $"The resource list has {slotCount} slots but {names.Count} were given. " + + "Entries can be overwritten but not added or removed."); + + var data = new byte[end - start]; + for (var i = 0; i < names.Count; i++) + { + var name = names[i] ?? string.Empty; + if (name.Length > MaxNameLength) + throw new InvalidDataException( + $"The name '{name}' is {name.Length} characters long, but a resource list entry holds at most {MaxNameLength}."); + + for (var c = 0; c < name.Length; c++) + { + if (name[c] > 0x7F) + throw new InvalidDataException($"The name '{name}' contains the non-ASCII character '{name[c]}'."); + + data[i * NameSize + c] = (byte)name[c]; + } + } + + stream.SetPosition(start); + stream.Write(data); + } + + private static (int Start, int End) GetResourceListRange(Stream stream) + { + if (!TryReadOffsets(stream, out var offsets)) + throw new InvalidDataException("The file is not a valid KH1 .ard archive."); + + return (offsets[ResourceListIndex], offsets[ResourceListIndex + 1]); + } + + private static bool TryReadOffsets(Stream stream, out int[] offsets) + { + offsets = null; + + if (stream.Length < HeaderSize) + return false; + + stream.SetPosition(0); + if (stream.ReadInt32() != EntryCount) + return false; + + var read = new int[EntryCount + 1]; + for (var i = 0; i <= EntryCount; i++) + read[i] = stream.ReadInt32(); + + // The last offset marks the end of the file, but the trailing padding is + // stripped on disk often enough that it can overshoot by up to a sector. + // Everything before it has to describe a real, ascending range. + if (read[0] < HeaderSize) + return false; + + for (var i = 0; i < EntryCount; i++) + { + if (read[i] > read[i + 1]) + return false; + if (read[i] > stream.Length) + return false; + } + + var resourceListSize = read[ResourceListIndex + 1] - read[ResourceListIndex]; + if (resourceListSize <= 0 || resourceListSize % NameSize != 0) + return false; + if (read[ResourceListIndex + 1] > stream.Length) + return false; + + offsets = read; + return true; + } + } +} diff --git a/OpenKh.Patcher/Metadata.cs b/OpenKh.Patcher/Metadata.cs index 9178a047d..acfa83891 100644 --- a/OpenKh.Patcher/Metadata.cs +++ b/OpenKh.Patcher/Metadata.cs @@ -102,6 +102,7 @@ public class AssetFile /// "copy" /// "imgd" /// "imgz" + /// "kh1ardresource" /// "kh2msg" /// "listpatch" /// "spawnpoint" @@ -190,6 +191,12 @@ public class AssetFile [YamlMember(DefaultValuesHandling = DefaultValuesHandling.OmitDefaults)] public int Index { get; set; } [YamlMember(DefaultValuesHandling = DefaultValuesHandling.OmitDefaults)] public string Game { get; set; } [YamlMember(DefaultValuesHandling = DefaultValuesHandling.OmitDefaults)] public bool CollectionOptional { get; set; } + + /// + /// Entries to overwrite, keyed by index, declared inline rather than in a separate file. + /// Used by the "kh1ardresource" method. + /// + [YamlMember(DefaultValuesHandling = DefaultValuesHandling.OmitDefaults)] public Dictionary Replacements { get; set; } } public class Multi diff --git a/OpenKh.Patcher/OpenKh.Patcher.csproj b/OpenKh.Patcher/OpenKh.Patcher.csproj index f0e0cacdd..0c265a56b 100644 --- a/OpenKh.Patcher/OpenKh.Patcher.csproj +++ b/OpenKh.Patcher/OpenKh.Patcher.csproj @@ -14,6 +14,7 @@ + diff --git a/OpenKh.Patcher/PatcherProcessor.cs b/OpenKh.Patcher/PatcherProcessor.cs index 47889a92b..5a5df0c05 100644 --- a/OpenKh.Patcher/PatcherProcessor.cs +++ b/OpenKh.Patcher/PatcherProcessor.cs @@ -390,6 +390,9 @@ private static void PatchFile(Context context, AssetFile assetFile, Stream strea case "areadatascript": PatchAreaDataScript(context, assetFile.Source, stream); break; + case "kh1ardresource": + PatchKh1ArdResource(assetFile, stream); + break; case "bdscript": PatchBdscript(context, assetFile, stream); break; @@ -627,6 +630,34 @@ private static void PatchAreaDataScript(Context context, List sources Kh2.Ard.AreaDataScript.Write(stream.SetPosition(0), scripts.Values); } + private static void PatchKh1ArdResource(AssetFile assetFile, Stream stream) + { + if (assetFile.Replacements == null || assetFile.Replacements.Count == 0) + throw new Exception($"File '{assetFile.Name}' does not define any replacements"); + + if (!Kh1.Ard.IsValid(stream)) + throw new InvalidDataException($"'{assetFile.Name}' is not a valid KH1 .ard archive"); + + var resources = Kh1.Ard.ReadResourceList(stream); + foreach (var replacement in assetFile.Replacements) + { + if (replacement.Key < 0 || replacement.Key >= resources.Count) + throw new IndexOutOfRangeException( + $"'{assetFile.Name}' has {resources.Count} resource entries (0 to {resources.Count - 1}), but a replacement targets index {replacement.Key}"); + + if (string.IsNullOrEmpty(replacement.Value)) + throw new Exception($"'{assetFile.Name}' does not give a name for resource index {replacement.Key}"); + + resources[replacement.Key] = replacement.Value; + } + + Kh1.Ard.WriteResourceList(stream, resources); + + // The resource list is edited in place; keep the rest of the archive intact, + // as PatchFile truncates the stream to whatever position it is left at. + stream.Position = stream.Length; + } + private static void PatchBdscript(Context context, AssetFile assetFile, Stream stream) { diff --git a/OpenKh.Tests/Patcher/PatcherTests.cs b/OpenKh.Tests/Patcher/PatcherTests.cs index 1d4c2a589..ce88bf8c1 100644 --- a/OpenKh.Tests/Patcher/PatcherTests.cs +++ b/OpenKh.Tests/Patcher/PatcherTests.cs @@ -9,6 +9,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; using Xunit; using Xunit.Sdk; using YamlDotNet.Serialization; @@ -4221,6 +4222,173 @@ public void ProcessMultipleTest() } + [Fact] + public void Kh1ArdResourceReplaceTest() + { + var patcher = new PatcherProcessor(); + var patch = Kh1ArdPatch(("1", "xa_al_9999.mset"), ("2", "tw_6100.moa")); + + CreateFile(AssetsInputDir, "tw01.ard").Using(x => x.Write(CreateArd( + "xa_ex_0010.mdls", "xa_ex_0010.mset", "ex_6540.moa", "ex_6540.moa.mset"))); + + patcher.Patch(AssetsInputDir, ModOutputDir, patch, ModInputDir, Tests: true); + + AssertArdResources(new[] + { + "xa_ex_0010.mdls", "xa_al_9999.mset", "tw_6100.moa", "ex_6540.moa.mset" + }, ModOutputDir, "tw01.ard"); + } + + [Fact] + public void Kh1ArdResourceReplaceIsDeclaredInlineInTheModYmlTest() + { + var yml = + "title: inline test\n" + + "assets:\n" + + "- name: tw01.ard\n" + + " method: kh1ardresource\n" + + " replacements:\n" + + " 1: xa_al_9999.mset\n"; + + var patch = new MemoryStream(Encoding.UTF8.GetBytes(yml)).Using(Metadata.Read); + + CreateFile(AssetsInputDir, "tw01.ard").Using(x => x.Write(CreateArd( + "a.mdls", "a.mset", "b.moa", "b.moa.mset"))); + + new PatcherProcessor().Patch(AssetsInputDir, ModOutputDir, patch, ModInputDir, Tests: true); + + AssertArdResources(new[] { "a.mdls", "xa_al_9999.mset", "b.moa", "b.moa.mset" }, + ModOutputDir, "tw01.ard"); + } + + [Fact] + public void Kh1ArdResourceReplaceOfReplacedFileTest() + { + var patcher = new PatcherProcessor(); + var patch = Kh1ArdPatch(("1", "xa_al_9999.mset")); + + // The vanilla file the patch must NOT be applied to. + CreateFile(AssetsInputDir, "tw01.ard").Using(x => x.Write(CreateArd( + "vanilla_a.mdls", "vanilla_a.mset", "vanilla_b.moa", "vanilla_b.moa.mset"))); + + // A higher ranked mod already staged its own replacement of the same file. + CreateFile(ModOutputDir, "tw01.ard").Using(x => x.Write(CreateArd( + "replaced_a.mdls", "replaced_a.mset", "replaced_b.moa", "replaced_b.moa.mset"))); + + patcher.Patch(AssetsInputDir, ModOutputDir, patch, ModInputDir, Tests: true); + + AssertArdResources(new[] + { + "replaced_a.mdls", "xa_al_9999.mset", "replaced_b.moa", "replaced_b.moa.mset" + }, ModOutputDir, "tw01.ard"); + } + + [Fact] + public void Kh1ArdResourceReplacePreservesTheRestOfTheFileTest() + { + var patcher = new PatcherProcessor(); + var patch = Kh1ArdPatch(("0", "c.mdls")); + + var original = CreateArd("a.mdls", "a.mset", "b.moa", "b.moa.mset"); + CreateFile(AssetsInputDir, "tw01.ard").Using(x => x.Write(original)); + + patcher.Patch(AssetsInputDir, ModOutputDir, patch, ModInputDir, Tests: true); + + var patched = File.ReadAllBytes(Path.Combine(ModOutputDir, "tw01.ard")); + Assert.Equal(original.Length, patched.Length); + // The header and everything past the resource list must be byte identical. + Assert.Equal(original.Take(ArdResourceListOffset), patched.Take(ArdResourceListOffset)); + Assert.Equal(original.Skip(ArdResourceListEnd), patched.Skip(ArdResourceListEnd)); + } + + [Fact] + public void Kh1ArdResourceReplaceOutOfRangeThrowsTest() + { + var patcher = new PatcherProcessor(); + var patch = Kh1ArdPatch(("9", "nope.mdls")); + + CreateFile(AssetsInputDir, "tw01.ard").Using(x => x.Write(CreateArd( + "a.mdls", "a.mset", "b.moa", "b.moa.mset"))); + + Assert.Throws(() => + patcher.Patch(AssetsInputDir, ModOutputDir, patch, ModInputDir, Tests: true)); + } + + [Fact] + public void Kh1ArdResourceReplaceEmptyNameThrowsTest() + { + var patcher = new PatcherProcessor(); + var patch = Kh1ArdPatch(("0", "")); + + CreateFile(AssetsInputDir, "tw01.ard").Using(x => x.Write(CreateArd( + "a.mdls", "a.mset", "b.moa", "b.moa.mset"))); + + Assert.Throws(() => + patcher.Patch(AssetsInputDir, ModOutputDir, patch, ModInputDir, Tests: true)); + } + + [Fact] + public void Kh1ArdResourceReplaceNameTooLongThrowsTest() + { + var patcher = new PatcherProcessor(); + var patch = Kh1ArdPatch(("0", new string('x', OpenKh.Kh1.Ard.MaxNameLength + 1))); + + CreateFile(AssetsInputDir, "tw01.ard").Using(x => x.Write(CreateArd( + "a.mdls", "a.mset", "b.moa", "b.moa.mset"))); + + Assert.Throws(() => + patcher.Patch(AssetsInputDir, ModOutputDir, patch, ModInputDir, Tests: true)); + } + + private const int ArdHeaderSize = 4 + (32 + 1) * 4; + private const int ArdResourceListOffset = 0x100; + + private static int ArdResourceListEnd => ArdResourceListOffset + 4 * OpenKh.Kh1.Ard.NameSize; + + private static Metadata Kh1ArdPatch(params (string Index, string Name)[] replacements) => new Metadata + { + Assets = new List + { + new AssetFile + { + Name = "tw01.ard", + Method = "kh1ardresource", + Replacements = replacements.ToDictionary(x => int.Parse(x.Index), x => x.Name) + } + } + }; + + /// + /// Builds a minimal but structurally valid .ard: 32 entries, with entry 5 holding + /// the given names and a marker block after it to catch collateral damage. + /// + private static byte[] CreateArd(params string[] names) + { + var listEnd = ArdResourceListOffset + names.Length * OpenKh.Kh1.Ard.NameSize; + var length = listEnd + 0x80; + var data = new byte[length]; + + BitConverter.GetBytes(32).CopyTo(data, 0); + for (var i = 0; i <= 32; i++) + { + var offset = i <= 5 ? ArdResourceListOffset : i == 6 ? listEnd : length; + BitConverter.GetBytes(offset).CopyTo(data, 4 + i * 4); + } + + for (var i = 0; i < names.Length; i++) + Encoding.ASCII.GetBytes(names[i]).CopyTo(data, ArdResourceListOffset + i * OpenKh.Kh1.Ard.NameSize); + + for (var i = listEnd; i < length; i++) + data[i] = 0xCD; + + Assert.True(ArdHeaderSize <= ArdResourceListOffset); + return data; + } + + private static void AssertArdResources(string[] expected, params string[] paths) => + File.OpenRead(Path.Join(paths)).Using(x => + Assert.Equal(expected, OpenKh.Kh1.Ard.ReadResourceList(x))); + private static void AssertFileExists(params string[] paths) { var filePath = Path.Join(paths); diff --git a/docs/tool/GUI.ModsManager/creatingMods.md b/docs/tool/GUI.ModsManager/creatingMods.md index 83be70e25..b549feba7 100644 --- a/docs/tool/GUI.ModsManager/creatingMods.md +++ b/docs/tool/GUI.ModsManager/creatingMods.md @@ -17,6 +17,7 @@ This document will focus on teaching you how to create mods using the OpenKH Mod * [areadataspawn](#areadataspawn-kh2---modifies-a-kh2-spawnpoint-subfile-located-within-ard-files-using-an-yaml-file-created-using-openkhcommandspawnscript) * [listpatch](#listpatch-kh2---can-modify-the-following-different-types-of-list-binaries-found-within-kh2) * [synthpatch](#synthpatch-kh2) + * [kh1ardresource](#kh1ardresource-kh1---replaces-entries-in-the-resource-list-of-a-kh1-ard-file) * [bbsarc](#bbsarc-bbs) * [Example of a Fully Complete `mod.yml` File](#an-example-of-a-fully-complete-modyml-can-be-seen-below-and-the-full-source-of-the-mod-can-be-seen-here) * [Generating a Simple `mod.yml` for New Mod Authors](#generating-a-simple-modyml-for-new-mod-authors) @@ -413,6 +414,36 @@ Asset Example ShopUnlock: 201 ``` +## `kh1ardresource` (KH1) - Replaces entries in the resource list of a KH1 `.ard` file. + +Every KH1 `.ard` contains a list of the models and animation sets its map loads. Each +entry is a fixed-size slot holding one name, and the slots generally run as `(model, mset)` pairs. +This method rewrites individual slots in place, so the rest of the binary is left byte for byte identical. + +Use it to swap a model or animation set without having to `copy` the whole multi-megabyte +`.ard` into your mod, which would clobber any other mod's changes to that file. + +Unlike most methods, this one takes no `source`. The replacements are declared inline with +`replacements`, keyed by slot index: + +``` +- name: tw01.ard + method: kh1ardresource + replacements: + 0: xa_al_9999.mdls + 1: xa_al_9999.mset + 8: tw_6100.moa + 9: tw_6100.moa.mset +``` + +Notes: + * Only existing slots can be overwritten. The list's length is fixed by the archive's + offset table, so entries cannot be added or removed — an out-of-range index is an error. + * A name can be at most 31 ASCII characters, and cannot be empty. + * The patch is applied on top of whatever is already staged for that `.ard`. If a mod + below yours replaced the file with `copy`, you edit *that* version; if none did, you + edit the original game file. Put your mod above any mod that replaces the same `.ard`. + ### `bbsarc` (BBS) Allows you to add/patch files inside a bbs `.arc` container without having to `copy` the entire arc file into your mod. You can use any method to patch those files, although at time of writing the only one that works for BBS files (other than `bbsarc`) is `copy`. From 8e67b646cfeb9e5ff7a44a7635bc383f4296d655 Mon Sep 17 00:00:00 2001 From: gaithern Date: Sat, 15 Aug 2026 21:54:00 -0500 Subject: [PATCH 2/5] Clean up this note --- docs/tool/GUI.ModsManager/creatingMods.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/tool/GUI.ModsManager/creatingMods.md b/docs/tool/GUI.ModsManager/creatingMods.md index b549feba7..bb9f41603 100644 --- a/docs/tool/GUI.ModsManager/creatingMods.md +++ b/docs/tool/GUI.ModsManager/creatingMods.md @@ -440,9 +440,6 @@ Notes: * Only existing slots can be overwritten. The list's length is fixed by the archive's offset table, so entries cannot be added or removed — an out-of-range index is an error. * A name can be at most 31 ASCII characters, and cannot be empty. - * The patch is applied on top of whatever is already staged for that `.ard`. If a mod - below yours replaced the file with `copy`, you edit *that* version; if none did, you - edit the original game file. Put your mod above any mod that replaces the same `.ard`. ### `bbsarc` (BBS) Allows you to add/patch files inside a bbs `.arc` container without having to `copy` the entire arc file into your mod. You can use any method to patch those files, although at time of writing the only one that works for BBS files (other than `bbsarc`) is `copy`. From 4ed3171e54de611a3582950584a6bee33460d38b Mon Sep 17 00:00:00 2001 From: gaithern Date: Sat, 15 Aug 2026 21:56:08 -0500 Subject: [PATCH 3/5] More note cleaning --- docs/tool/GUI.ModsManager/creatingMods.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/tool/GUI.ModsManager/creatingMods.md b/docs/tool/GUI.ModsManager/creatingMods.md index bb9f41603..545385697 100644 --- a/docs/tool/GUI.ModsManager/creatingMods.md +++ b/docs/tool/GUI.ModsManager/creatingMods.md @@ -437,8 +437,7 @@ Unlike most methods, this one takes no `source`. The replacements are declared i ``` Notes: - * Only existing slots can be overwritten. The list's length is fixed by the archive's - offset table, so entries cannot be added or removed — an out-of-range index is an error. + * Only existing slots can be overwritten. * A name can be at most 31 ASCII characters, and cannot be empty. ### `bbsarc` (BBS) From 28b6ece9d7391fdacc15da53936feaed85d4ce69 Mon Sep 17 00:00:00 2001 From: gaithern Date: Sun, 16 Aug 2026 00:08:16 -0500 Subject: [PATCH 4/5] Read kh1ardresource replacements from a source file Matches every other patch method: mod.yml lists the asset and points at a file holding the edits, so the manifest only changes when an edit is added or removed. Replaces the inline 'replacements' map, which was the only inline-data property on AssetFile. --- OpenKh.Patcher/Metadata.cs | 6 ----- OpenKh.Patcher/PatcherProcessor.cs | 32 ++++++++++++++-------- OpenKh.Tests/Patcher/PatcherTests.cs | 33 ++++++++++++++--------- docs/tool/GUI.ModsManager/creatingMods.md | 27 +++++++++++++------ 4 files changed, 61 insertions(+), 37 deletions(-) diff --git a/OpenKh.Patcher/Metadata.cs b/OpenKh.Patcher/Metadata.cs index acfa83891..d084ae7dd 100644 --- a/OpenKh.Patcher/Metadata.cs +++ b/OpenKh.Patcher/Metadata.cs @@ -191,12 +191,6 @@ public class AssetFile [YamlMember(DefaultValuesHandling = DefaultValuesHandling.OmitDefaults)] public int Index { get; set; } [YamlMember(DefaultValuesHandling = DefaultValuesHandling.OmitDefaults)] public string Game { get; set; } [YamlMember(DefaultValuesHandling = DefaultValuesHandling.OmitDefaults)] public bool CollectionOptional { get; set; } - - /// - /// Entries to overwrite, keyed by index, declared inline rather than in a separate file. - /// Used by the "kh1ardresource" method. - /// - [YamlMember(DefaultValuesHandling = DefaultValuesHandling.OmitDefaults)] public Dictionary Replacements { get; set; } } public class Multi diff --git a/OpenKh.Patcher/PatcherProcessor.cs b/OpenKh.Patcher/PatcherProcessor.cs index 5a5df0c05..60b80305b 100644 --- a/OpenKh.Patcher/PatcherProcessor.cs +++ b/OpenKh.Patcher/PatcherProcessor.cs @@ -391,7 +391,7 @@ private static void PatchFile(Context context, AssetFile assetFile, Stream strea PatchAreaDataScript(context, assetFile.Source, stream); break; case "kh1ardresource": - PatchKh1ArdResource(assetFile, stream); + PatchKh1ArdResource(context, assetFile, stream); break; case "bdscript": PatchBdscript(context, assetFile, stream); @@ -630,25 +630,35 @@ private static void PatchAreaDataScript(Context context, List sources Kh2.Ard.AreaDataScript.Write(stream.SetPosition(0), scripts.Values); } - private static void PatchKh1ArdResource(AssetFile assetFile, Stream stream) + private static void PatchKh1ArdResource(Context context, AssetFile assetFile, Stream stream) { - if (assetFile.Replacements == null || assetFile.Replacements.Count == 0) - throw new Exception($"File '{assetFile.Name}' does not define any replacements"); + if (assetFile.Source == null || assetFile.Source.Count == 0) + throw new Exception($"File '{assetFile.Name}' does not contain any source"); if (!Kh1.Ard.IsValid(stream)) throw new InvalidDataException($"'{assetFile.Name}' is not a valid KH1 .ard archive"); var resources = Kh1.Ard.ReadResourceList(stream); - foreach (var replacement in assetFile.Replacements) + foreach (var source in assetFile.Source) { - if (replacement.Key < 0 || replacement.Key >= resources.Count) - throw new IndexOutOfRangeException( - $"'{assetFile.Name}' has {resources.Count} resource entries (0 to {resources.Count - 1}), but a replacement targets index {replacement.Key}"); + var srcFile = context.GetSourceModAssetPath(source.Name); + if (!File.Exists(srcFile)) + throw new FileNotFoundException($"The mod does not contain the file {source.Name}", srcFile); - if (string.IsNullOrEmpty(replacement.Value)) - throw new Exception($"'{assetFile.Name}' does not give a name for resource index {replacement.Key}"); + var replacements = deserializer.Deserialize>(File.ReadAllText(srcFile)) + ?? new Dictionary(); - resources[replacement.Key] = replacement.Value; + foreach (var replacement in replacements) + { + if (replacement.Key < 0 || replacement.Key >= resources.Count) + throw new IndexOutOfRangeException( + $"'{source.Name}' sets resource index {replacement.Key}, but '{assetFile.Name}' only has {resources.Count} entries (0 to {resources.Count - 1})"); + + if (string.IsNullOrEmpty(replacement.Value)) + throw new Exception($"'{source.Name}' does not give a name for resource index {replacement.Key}"); + + resources[replacement.Key] = replacement.Value; + } } Kh1.Ard.WriteResourceList(stream, resources); diff --git a/OpenKh.Tests/Patcher/PatcherTests.cs b/OpenKh.Tests/Patcher/PatcherTests.cs index ce88bf8c1..891f157e1 100644 --- a/OpenKh.Tests/Patcher/PatcherTests.cs +++ b/OpenKh.Tests/Patcher/PatcherTests.cs @@ -4240,18 +4240,20 @@ public void Kh1ArdResourceReplaceTest() } [Fact] - public void Kh1ArdResourceReplaceIsDeclaredInlineInTheModYmlTest() + public void Kh1ArdResourceReadsTheReplacementsFromItsSourceFileTest() { var yml = - "title: inline test\n" + + "title: source file test\n" + "assets:\n" + "- name: tw01.ard\n" + " method: kh1ardresource\n" + - " replacements:\n" + - " 1: xa_al_9999.mset\n"; + " source:\n" + + " - name: files/tw01.yml\n"; var patch = new MemoryStream(Encoding.UTF8.GetBytes(yml)).Using(Metadata.Read); + CreateFile(ModInputDir, "files/tw01.yml") + .Using(x => x.Write(Encoding.UTF8.GetBytes("1: xa_al_9999.mset\n"))); CreateFile(AssetsInputDir, "tw01.ard").Using(x => x.Write(CreateArd( "a.mdls", "a.mset", "b.moa", "b.moa.mset"))); @@ -4345,18 +4347,25 @@ public void Kh1ArdResourceReplaceNameTooLongThrowsTest() private static int ArdResourceListEnd => ArdResourceListOffset + 4 * OpenKh.Kh1.Ard.NameSize; - private static Metadata Kh1ArdPatch(params (string Index, string Name)[] replacements) => new Metadata + private static Metadata Kh1ArdPatch(params (string Index, string Name)[] replacements) { - Assets = new List + var lines = replacements.Select(x => $"{x.Index}: {x.Name}"); + File.WriteAllText(Path.Combine(ModInputDir, "tw01.yml"), + string.Join(Environment.NewLine, lines) + Environment.NewLine); + + return new Metadata { - new AssetFile + Assets = new List { - Name = "tw01.ard", - Method = "kh1ardresource", - Replacements = replacements.ToDictionary(x => int.Parse(x.Index), x => x.Name) + new AssetFile + { + Name = "tw01.ard", + Method = "kh1ardresource", + Source = new List { new AssetFile { Name = "tw01.yml" } } + } } - } - }; + }; + } /// /// Builds a minimal but structurally valid .ard: 32 entries, with entry 5 holding diff --git a/docs/tool/GUI.ModsManager/creatingMods.md b/docs/tool/GUI.ModsManager/creatingMods.md index 545385697..eaeb958d1 100644 --- a/docs/tool/GUI.ModsManager/creatingMods.md +++ b/docs/tool/GUI.ModsManager/creatingMods.md @@ -423,22 +423,33 @@ This method rewrites individual slots in place, so the rest of the binary is lef Use it to swap a model or animation set without having to `copy` the whole multi-megabyte `.ard` into your mod, which would clobber any other mod's changes to that file. -Unlike most methods, this one takes no `source`. The replacements are declared inline with -`replacements`, keyed by slot index: +Asset Example ``` - name: tw01.ard method: kh1ardresource - replacements: - 0: xa_al_9999.mdls - 1: xa_al_9999.mset - 8: tw_6100.moa - 9: tw_6100.moa.mset + source: + - name: files/tw01.yml ``` +YAML Source Example - the key is the slot index, the value is the new name: + +``` +0: xa_al_9999.mdls +1: xa_al_9999.mset +8: tw_6100.moa +9: tw_6100.moa.mset +``` + +To find the indices, dump the resource list of the file you are targeting. The model half +of a pair is not always a `.mdls`: enemies and objects use `.moa` and `.mfa`. + Notes: - * Only existing slots can be overwritten. + * Only existing slots can be overwritten. The list's length is fixed by the archive's + offset table, so entries cannot be added or removed - an out-of-range index is an error. * A name can be at most 31 ASCII characters, and cannot be empty. + * Listing more than one source applies them in order, so a later file can override an + earlier one. ### `bbsarc` (BBS) Allows you to add/patch files inside a bbs `.arc` container without having to `copy` the entire arc file into your mod. You can use any method to patch those files, although at time of writing the only one that works for BBS files (other than `bbsarc`) is `copy`. From 03a1ec96fc13ed3e8f630dca937e8f548724977c Mon Sep 17 00:00:00 2001 From: gaithern Date: Sun, 16 Aug 2026 00:31:50 -0500 Subject: [PATCH 5/5] Whoops need to fix this again --- docs/tool/GUI.ModsManager/creatingMods.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/docs/tool/GUI.ModsManager/creatingMods.md b/docs/tool/GUI.ModsManager/creatingMods.md index eaeb958d1..d816b2cb9 100644 --- a/docs/tool/GUI.ModsManager/creatingMods.md +++ b/docs/tool/GUI.ModsManager/creatingMods.md @@ -441,15 +441,9 @@ YAML Source Example - the key is the slot index, the value is the new name: 9: tw_6100.moa.mset ``` -To find the indices, dump the resource list of the file you are targeting. The model half -of a pair is not always a `.mdls`: enemies and objects use `.moa` and `.mfa`. - Notes: - * Only existing slots can be overwritten. The list's length is fixed by the archive's - offset table, so entries cannot be added or removed - an out-of-range index is an error. + * Only existing slots can be overwritten. * A name can be at most 31 ASCII characters, and cannot be empty. - * Listing more than one source applies them in order, so a later file can override an - earlier one. ### `bbsarc` (BBS) Allows you to add/patch files inside a bbs `.arc` container without having to `copy` the entire arc file into your mod. You can use any method to patch those files, although at time of writing the only one that works for BBS files (other than `bbsarc`) is `copy`.