From 194264903ab87d94a3d4f08aadfce413b30d083a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=82ngelo=20Tadeucci?= Date: Thu, 5 Nov 2026 14:26:29 -0300 Subject: [PATCH 1/3] Parse table/Server/globalEquipmentRankProbability.xml The server rolls the rank of a dropped item from this table when the drop row names no grade and the item has no fixed rank. One row per npcRank, npcLevel and assistCondition, with Rare, Elite, Excellent and Legendary percentages. KR and CN override some npcRank 4 rows, so rows resolve by locale on that key. Co-Authored-By: Claude Opus 5.5 --- Maple2.File.Parser/Maple2.File.Parser.csproj | 2 +- Maple2.File.Parser/ServerTableParser.cs | 13 +++++ .../Server/GlobalEquipmentRankProbability.cs | 24 +++++++++ Maple2.File.Tests/ServerTableParserTest.cs | 50 +++++++++++++++++++ 4 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 Maple2.File.Parser/Xml/Table/Server/GlobalEquipmentRankProbability.cs diff --git a/Maple2.File.Parser/Maple2.File.Parser.csproj b/Maple2.File.Parser/Maple2.File.Parser.csproj index 0441abb..58c23d3 100644 --- a/Maple2.File.Parser/Maple2.File.Parser.csproj +++ b/Maple2.File.Parser/Maple2.File.Parser.csproj @@ -13,7 +13,7 @@ MapleStory2, File, Parser, m2d, xml true - 2.4.35 + 2.4.36 net8.0 README.md enable diff --git a/Maple2.File.Parser/ServerTableParser.cs b/Maple2.File.Parser/ServerTableParser.cs index c959bd0..27e41ed 100644 --- a/Maple2.File.Parser/ServerTableParser.cs +++ b/Maple2.File.Parser/ServerTableParser.cs @@ -45,6 +45,7 @@ public class ServerTableParser { private readonly XmlSerializer groupSpawnSerializer; private readonly XmlSerializer shadowExpeditionDropSerializer; private readonly XmlSerializer additionalEffectGroupSerializer; + private readonly XmlSerializer globalEquipmentRankProbabilitySerializer; private readonly XmlSerializer fishSerializer; private readonly XmlSerializer fishingSpotSerializer; private readonly XmlSerializer fishLureSerializer; @@ -95,6 +96,7 @@ public ServerTableParser(M2dReader xmlReader) { groupSpawnSerializer = new XmlSerializer(typeof(GroupSpawnRoot)); shadowExpeditionDropSerializer = new XmlSerializer(typeof(ShadowExpeditionDropRoot)); additionalEffectGroupSerializer = new XmlSerializer(typeof(AdditionalEffectGroupRoot)); + globalEquipmentRankProbabilitySerializer = new XmlSerializer(typeof(GlobalEquipmentRankProbabilityRoot)); fishSerializer = new XmlSerializer(typeof(FishRoot)); fishingSpotSerializer = new XmlSerializer(typeof(FishingSpotRoot)); fishLureSerializer = new XmlSerializer(typeof(FishLureRoot)); @@ -537,6 +539,17 @@ public IEnumerable ParseShadowExpeditionDrop() { } } + public IEnumerable ParseGlobalEquipmentRankProbability() { + string xml = Sanitizer.RemoveEmpty(xmlReader.GetString(xmlReader.GetEntry("table/Server/globalEquipmentRankProbability.xml"))); + var reader = XmlReader.Create(new StringReader(xml)); + var data = globalEquipmentRankProbabilitySerializer.Deserialize(reader) as GlobalEquipmentRankProbabilityRoot; + Debug.Assert(data != null); + + foreach (GlobalEquipmentRankProbability row in data.DropProbability) { + yield return row; + } + } + public IEnumerable<(int Id, Fish Fish)> ParseFish() { string xml = Sanitizer.RemoveEmpty(xmlReader.GetString(xmlReader.GetEntry("table/Server/fish.xml"))); var reader = XmlReader.Create(new StringReader(xml)); diff --git a/Maple2.File.Parser/Xml/Table/Server/GlobalEquipmentRankProbability.cs b/Maple2.File.Parser/Xml/Table/Server/GlobalEquipmentRankProbability.cs new file mode 100644 index 0000000..150a17e --- /dev/null +++ b/Maple2.File.Parser/Xml/Table/Server/GlobalEquipmentRankProbability.cs @@ -0,0 +1,24 @@ +using System.Xml.Serialization; +using M2dXmlGenerator; + +namespace Maple2.File.Parser.Xml.Table.Server; + +// ./data/server/table/Server/globalEquipmentRankProbability.xml +// The rank an item drops at when its drop row names no grade and the item has no fixed rank. One row per +// npcRank, npcLevel and assistCondition; KR and CN override some npcRank 4 rows. +[XmlRoot("ms2")] +public partial class GlobalEquipmentRankProbabilityRoot { + [M2dFeatureLocale(Name = "DropProbability", Selector = "npcRank|npcLevel|assistCondition")] + private IList _dropProbability; +} + +// The four columns are percentages of ranks 2 to 5; whatever they leave is rank 1. An empty cell is 0. +public partial class GlobalEquipmentRankProbability : IFeatureLocale { + [XmlAttribute] public int npcRank; + [XmlAttribute] public int npcLevel; + [XmlAttribute] public int assistCondition; + [XmlAttribute] public float Rare; + [XmlAttribute] public float Elite; + [XmlAttribute] public float Excellent; + [XmlAttribute] public float Legendary; +} diff --git a/Maple2.File.Tests/ServerTableParserTest.cs b/Maple2.File.Tests/ServerTableParserTest.cs index a037be6..a3e55d1 100644 --- a/Maple2.File.Tests/ServerTableParserTest.cs +++ b/Maple2.File.Tests/ServerTableParserTest.cs @@ -1,6 +1,7 @@ using System.Numerics; using Maple2.File.Parser; using Maple2.File.Parser.Enum; +using Maple2.File.Parser.Tools; using Maple2.File.Parser.Xml.Table; using Maple2.File.Parser.Xml.Table.Server; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -309,6 +310,55 @@ public void TestAdditionalEffectGroup() { CollectionAssert.AreEqual(new short[] { 2 }, groups[1].value[3].effectLevel); } + [TestMethod] + public void TestGlobalEquipmentRankProbability() { + var parser = new ServerTableParser(TestUtils.ServerReader); + + Filter.Load(TestUtils.XmlReader, "NA", "Live"); + Dictionary<(int, int, int), GlobalEquipmentRankProbability> na = parser.ParseGlobalEquipmentRankProbability() + .ToDictionary(row => (row.npcRank, row.npcLevel, row.assistCondition)); + Filter.Load(TestUtils.XmlReader, "KR", "Live"); + Dictionary<(int, int, int), GlobalEquipmentRankProbability> kr = parser.ParseGlobalEquipmentRankProbability() + .ToDictionary(row => (row.npcRank, row.npcLevel, row.assistCondition)); + + // 18 npcRanks x levels 1-99 x assistCondition 0/1. The KR and CN rows replace rows rather than add them. + Assert.AreEqual(18 * 99 * 2, na.Count); + Assert.AreEqual(18 * 99 * 2, kr.Count); + + // line 41: npcRank="1" npcLevel="20" assistCondition="0" Rare="16.5" Elite="" Excellent="" Legendary="" + AssertRow(na[(1, 20, 0)], 16.5f, 0f, 0f, 0f); + + // line 437: npcRank="4" npcLevel="20" assistCondition="0" Rare="80" Elite="20", not its KR or CN override + AssertRow(na[(4, 20, 0)], 80f, 20f, 0f, 0f); + // line 635: the same key with locale="KR": Rare="100" Elite="0" Excellent="0" + AssertRow(kr[(4, 20, 0)], 100f, 0f, 0f, 0f); + // lines 488 and 686: npcRank="4" npcLevel="45" assistCondition="1", NA then KR + AssertRow(na[(4, 45, 1)], 80f, 20f, 0f, 0f); + AssertRow(kr[(4, 45, 1)], 0f, 50f, 50f, 0f); + // KR overrides only npcRank 4. + AssertRow(kr[(1, 20, 0)], 16.5f, 0f, 0f, 0f); + + // line 1013: npcRank="6" npcLevel="11" assistCondition="0" Rare="16" Elite="83.99999" Excellent="1E-05" Legendary="0" + AssertRow(na[(6, 11, 0)], 16f, 83.99999f, 1E-05f, 0f); + // lines 1051 and 1052: assistCondition 0 and 1 differ for npcRank="6" npcLevel="30" + AssertRow(na[(6, 30, 0)], 0f, 99.99999f, 1E-05f, 0f); + AssertRow(na[(6, 30, 1)], 0f, 0f, 100f, 0f); + + // line 1229: npcRank="7" npcLevel="20" assistCondition="0" Rare="16.52" Elite="0.00826446280991736" Excellent="0.000208" + AssertRow(na[(7, 20, 0)], 16.52f, 0.00826446280991736f, 0.000208f, 0f); + // line 1873: npcRank="90" npcLevel="45" assistCondition="0" Rare="93.8999" Elite="6" Excellent="0.1" Legendary="0.0001" + AssertRow(na[(90, 45, 0)], 93.8999f, 6f, 0.1f, 0.0001f); + // line 3962, the last row: npcRank="920" npcLevel="99" assistCondition="1" Excellent="100" + AssertRow(na[(920, 99, 1)], 0f, 0f, 100f, 0f); + } + + private static void AssertRow(GlobalEquipmentRankProbability row, float rare, float elite, float excellent, float legendary) { + Assert.AreEqual(rare, row.Rare); + Assert.AreEqual(elite, row.Elite); + Assert.AreEqual(excellent, row.Excellent); + Assert.AreEqual(legendary, row.Legendary); + } + [TestMethod] public void TestSpawnInteractObject() { var parser = new ServerTableParser(TestUtils.ServerReader); From 62ad4b4093ff0aeb76630bcf484418fc742db747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=82ngelo=20Tadeucci?= Date: Thu, 5 Nov 2026 14:48:37 -0300 Subject: [PATCH 2/3] Read an item with no rank attribute as rank 0 The client's CItemData starts rank at 0 and reads 0 as no fixed rank: a dropped item with no fixed rank rolls its rank from globalEquipmentRankProbability.xml. Defaulting to 1 made every unranked item look like an explicit rank="1". Co-Authored-By: Claude Opus 5.5 --- Maple2.File.Parser/Xml/Item/Property.cs | 4 +++- Maple2.File.Tests/ItemParserTest.cs | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/Maple2.File.Parser/Xml/Item/Property.cs b/Maple2.File.Parser/Xml/Item/Property.cs index 1d26778..4f684f4 100644 --- a/Maple2.File.Parser/Xml/Item/Property.cs +++ b/Maple2.File.Parser/Xml/Item/Property.cs @@ -5,7 +5,9 @@ namespace Maple2.File.Parser.Xml.Item; public partial class Property { [XmlAttribute] public bool skin; - [XmlAttribute] public short rank = 1; + // 0 when the item names no rank, as the client's CItemData starts it. A drop then rolls the rank from + // globalEquipmentRankProbability.xml, so 0 must stay apart from an explicit rank="1". + [XmlAttribute] public short rank; [XmlAttribute] public int skinType; [XmlAttribute] public int slotMax = 1; // if > 99999, set to 99999 [XmlAttribute] public int type; diff --git a/Maple2.File.Tests/ItemParserTest.cs b/Maple2.File.Tests/ItemParserTest.cs index 5eb9fe7..5b2a937 100644 --- a/Maple2.File.Tests/ItemParserTest.cs +++ b/Maple2.File.Tests/ItemParserTest.cs @@ -31,6 +31,21 @@ public void TestItemParser() { Assert.AreEqual(35309, count); } + [TestMethod] + public void TestItemRank() { + Filter.Load(TestUtils.XmlReader, Locale.NA.ToString(), "Live"); + var parser = new ItemParser(TestUtils.XmlReader, "en"); + + Dictionary ranks = parser.Parse() + .Where(entry => entry.Id is 11300106 or 11300001 or 11300003) + .ToDictionary(entry => entry.Id, entry => entry.Data.property.rank); + + // 11300106 Apprentice Hat names no rank; 11300001 has , 11300003 . + Assert.AreEqual(0, ranks[11300106]); + Assert.AreEqual(2, ranks[11300001]); + Assert.AreEqual(3, ranks[11300003]); + } + [TestMethod] public void TestItemParserNew() { var locale = Locale.KR; From 353389fba3eb62ffb5dfd8dbbaef6d70e632b6ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=82ngelo=20Tadeucci?= Date: Thu, 5 Nov 2026 15:15:45 -0300 Subject: [PATCH 3/3] Cover the CN override and an explicit rank 1 in the parser tests Co-Authored-By: Claude Opus 5.5 --- Maple2.File.Tests/ItemParserTest.cs | 6 ++++-- Maple2.File.Tests/ServerTableParserTest.cs | 6 ++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Maple2.File.Tests/ItemParserTest.cs b/Maple2.File.Tests/ItemParserTest.cs index 5b2a937..6f4c557 100644 --- a/Maple2.File.Tests/ItemParserTest.cs +++ b/Maple2.File.Tests/ItemParserTest.cs @@ -37,11 +37,13 @@ public void TestItemRank() { var parser = new ItemParser(TestUtils.XmlReader, "en"); Dictionary ranks = parser.Parse() - .Where(entry => entry.Id is 11300106 or 11300001 or 11300003) + .Where(entry => entry.Id is 11300106 or 13200172 or 11300001 or 11300003) .ToDictionary(entry => entry.Id, entry => entry.Data.property.rank); - // 11300106 Apprentice Hat names no rank; 11300001 has , 11300003 . + // 11300106 Apprentice Hat names no rank. 13200172's NA environment has , + // 11300001 and 11300003 . Assert.AreEqual(0, ranks[11300106]); + Assert.AreEqual(1, ranks[13200172]); Assert.AreEqual(2, ranks[11300001]); Assert.AreEqual(3, ranks[11300003]); } diff --git a/Maple2.File.Tests/ServerTableParserTest.cs b/Maple2.File.Tests/ServerTableParserTest.cs index a3e55d1..3289ebc 100644 --- a/Maple2.File.Tests/ServerTableParserTest.cs +++ b/Maple2.File.Tests/ServerTableParserTest.cs @@ -320,10 +320,14 @@ public void TestGlobalEquipmentRankProbability() { Filter.Load(TestUtils.XmlReader, "KR", "Live"); Dictionary<(int, int, int), GlobalEquipmentRankProbability> kr = parser.ParseGlobalEquipmentRankProbability() .ToDictionary(row => (row.npcRank, row.npcLevel, row.assistCondition)); + Filter.Load(TestUtils.XmlReader, "CN", "Live"); + Dictionary<(int, int, int), GlobalEquipmentRankProbability> cn = parser.ParseGlobalEquipmentRankProbability() + .ToDictionary(row => (row.npcRank, row.npcLevel, row.assistCondition)); // 18 npcRanks x levels 1-99 x assistCondition 0/1. The KR and CN rows replace rows rather than add them. Assert.AreEqual(18 * 99 * 2, na.Count); Assert.AreEqual(18 * 99 * 2, kr.Count); + Assert.AreEqual(18 * 99 * 2, cn.Count); // line 41: npcRank="1" npcLevel="20" assistCondition="0" Rare="16.5" Elite="" Excellent="" Legendary="" AssertRow(na[(1, 20, 0)], 16.5f, 0f, 0f, 0f); @@ -332,6 +336,8 @@ public void TestGlobalEquipmentRankProbability() { AssertRow(na[(4, 20, 0)], 80f, 20f, 0f, 0f); // line 635: the same key with locale="KR": Rare="100" Elite="0" Excellent="0" AssertRow(kr[(4, 20, 0)], 100f, 0f, 0f, 0f); + // line 833: the same key with locale="CN": Rare="16.5" + AssertRow(cn[(4, 20, 0)], 16.5f, 0f, 0f, 0f); // lines 488 and 686: npcRank="4" npcLevel="45" assistCondition="1", NA then KR AssertRow(na[(4, 45, 1)], 80f, 20f, 0f, 0f); AssertRow(kr[(4, 45, 1)], 0f, 50f, 50f, 0f);