From 60f38981dfd95642f999ab9511c70bc82496aae7 Mon Sep 17 00:00:00 2001
From: Yohann Gayao <68230605+DerpyNewb@users.noreply.github.com>
Date: Wed, 21 Oct 2026 06:36:57 +0800
Subject: [PATCH 1/4] Add a read model for vfx effect files
VfxFile reads a vfx/*.xml effect into its emitters, behaviour chains, render stacks and references. VfxCurve parses the curve properties: channels, keys and the min/max mappings.
VfxCurveTests and VfxFileTests cover both; VfxTestFiles builds small vfx files and packs for them.
---
.../GameWorld.CoreTest/Vfx/VfxCurveTests.cs | 85 +++++++
.../GameWorld.CoreTest/Vfx/VfxFileTests.cs | 160 +++++++++++++
.../GameWorld.CoreTest/Vfx/VfxTestFiles.cs | 68 ++++++
Shared/GameFiles/Vfx/VfxCurve.cs | 143 ++++++++++++
Shared/GameFiles/Vfx/VfxFile.cs | 210 ++++++++++++++++++
5 files changed, 666 insertions(+)
create mode 100644 GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxCurveTests.cs
create mode 100644 GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxFileTests.cs
create mode 100644 GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxTestFiles.cs
create mode 100644 Shared/GameFiles/Vfx/VfxCurve.cs
create mode 100644 Shared/GameFiles/Vfx/VfxFile.cs
diff --git a/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxCurveTests.cs b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxCurveTests.cs
new file mode 100644
index 000000000..53265d359
--- /dev/null
+++ b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxCurveTests.cs
@@ -0,0 +1,85 @@
+using System.Globalization;
+using Shared.GameFormats.Vfx;
+
+namespace GameWorld.Core.Test.Vfx
+{
+ public class VfxCurveTests
+ {
+ // One output between a min and a max channel, each running from (0, 0) to (1, 1); the
+ // segment eases by the first key's out-interpolation.
+ static string Segment(string interpolation, string random = "0") =>
+ $"x_min,8405056,0;0.0,0.0,{interpolation},{interpolation},-0.05,0.0,0.05,0.0,1;1.0,1.0,linear,linear,-0.05,0.0,0.05,0.0,1" +
+ $"|x_max,16728128,0;0.0,0.0,{interpolation},{interpolation},-0.05,0.0,0.05,0.0,1;1.0,1.0,linear,linear,-0.05,0.0,0.05,0.0,1:x,{random};0,1";
+
+ [TestCase("linear", 0.25f)]
+ [TestCase("step", 0f)]
+ [TestCase("step_next", 1f)]
+ [TestCase("s", 0.15625f)]
+ [TestCase("s_inv", 0.32635f)]
+ [TestCase("sine", 0.14645f)]
+ [TestCase("sine_recip", 0.33333f)]
+ [TestCase("log1", 0.35737f)]
+ [TestCase("log3", 0.5843f)]
+ [TestCase("exp1", 0.1653f)]
+ [TestCase("exp3", 0.05853f)]
+ [TestCase("bezier_c", 0.23813f)]
+ [TestCase("bezier_q", 0.23813f)]
+ public void Interpolation_EndsAtItsKeys_AndShapesTheSegment(string interpolation, float atQuarter)
+ {
+ var curve = VfxCurve.Parse(Segment(interpolation));
+ Assert.That(curve, Is.Not.Null);
+ Assert.Multiple(() =>
+ {
+ Assert.That(curve.Evaluate(0, 0, 0), Is.EqualTo(0).Within(1e-4));
+ Assert.That(curve.Evaluate(0, 0.25f, 0), Is.EqualTo(atQuarter).Within(1e-3));
+ Assert.That(curve.Evaluate(0, 1, 0), Is.EqualTo(1).Within(1e-4));
+ });
+ }
+
+ [Test]
+ public void KeysOutOfTimeOrder_AreSortedFirst()
+ {
+ // 105k vanilla channels list their keys like this: the key at 1.0 before the key at 0.0.
+ var curve = VfxCurve.Parse(
+ "x_min,0,0;1.0,10.0,linear,linear,-0.05,0.0,0.05,0.0,1;0.0,0.0,linear,linear,-0.05,0.0,0.05,0.0,1" +
+ "|x_max,0,0;1.0,10.0,linear,linear,-0.05,0.0,0.05,0.0,1;0.0,0.0,linear,linear,-0.05,0.0,0.05,0.0,1:x,0;0,1");
+ Assert.That(curve.Evaluate(0, 0.5f, 0), Is.EqualTo(5).Within(1e-4));
+ }
+
+ [TestCase("1", 12.5f, Description = "random 1: a point between min and max")]
+ [TestCase("0", 10f, Description = "random 0: the min")]
+ public void MinMax_FollowTheRandomFlag(string random, float expected)
+ {
+ var curve = VfxCurve.Parse(
+ $"x_min,0,0;0.0,10.0,linear,linear,-0.05,0.0,0.05,0.0,1|x_max,0,0;0.0,20.0,linear,linear,-0.05,0.0,0.05,0.0,1:x,{random};0,1");
+ Assert.That(curve.Evaluate(0, 0.5f, 0.25f), Is.EqualTo(expected).Within(1e-4));
+ }
+
+ [Test]
+ public void Outputs_ReadTheirOwnChannels_AndAMissingOneFallsBack()
+ {
+ var curve = VfxCurve.Parse(
+ "x_min,0,0;0.0,1.0,linear,linear,0,0,0,0,1|x_max,0,0;0.0,1.0,linear,linear,0,0,0,0,1" +
+ "|y_min,0,0;0.0,2.0,linear,linear,0,0,0,0,1|y_max,0,0;0.0,2.0,linear,linear,0,0,0,0,1:x,0;0,1|y,0;2,3");
+ Assert.That((curve.Evaluate(0, 0, 0), curve.Evaluate(1, 0, 0), curve.Evaluate(2, 0, 0, fallback: 7)), Is.EqualTo((1f, 2f, 7f)));
+ }
+
+ [Test]
+ public void Constant_HoldsOneValuePerOutput()
+ {
+ var curve = VfxCurve.Constant(3, 4);
+ Assert.That((curve.Evaluate(0, 0.7f, 0.9f), curve.Evaluate(1, 0.2f, 0.1f)), Is.EqualTo((3f, 4f)));
+ }
+
+ [TestCase("", Description = "empty")]
+ [TestCase("garbage", Description = "no mapping")]
+ [TestCase("x_min,0,0;0.0,1.0|x_max,0,0;0.0,1.0:x,0;0,1", Description = "keys with 2 fields, not 9")]
+ [TestCase("x_min,0,0;0.0,1.0,linear,linear,0,0,0,0:x,0;0,0", Description = "a key with 8 fields, not 9")]
+ [TestCase("x_min,0,0;0.0,1.0,linear,linear,0,0,0,0,1:x,0;0,3", Description = "a mapping past the last channel")]
+ [TestCase("x_min,0,0;zero,1.0,linear,linear,0,0,0,0,1:x,0;0,0", Description = "a number that is not one")]
+ // float.Parse reads these two as infinity and NaN; a modded rate of either hung the spawn loop.
+ [TestCase("x_min,0,0;0.0,1e39,linear,linear,0,0,0,0,1:x,0;0,0", Description = "a value past float's range")]
+ [TestCase("x_min,0,0;0.0,NaN,linear,linear,0,0,0,0,1:x,0;0,0", Description = "NaN")]
+ public void BrokenText_DoesNotParse(string text) => Assert.That(VfxCurve.Parse(text), Is.Null);
+ }
+}
diff --git a/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxFileTests.cs b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxFileTests.cs
new file mode 100644
index 000000000..2b8395e6e
--- /dev/null
+++ b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxFileTests.cs
@@ -0,0 +1,160 @@
+using System.Text;
+using Microsoft.Xna.Framework;
+using Shared.GameFormats.Vfx;
+
+namespace GameWorld.Core.Test.Vfx
+{
+ public class VfxFileTests
+ {
+ [Test]
+ public void A_tier_takes_its_override_and_the_base_where_it_has_none()
+ {
+ var bytes = VfxTestFiles.Xml(
+ emitters: VfxTestFiles.Emitter("e", """"""),
+ chains: """""");
+ var high = VfxFile.Parse(bytes, tier: 1);
+ var properties = high.Emitters[0].Properties;
+ Assert.That((properties["A"].Value, properties["B"].Value, properties.ContainsKey("C")), Is.EqualTo(("2", "3", false)));
+ Assert.That(high.Chains["e"].Modifiers.Single().Fragment, Is.EqualTo("vs_colour"));
+ Assert.That(VfxFile.Parse(bytes, tier: 2).Chains.ContainsKey("e"), Is.False); // no block 2: draws nothing at Medium
+ Assert.That(VfxFile.Parse(bytes).Emitters[0].Properties["A"].Value, Is.EqualTo("1"));
+ }
+
+ // continuous_rigid particles are meshes: RigidData lists the models, one picked per particle; an entry naming none is dropped.
+ [Test]
+ public void Rigid_data_lists_each_model_with_its_pivot_and_animation()
+ {
+ var rigid = """""";
+ var emitter = VfxFile.Parse(VfxTestFiles.Xml(emitters: VfxTestFiles.Emitter("e", rigid))).Emitters[0];
+ Assert.That(emitter.Rigid, Is.EqualTo(new[]
+ {
+ new VfxRigidEntry("vfx/models/rock.wsmodel", new Vector3(0.5f, 0.25f, 0.5f), null),
+ new VfxRigidEntry("vfx/models/rat.wsmodel", new Vector3(0.5f), "animations/rat.anim", 2.5f),
+ }));
+ }
+
+ // property_event: OnParticleSpawn and OnParticleDeath name an effect and what of the particle it follows; one naming none
+ // ("" on 80 vanilla events) starts nothing.
+ [Test]
+ public void Particle_events_name_an_effect_and_what_it_follows()
+ {
+ var events = """""";
+ var emitter = VfxFile.Parse(VfxTestFiles.Xml(emitters: VfxTestFiles.Emitter("e", events))).Emitters[0];
+ Assert.That(emitter.Events, Is.EqualTo(new[] { new VfxParticleEvent(false, "trail", true, false, true, false, true, VfxEventTurn.Velocity, -0.3f) }));
+ }
+
+ [TestCase("")]
+ [TestCase("0")]
+ public void An_event_naming_no_effect_starts_nothing(string name)
+ {
+ var events = $"""""";
+ Assert.That(VfxFile.Parse(VfxTestFiles.Xml(emitters: VfxTestFiles.Emitter("e", events))).Emitters[0].Events, Is.Empty);
+ }
+
+ // ref_spawn_speed="-1.00" is CA's unset; 0 is a speed.
+ [TestCase("", null)]
+ [TestCase(" ref_spawn_speed=\"-1.00\"", null)]
+ [TestCase(" ref_spawn_speed=\"0.00\"", 0f)]
+ [TestCase(" ref_spawn_speed=\"30.00\"", 30f)]
+ public void Ref_spawn_speed_of_minus_one_is_unset(string root, float? expected) =>
+ Assert.That(VfxFile.Parse(VfxTestFiles.Xml(root: root)).RefSpawnSpeed, Is.EqualTo(expected));
+
+ // No vanilla chain lacks a value="0" block; one a modder wrote without it draws nothing at Ultra in game.
+ [Test]
+ public void Ultra_reads_no_other_tiers_block()
+ {
+ var bytes = VfxTestFiles.Xml(
+ emitters: VfxTestFiles.Emitter("e", """"""),
+ chains: """""");
+ Assert.That(VfxFile.Parse(bytes).Chains.ContainsKey("e"), Is.False);
+ }
+
+ const string Sample = """
+
+
+
+
+
+ x_min,0,0;0.0,10.0,linear,linear,-0.05,0.0,0.05,0.0,1|x_max,0,0;0.0,10.0,linear,linear,-0.05,0.0,0.05,0.0,1:x,0;0,1
+ x_min,0,0;0.0,5.0,linear,linear,-0.05,0.0,0.05,0.0,1|x_max,0,0;0.0,5.0,linear,linear,-0.05,0.0,0.05,0.0,1:x,0;0,1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ // CA writes these files as UTF-16 with a byte order mark.
+ static VfxFile Parse(string text) => VfxFile.Parse([.. Encoding.Unicode.GetPreamble(), .. Encoding.Unicode.GetBytes(text)]);
+
+ [Test]
+ public void Emitter_ReadsQualityZeroOnly_InEveryValueShape()
+ {
+ var emitter = Parse(Sample).Emitters.Single();
+ Assert.Multiple(() =>
+ {
+ Assert.That((emitter.Id, emitter.Type), Is.EqualTo(("smoke", "continuous_mesh")));
+ Assert.That(emitter.Properties.ContainsKey("Enable"), Is.False, "a quality-2-only property is not Ultra's");
+ Assert.That(emitter.Properties["CullDistances"].Number("z"), Is.EqualTo(700));
+ Assert.That(VfxCurve.Parse(emitter.Properties["EmissionRateCurve"].Text).Evaluate(0, 0, 0), Is.EqualTo(10), "quality 0's curve, not quality 1's");
+ Assert.That((emitter.Properties["LifeTime"].Number("min"), emitter.Properties["LifeTime"].Number("max")), Is.EqualTo((1f, 2f)));
+ Assert.That(emitter.Properties["ParticleSimulationSpace"].Value, Is.EqualTo("LOCAL_SPACE_POSITION_ROTATION"));
+ });
+ }
+
+ [Test]
+ public void Chain_ReadsTheQualityZeroBlock_WhereverItSits()
+ {
+ var chain = Parse(Sample).Chains["smoke"];
+ Assert.That(chain.Find("ps_texture").Textures["diffuse"], Is.EqualTo(@"vfx\textures\smoke_dense_loop_diffuse.dds"));
+ Assert.That(chain.Find("vs_animated_texture").Properties["no_cells_xy"].Float2(Vector2.One), Is.EqualTo(new Vector2(8, 4)));
+ }
+
+ [Test]
+ public void RenderStack_KeepsItsSceneViewType() =>
+ Assert.That(Parse(Sample).SceneViewTypes, Is.EqualTo(new Dictionary { ["ripple"] = "DISTORTION" }));
+
+ [Test]
+ public void Reference_ReadsTransformRangesAndOverrides()
+ {
+ var reference = Parse(Sample).References.Single();
+ Assert.Multiple(() =>
+ {
+ Assert.That(reference.Target, Is.EqualTo("wh3_dlc23_lib_warmachines"));
+ Assert.That((reference.ScaleMin, reference.ScaleMax, reference.StartTimeMin, reference.SimulationSpeed), Is.EqualTo((1f, 2f, -1f, 0.5f)));
+ Assert.That((reference.Position, reference.RotationDegrees), Is.EqualTo((new Vector3(0.5f, 0, 0), new Vector3(0, 90, 0))));
+ Assert.That(reference.InfiniteLife, Is.True);
+ Assert.That(reference.Overrides["base_red_smoke"].Enable, Is.False);
+ var fire = reference.Overrides["furnace_fire"];
+ Assert.That((fire.TintMin.X, fire.ParticleLifetimeBiasMax, fire.SpawnRateScaleMin), Is.EqualTo((0.5f, 0.2f, 2f)));
+ });
+ }
+
+ [Test]
+ public void Utf8WithoutBom_ParsesTheSame() =>
+ Assert.That(VfxFile.Parse(Encoding.UTF8.GetBytes(Sample)).Emitters.Single().Id, Is.EqualTo("smoke"));
+ }
+}
diff --git a/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxTestFiles.cs b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxTestFiles.cs
new file mode 100644
index 000000000..b90a92498
--- /dev/null
+++ b/GameWorld/GameWorldCore/GameWorld.CoreTest/Vfx/VfxTestFiles.cs
@@ -0,0 +1,68 @@
+using System.Text;
+using Microsoft.Xna.Framework;
+using Shared.Core.PackFiles;
+using Shared.Core.PackFiles.Models;
+using Shared.Core.Settings;
+
+namespace GameWorld.Core.Test.Vfx
+{
+ // Small vfx files written the way CA's are (UTF-16 with a byte order mark, several root
+ // elements), in an in-memory pack under vfx/.
+ static class VfxTestFiles
+ {
+ public static byte[] Xml(string emitters = "", string chains = "", string references = "", string stacks = "", string root = "") =>
+ [
+ .. Encoding.Unicode.GetPreamble(),
+ .. Encoding.Unicode.GetBytes(
+ $"""{emitters}{chains}{stacks}{references}"""),
+ ];
+
+ public static string Emitter(string id, string properties = "") =>
+ $"""{properties}""";
+
+ public static string Reference(string target, string posX = "0.00", string posY = "0.00", string rotY = "0.00", string overrides = "", bool infinite = true) =>
+ $"""{overrides}""";
+
+ public static string Override(string name, bool enable = true, string tint = "1.00") =>
+ $"""""";
+
+ /// A vector field volume laid out as vanilla's: uncompressed A16B16G16R16F, a volume, no mips; x along a row, y
+ /// down the rows, z through the slices.
+ public static byte[] Field(int width, int height, int depth, Func at)
+ {
+ var bytes = new byte[128 + width * height * depth * 8];
+ "DDS "u8.CopyTo(bytes);
+ foreach (var (offset, value) in new[] { (4, 124), (8, 0x80100F), (12, height), (16, width), (24, depth), (76, 32), (80, 4), (84, 113), (112, 0x200000) })
+ BitConverter.TryWriteBytes(bytes.AsSpan(offset), value); // size, flags, height, width, depth, pixel format, FourCC, D3DFMT_A16B16G16R16F, volume
+ var n = 128;
+ for (var z = 0; z < depth; z++)
+ for (var y = 0; y < height; y++)
+ for (var x = 0; x < width; x++, n += 8)
+ {
+ var v = at(x, y, z);
+ BitConverter.TryWriteBytes(bytes.AsSpan(n), (Half)v.X);
+ BitConverter.TryWriteBytes(bytes.AsSpan(n + 2), (Half)v.Y);
+ BitConverter.TryWriteBytes(bytes.AsSpan(n + 4), (Half)v.Z);
+ }
+ return bytes;
+ }
+
+ /// Files under vfx/; a name may carry folders below it ("materials/x.xml.material").
+ public static IPackFileService Pack(params (string Name, byte[] Bytes)[] files) => Pack(files.Select(f =>
+ {
+ var path = "vfx/" + f.Name;
+ var slash = path.LastIndexOf('/');
+ return new NewPackFileEntry(path[..slash], PackFile.CreateFromBytes(path[(slash + 1)..], f.Bytes));
+ }));
+
+ public static IPackFileService Pack(IEnumerable files) => Pack(files.Select(f => new NewPackFileEntry("vfx", f)));
+
+ static IPackFileService Pack(IEnumerable entries)
+ {
+ var packFileService = new PackFileService(null) { EnforceGameFilesMustBeLoaded = false };
+ var container = packFileService.CreateNewPackFileContainer("output", PackFileVersion.PFH5, PackFileCAType.MOD, true);
+ packFileService.AddFilesToPack(container, entries.ToList());
+ return packFileService;
+ }
+ }
+}
diff --git a/Shared/GameFiles/Vfx/VfxCurve.cs b/Shared/GameFiles/Vfx/VfxCurve.cs
new file mode 100644
index 000000000..fe8f64524
--- /dev/null
+++ b/Shared/GameFiles/Vfx/VfxCurve.cs
@@ -0,0 +1,143 @@
+using System.Globalization;
+
+namespace Shared.GameFormats.Vfx
+{
+ ///
+ /// A curve-valued vfx property: "channel|channel:mapping|mapping". A channel is "name,colour,flag;key;key",
+ /// a key "time,value,inInterp,outInterp,tanInX,tanInY,tanOutX,tanOutY,flag", a mapping
+ /// "output,random;minChannel,maxChannel". An output lies between its min and max channels: random 1
+ /// draws a point between them, 0 uses the min. Measured over vfx_desc.pack (9.0): 520,858 of 524,102
+ /// random-0 mappings have identical channels, and 126,495 of 150,773 random-1 mappings differ.
+ ///
+ public class VfxCurve
+ {
+ public record Key(float Time, float Value, string In, string Out, float TanInX, float TanInY, float TanOutX, float TanOutY);
+ public record Output(string Name, bool Random, Key[] Min, Key[] Max);
+
+ public IReadOnlyList