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..d084ae7dd 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"
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..60b80305b 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(context, assetFile, stream);
+ break;
case "bdscript":
PatchBdscript(context, assetFile, stream);
break;
@@ -627,6 +630,44 @@ private static void PatchAreaDataScript(Context context, List sources
Kh2.Ard.AreaDataScript.Write(stream.SetPosition(0), scripts.Values);
}
+ private static void PatchKh1ArdResource(Context context, AssetFile assetFile, Stream stream)
+ {
+ 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 source in assetFile.Source)
+ {
+ var srcFile = context.GetSourceModAssetPath(source.Name);
+ if (!File.Exists(srcFile))
+ throw new FileNotFoundException($"The mod does not contain the file {source.Name}", srcFile);
+
+ var replacements = deserializer.Deserialize>(File.ReadAllText(srcFile))
+ ?? new Dictionary();
+
+ 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);
+
+ // 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..891f157e1 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,182 @@ 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 Kh1ArdResourceReadsTheReplacementsFromItsSourceFileTest()
+ {
+ var yml =
+ "title: source file test\n" +
+ "assets:\n" +
+ "- name: tw01.ard\n" +
+ " method: kh1ardresource\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")));
+
+ 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)
+ {
+ 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
+ {
+ Assets = new List
+ {
+ 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
+ /// 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..d816b2cb9 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,37 @@ 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.
+
+Asset Example
+
+```
+- name: tw01.ard
+ method: kh1ardresource
+ 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
+```
+
+Notes:
+ * Only existing slots can be overwritten.
+ * A name can be at most 31 ASCII characters, and cannot be empty.
+
### `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`.