aboutsummaryrefslogtreecommitdiff
path: root/crates/fparkan-runtime/tests
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-10-22 14:30:41 +0300
committerValentin Popov <valentin@popov.link>2026-10-22 14:30:41 +0300
commit83db371742ad3865bf4dc534ae38429c07a936d3 (patch)
treeabb39dd412c6dd3bada1125d2a3c7ba78284cc1b /crates/fparkan-runtime/tests
parent29b81b87237d0e6a3b7ffac913a0b882738eed7c (diff)
downloadfparkan-83db371742ad3865bf4dc534ae38429c07a936d3.tar.xz
fparkan-83db371742ad3865bf4dc534ae38429c07a936d3.zip
feat(runtime): load Behavior profiles for resource queries
Diffstat (limited to 'crates/fparkan-runtime/tests')
-rw-r--r--crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs263
1 files changed, 263 insertions, 0 deletions
diff --git a/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs b/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs
index 5e89934..610e8db 100644
--- a/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs
+++ b/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs
@@ -23,6 +23,9 @@ internal static class BehaviorDistributorTickOracle {
private const int IndexedPropertySetRva = 0x0b470;
private const int IndexedPropertyGetRva = 0x0b5b0;
private const int OreInitializationRva = 0x08790;
+ private const int BehaviorProfileMapRva = 0x08a80;
+ private const int BehaviorVarBlockConstructorRva = 0x22d90;
+ private const int BehaviorVarFieldRegisterRva = 0x22e20;
private const int GetSystemArealMapIatRva = 0x59008;
private const int ControlResourceDeviceGetterRva = 0x21d00;
private const int ControlResourceAggregateQueryRva = 0x2b410;
@@ -45,6 +48,10 @@ internal static class BehaviorDistributorTickOracle {
private const int ControlResourceNoOpStringPairRva = 0x0bcd0;
private const int ControlResourceTag19VtableRva = 0x3c608;
private const int ControlResourceTag19StringPairNoOpRva = 0x0bcd0;
+ private const int BehaviorVarTableVtableRva = 0x591b0;
+ private const int BehaviorVarArrayLoadRva = 0x49fa0;
+ private const int BehaviorVarFieldBindRva = 0x49090;
+ private const int BehaviorVarApplyRva = 0x49170;
private const uint ResourceProperty = 0x02000100;
private static Fixture active;
@@ -74,6 +81,13 @@ internal static class BehaviorDistributorTickOracle {
[UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate void IndexedPropertySet(IntPtr self, uint index, uint valueBits);
[UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int IndexedPropertyGet(IntPtr self, uint index, IntPtr resultBits);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void NativeOreInitialization(IntPtr self);
+ [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate int NativeBehaviorVarArrayLoad(IntPtr self, IntPtr bytes, IntPtr cursorOut);
+ [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void NativeBehaviorVarFieldBind(IntPtr self, IntPtr typeName, IntPtr fieldName, IntPtr destination);
+ [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void NativeBehaviorVarApply(IntPtr self);
+ [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void NativeBehaviorProfileMap(IntPtr self);
+ [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void NativeBehaviorVarBlockConstructor(IntPtr self);
+ [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void NativeBehaviorVarFieldRegister(IntPtr self, IntPtr table);
+ [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate void NativeBehaviorProfileLoadCallback(IntPtr owner, uint selector, IntPtr archive, IntPtr entry);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate float NativeControlResourceDeviceGet(IntPtr self, uint selector);
[UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate void NativeFastcallComponentBuilder(IntPtr owner, IntPtr node, uint parentId);
[UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int NativeArealMapEvent(IntPtr owner, uint message, uint subcode, IntPtr descriptor);
@@ -111,6 +125,15 @@ internal static class BehaviorDistributorTickOracle {
private static IntPtr x87ControlWordCell;
private static X87ControlWordReader x87ControlWordReader;
private static X87ControlWordWriter x87ControlWordWriter;
+ private static readonly NativeBehaviorProfileLoadCallback BehaviorProfileLoadCallback = CaptureBehaviorProfileLoad;
+ private static readonly List<BehaviorProfileSelection> behaviorProfileSelections = new List<BehaviorProfileSelection>();
+
+ private sealed class BehaviorProfileSelection {
+ public IntPtr Owner;
+ public uint Selector;
+ public string Archive;
+ public string Entry;
+ }
private sealed class NativeObject {
public IntPtr Self;
@@ -533,6 +556,9 @@ internal static class BehaviorDistributorTickOracle {
RunNativeControlResourceDeviceCase(controlModule);
RunNativeControlProviderLiveStateCase(controlModule);
RunNativeControlAggregateResourceCases(controlModule);
+ RunNativeBehaviorProfileMapCases(module);
+ RunNativeBehaviorVarSyntheticCase(module);
+ RunNativeBehaviorRealProfileCases(module);
} finally {
SetX87ControlWord(savedControlWord);
Console.WriteLine("x87-control-word-restored=0x" + CurrentX87ControlWord().ToString("x4"));
@@ -1092,6 +1118,243 @@ internal static class BehaviorDistributorTickOracle {
Console.WriteLine("native-behavior-ore-initialization: classes=80000004,80000008,01002000,01004000,01008000; defaults=verified");
}
+ private static void CaptureBehaviorProfileLoad(IntPtr owner, uint selector, IntPtr archive, IntPtr entry) {
+ behaviorProfileSelections.Add(new BehaviorProfileSelection {
+ Owner = owner,
+ Selector = selector,
+ Archive = Marshal.PtrToStringAnsi(archive),
+ Entry = Marshal.PtrToStringAnsi(entry)
+ });
+ }
+
+ private static void RunNativeBehaviorProfileMapCases(IntPtr module) {
+ NativeBehaviorProfileMap map = (NativeBehaviorProfileMap)Marshal.GetDelegateForFunctionPointer(
+ IntPtr.Add(module, BehaviorProfileMapRva), typeof(NativeBehaviorProfileMap));
+ string[][] cases = new string[][] {
+ new string[] { "80000004", "prof_mine.var" },
+ new string[] { "80000008", "prof_storage.var" },
+ new string[] { "80000002", "prof_generator.var" },
+ new string[] { "80000010", "prof_plant.var" },
+ new string[] { "80000040", "prof_angar.var" },
+ new string[] { "80000200", "prof_mtp.var" },
+ new string[] { "80000400", "prof_institute.var" },
+ new string[] { "80010000", "prof_bunker.var" },
+ new string[] { "80020000", "prof_bunker.var" },
+ new string[] { "80040000", "prof_bunker.var" },
+ new string[] { "80100000", "prof_tower.var" },
+ new string[] { "80200000", "prof_tower.var" },
+ new string[] { "01002000", "prof_trn.var" },
+ new string[] { "01004000", "prof_bld.var" },
+ new string[] { "01008000", "prof_war.var" },
+ new string[] { "01010000", "prof_hq.var" },
+ new string[] { "01020000", "prof_hero.var" },
+ new string[] { "20000000", "prof_animal.var" }
+ };
+ using (Fixture fixture = NewFixture()) {
+ IntPtr owner = fixture.Allocate(0x0b00);
+ IntPtr vtable = fixture.Allocate(0x68);
+ Marshal.WriteIntPtr(owner, vtable);
+ Marshal.WriteIntPtr(vtable, 0x64, Marshal.GetFunctionPointerForDelegate(BehaviorProfileLoadCallback));
+ foreach (string[] test in cases) {
+ behaviorProfileSelections.Clear();
+ Marshal.WriteInt32(owner, 0x0afc, unchecked((int)Convert.ToUInt32(test[0], 16)));
+ map(owner);
+ Expect(behaviorProfileSelections.Count == 1, "profile mapper callback count for class 0x" + test[0]);
+ BehaviorProfileSelection selection = behaviorProfileSelections[0];
+ Expect(selection.Owner == owner && selection.Selector == 2 && selection.Archive == "behpsp.res" &&
+ selection.Entry == test[1], "profile mapper result for class 0x" + test[0]);
+ }
+ string[] noMatch = new string[] { "deadbeef", "8000000c" };
+ foreach (string test in noMatch) {
+ behaviorProfileSelections.Clear();
+ Marshal.WriteInt32(owner, 0x0afc, unchecked((int)Convert.ToUInt32(test, 16)));
+ map(owner);
+ Expect(behaviorProfileSelections.Count == 0, "profile mapper leaves unknown/combined class 0x" + test + " unconfigured");
+ }
+ }
+ Console.WriteLine("native-behavior-profile-map: mapped-classes=" + cases.Length + " (including three bunker and two tower codes); unknown-and-combined=no callback");
+ }
+
+ private static void RunNativeBehaviorVarSyntheticCase(IntPtr module) {
+ NativeBehaviorVarArrayLoad load = (NativeBehaviorVarArrayLoad)Marshal.GetDelegateForFunctionPointer(
+ IntPtr.Add(module, BehaviorVarArrayLoadRva), typeof(NativeBehaviorVarArrayLoad));
+ NativeBehaviorVarFieldBind bind = (NativeBehaviorVarFieldBind)Marshal.GetDelegateForFunctionPointer(
+ IntPtr.Add(module, BehaviorVarFieldBindRva), typeof(NativeBehaviorVarFieldBind));
+ NativeBehaviorVarApply apply = (NativeBehaviorVarApply)Marshal.GetDelegateForFunctionPointer(
+ IntPtr.Add(module, BehaviorVarApplyRva), typeof(NativeBehaviorVarApply));
+ byte[] bytes = SerializeOneBehaviorVarField("Transfer_Ore_OffBoard", "float", 0x3f000000, 0x41200000);
+ using (Fixture fixture = NewFixture()) {
+ IntPtr table = fixture.Allocate(0x18);
+ IntPtr target = fixture.Allocate(sizeof(uint));
+ IntPtr input = fixture.Allocate(bytes.Length);
+ IntPtr cursorOut = fixture.Allocate(IntPtr.Size);
+ IntPtr fieldName = fixture.Allocate(32);
+ IntPtr typeName = fixture.Allocate(8);
+ CopyBytes(input, bytes);
+ WriteFixedAnsi(fieldName, 0, "Transfer_Ore_OffBoard", 32);
+ WriteFixedAnsi(typeName, 0, "float", 8);
+ Marshal.WriteIntPtr(table, IntPtr.Add(module, BehaviorVarTableVtableRva));
+ Marshal.WriteInt32(input, 0, 1);
+
+ Expect(load(table, input, cursorOut) == 1, "native .var parser accepts the synthetic one-row profile");
+ Expect(Marshal.ReadInt32(table, 4) == 1 && Marshal.ReadIntPtr(cursorOut) == IntPtr.Add(input, bytes.Length),
+ "native .var parser consumes the full synthetic profile");
+ bind(table, typeName, fieldName, target);
+ apply(table);
+ Expect(unchecked((uint)Marshal.ReadInt32(target)) == 0x41200000,
+ "native .var apply uses the second value blob after binding");
+ Console.WriteLine("native-behavior-var-synthetic: first=0x3f000000 second=0x41200000 applied=0x" +
+ unchecked((uint)Marshal.ReadInt32(target)).ToString("x8"));
+ }
+ }
+
+ private static void RunNativeBehaviorRealProfileCases(IntPtr module) {
+ string archivePath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_BEHAVIOR_PROFILE_RES");
+ if (String.IsNullOrEmpty(archivePath)) archivePath = @"C:\GOG Games\Parkan - Iron Strategy\behpsp.res";
+ if (!File.Exists(archivePath)) throw new FileNotFoundException(
+ "Set FPARKAN_ORIGINAL_BEHAVIOR_PROFILE_RES to the installed behpsp.res.", archivePath);
+ byte[] archiveBytes = File.ReadAllBytes(archivePath);
+ NativeBehaviorProfileMap map = (NativeBehaviorProfileMap)Marshal.GetDelegateForFunctionPointer(
+ IntPtr.Add(module, BehaviorProfileMapRva), typeof(NativeBehaviorProfileMap));
+ NativeBehaviorVarArrayLoad load = (NativeBehaviorVarArrayLoad)Marshal.GetDelegateForFunctionPointer(
+ IntPtr.Add(module, BehaviorVarArrayLoadRva), typeof(NativeBehaviorVarArrayLoad));
+ NativeBehaviorVarBlockConstructor construct = (NativeBehaviorVarBlockConstructor)Marshal.GetDelegateForFunctionPointer(
+ IntPtr.Add(module, BehaviorVarBlockConstructorRva), typeof(NativeBehaviorVarBlockConstructor));
+ NativeBehaviorVarFieldRegister register = (NativeBehaviorVarFieldRegister)Marshal.GetDelegateForFunctionPointer(
+ IntPtr.Add(module, BehaviorVarFieldRegisterRva), typeof(NativeBehaviorVarFieldRegister));
+ NativeBehaviorVarApply apply = (NativeBehaviorVarApply)Marshal.GetDelegateForFunctionPointer(
+ IntPtr.Add(module, BehaviorVarApplyRva), typeof(NativeBehaviorVarApply));
+
+ string selectedEntry = SelectBehaviorProfile(map, 0x80000004);
+ Expect(selectedEntry == "prof_mine.var", "mine profile is selected by native class mapper");
+ byte[] profile = ReadNresEntry(archiveBytes, selectedEntry);
+ Expect(ReadUInt32(profile, 0) == 27, "native mine profile contains all 27 rows");
+ uint expectedOreOffBoard = FindBehaviorVarSecondBlob(profile, "Transfer_Ore_OffBoard");
+ uint expectedPowerOut = FindBehaviorVarSecondBlob(profile, "Transfer_Power_Out");
+
+ using (Fixture fixture = NewFixture()) {
+ IntPtr table = fixture.Allocate(0x18);
+ IntPtr rawProfile = fixture.Allocate(profile.Length);
+ IntPtr cursorOut = fixture.Allocate(IntPtr.Size);
+ IntPtr block = fixture.Allocate(0x80);
+ CopyBytes(rawProfile, profile);
+ Marshal.WriteIntPtr(table, IntPtr.Add(module, BehaviorVarTableVtableRva));
+
+ Expect(load(table, rawProfile, cursorOut) == 1, "native parser accepts the licensed mine profile");
+ Expect(Marshal.ReadInt32(table, 4) == 27 && Marshal.ReadIntPtr(cursorOut) == IntPtr.Add(rawProfile, profile.Length),
+ "native parser consumes all rows in the licensed mine profile");
+ construct(block);
+ Expect(unchecked((uint)Marshal.ReadInt32(block, 0x58)) == 0x40a00000 &&
+ unchecked((uint)Marshal.ReadInt32(block, 0x60)) == 0x42c80000,
+ "native variable block starts with transfer defaults 5 and 100");
+ register(block, table);
+ apply(table);
+ Expect(unchecked((uint)Marshal.ReadInt32(block, 0x58)) == expectedOreOffBoard &&
+ unchecked((uint)Marshal.ReadInt32(block, 0x60)) == expectedPowerOut,
+ "native profile bind/apply copies second blobs into Transfer_Ore_OffBoard and Transfer_Power_Out");
+ Console.WriteLine("native-behavior-real-profile: class=80000004 entry=" + selectedEntry + " rows=27 Transfer_Ore_OffBoard=" +
+ Hex(expectedOreOffBoard) + " Transfer_Power_Out=" + Hex(expectedPowerOut) + "; native-bind-and-apply=verified");
+ }
+ }
+
+ private static string SelectBehaviorProfile(NativeBehaviorProfileMap map, uint classCode) {
+ using (Fixture fixture = NewFixture()) {
+ IntPtr owner = fixture.Allocate(0x0b00);
+ IntPtr vtable = fixture.Allocate(0x68);
+ Marshal.WriteIntPtr(owner, vtable);
+ Marshal.WriteIntPtr(vtable, 0x64, Marshal.GetFunctionPointerForDelegate(BehaviorProfileLoadCallback));
+ behaviorProfileSelections.Clear();
+ Marshal.WriteInt32(owner, 0x0afc, unchecked((int)classCode));
+ map(owner);
+ Expect(behaviorProfileSelections.Count == 1, "native mapper returned a single profile for class 0x" + classCode.ToString("x8"));
+ BehaviorProfileSelection selection = behaviorProfileSelections[0];
+ Expect(selection.Owner == owner && selection.Selector == 2 && selection.Archive == "behpsp.res",
+ "native mapper selected the behavior profile archive and selector");
+ return selection.Entry;
+ }
+ }
+
+ private static byte[] SerializeOneBehaviorVarField(string fieldName, string typeName, uint firstBits, uint secondBits) {
+ byte[] field = Encoding.ASCII.GetBytes(fieldName);
+ byte[] type = Encoding.ASCII.GetBytes(typeName);
+ using (MemoryStream stream = new MemoryStream()) using (BinaryWriter writer = new BinaryWriter(stream)) {
+ writer.Write((uint)1); // row count
+ writer.Write((uint)4); // value size
+ writer.Write((uint)3); // float type index
+ writer.Write((uint)0); // flags
+ writer.Write((uint)field.Length); writer.Write(field);
+ writer.Write((uint)type.Length); writer.Write(type);
+ writer.Write(firstBits); writer.Write(secondBits);
+ writer.Write((uint)0); writer.Write((uint)0); // trailing row metadata
+ return stream.ToArray();
+ }
+ }
+
+ private static byte[] ReadNresEntry(byte[] archive, string name) {
+ if (archive.Length < 16) throw new InvalidDataException("NRes archive is shorter than its header");
+ uint count = ReadUInt32(archive, 8);
+ if (count > Int32.MaxValue / 64) throw new InvalidDataException("NRes directory count is too large");
+ int directory = checked(archive.Length - (int)count * 64);
+ if (directory < 16) throw new InvalidDataException("NRes directory falls inside the header");
+ for (int index = 0; index < (int)count; index++) {
+ int record = checked(directory + index * 64);
+ int nameEnd = record + 20;
+ while (nameEnd < record + 56 && archive[nameEnd] != 0) nameEnd++;
+ string entryName = Encoding.ASCII.GetString(archive, record + 20, nameEnd - (record + 20));
+ if (!String.Equals(entryName, name, StringComparison.Ordinal)) continue;
+ uint size = ReadUInt32(archive, record + 12);
+ uint dataOffset = ReadUInt32(archive, record + 56);
+ if (size > Int32.MaxValue || dataOffset > Int32.MaxValue || dataOffset + size > archive.Length)
+ throw new InvalidDataException("NRes entry range is outside the archive");
+ byte[] data = new byte[(int)size];
+ Buffer.BlockCopy(archive, (int)dataOffset, data, 0, data.Length);
+ return data;
+ }
+ throw new InvalidDataException("NRes archive does not contain entry " + name);
+ }
+
+ private static uint FindBehaviorVarSecondBlob(byte[] profile, string fieldName) {
+ if (profile.Length < 4) throw new InvalidDataException("Behavior profile is shorter than its row count");
+ uint count = ReadUInt32(profile, 0);
+ if (count > 1024) throw new InvalidDataException("Behavior profile row count is unreasonable");
+ int cursor = 4;
+ for (int index = 0; index < (int)count; index++) {
+ EnsureRange(profile, cursor, 12);
+ uint size = ReadUInt32(profile, cursor);
+ if (size > Int32.MaxValue) throw new InvalidDataException("Behavior variable size is too large");
+ uint type = ReadUInt32(profile, cursor + 4);
+ cursor += 12;
+ uint fieldLength = ReadUInt32(profile, cursor);
+ if (fieldLength > Int32.MaxValue) throw new InvalidDataException("Behavior field name is too large");
+ cursor += 4; EnsureRange(profile, cursor, (int)fieldLength);
+ string name = Encoding.ASCII.GetString(profile, cursor, (int)fieldLength);
+ cursor += (int)fieldLength;
+ uint typeLength = ReadUInt32(profile, cursor);
+ if (typeLength > Int32.MaxValue) throw new InvalidDataException("Behavior variable type name is too large");
+ cursor += 4; EnsureRange(profile, cursor, (int)typeLength);
+ cursor += (int)typeLength;
+ EnsureRange(profile, cursor, checked((int)size * 2 + 8));
+ int secondBlob = checked(cursor + (int)size);
+ if (String.Equals(name, fieldName, StringComparison.Ordinal)) {
+ if (type != 3 || size != 4) throw new InvalidDataException("Expected float profile field " + fieldName);
+ return ReadUInt32(profile, secondBlob);
+ }
+ cursor = checked(cursor + (int)size * 2 + 8);
+ }
+ throw new InvalidDataException("Behavior profile does not contain field " + fieldName);
+ }
+
+ private static uint ReadUInt32(byte[] data, int offset) {
+ EnsureRange(data, offset, 4);
+ return unchecked((uint)(data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24)));
+ }
+
+ private static void EnsureRange(byte[] data, int offset, int size) {
+ if (offset < 0 || size < 0 || offset > data.Length - size) throw new InvalidDataException("Behavior profile range is outside its input");
+ }
+
+ private static void CopyBytes(IntPtr destination, byte[] source) { Marshal.Copy(source, 0, destination, source.Length); }
+
private static ushort CurrentX87ControlWord() {
if (x87ControlWordReader == null || x87ControlWordCell == IntPtr.Zero)
throw new InvalidOperationException("x87 control-word reader is not initialized");