From 38c909eb658f77a9fc3223ac297e4c1f11a4c1e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=82ngelo=20Tadeucci?= Date: Fri, 28 Aug 2026 07:08:13 -0300 Subject: [PATCH 1/2] Parse the fieldPortal section of table/FieldMetaData.xml The client builds its sub-map to exploration-zone mapping from this section at startup, and PrivateMaple2 needs the same table to stop reconstructing that mapping from map properties and runtime portal targets. Xml/Table/FieldMetaData.cs models the fieldPortal entries and their portal children. Only that section is modelled; the file also carries npc, interactObject, fluid, liftable, pet and taxiStation sections that nothing consumes yet. ParseFieldMetaData on TableParser yields (field id, entry), matching the shape of the other table parsers. TableParserTest pins the shipped counts and the portalIndoor edge out of Turtcoli Cave, so a parse that silently drops indoor or portalIndoor fails rather than changing consumer behaviour quietly. Co-Authored-By: Claude Opus 5 (1M context) --- Maple2.File.Parser/Maple2.File.Parser.csproj | 2 +- Maple2.File.Parser/TableParser.cs | 13 ++++++++ Maple2.File.Parser/Xml/Table/FieldMetaData.cs | 31 +++++++++++++++++++ Maple2.File.Tests/TableParserTest.cs | 26 ++++++++++++++++ 4 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 Maple2.File.Parser/Xml/Table/FieldMetaData.cs diff --git a/Maple2.File.Parser/Maple2.File.Parser.csproj b/Maple2.File.Parser/Maple2.File.Parser.csproj index 0970e28..c1af205 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.22 + 2.4.23 net8.0 README.md enable diff --git a/Maple2.File.Parser/TableParser.cs b/Maple2.File.Parser/TableParser.cs index 0f3009d..4c126e9 100644 --- a/Maple2.File.Parser/TableParser.cs +++ b/Maple2.File.Parser/TableParser.cs @@ -96,6 +96,7 @@ public class TableParser { private readonly XmlSerializer blackMarketSerializer; private readonly XmlSerializer changeJobSerializer; private readonly XmlSerializer fieldMissionSerializer; + private readonly XmlSerializer fieldMetaDataSerializer; private readonly XmlSerializer worldMapSerializer; private readonly XmlSerializer mapleSurvivalSkinInfoSerializer; private readonly XmlSerializer weddingExpSerializer; @@ -212,6 +213,7 @@ public TableParser(M2dReader xmlReader, string language) { blackMarketSerializer = new XmlSerializer(typeof(BlackMarketRoot)); changeJobSerializer = new XmlSerializer(typeof(ChangeJobRoot)); fieldMissionSerializer = new XmlSerializer(typeof(FieldMissionRoot)); + fieldMetaDataSerializer = new XmlSerializer(typeof(FieldMetaDataRoot)); worldMapSerializer = new XmlSerializer(typeof(WorldMapRoot)); mapleSurvivalSkinInfoSerializer = new XmlSerializer(typeof(MapleSurvivalSkinInfoRoot)); weddingExpSerializer = new XmlSerializer(typeof(WeddingExpRoot)); @@ -1405,6 +1407,17 @@ public IEnumerable ParseJobTableNew() { } } + public IEnumerable<(int Id, FieldPortal Portal)> ParseFieldMetaData() { + string xml = Sanitizer.RemoveEmpty(xmlReader.GetString(xmlReader.GetEntry($"table/{locale}/fieldmetadata.xml"))); + var reader = XmlReader.Create(new StringReader(xml)); + var data = fieldMetaDataSerializer.Deserialize(reader) as FieldMetaDataRoot; + Debug.Assert(data != null); + + foreach (FieldPortal entry in data.fieldPortal) { + yield return (entry.field, entry); + } + } + public IEnumerable<(string Feature, IList Maps)> ParseWorldMap() { string xml = Sanitizer.RemoveEmpty(xmlReader.GetString(xmlReader.GetEntry($"table/{locale}/newworldmap.xml"))); var reader = XmlReader.Create(new StringReader(xml)); diff --git a/Maple2.File.Parser/Xml/Table/FieldMetaData.cs b/Maple2.File.Parser/Xml/Table/FieldMetaData.cs new file mode 100644 index 0000000..947f7b6 --- /dev/null +++ b/Maple2.File.Parser/Xml/Table/FieldMetaData.cs @@ -0,0 +1,31 @@ +using System.Xml.Serialization; +using M2dXmlGenerator; + +namespace Maple2.File.Parser.Xml.Table; + +// ./data/xml/table/na/fieldmetadata.xml +// +// Only the section is modelled. The file also carries , , +// , , and sections, which nothing consumes yet. +[XmlRoot("ms2")] +public partial class FieldMetaDataRoot { + [M2dFeatureLocale(Selector = "field")] private IList _fieldPortal; +} + +// The client builds its sub-map to exploration-zone table from these entries: portals with +// portalIndoor != 0 are edges, and every map reachable from an indoor="0" entry belongs to +// that outdoor field. See PrivateMaple2 docs/client-re/field-mission-binding.md. +public partial class FieldPortal : IFeatureLocale { + [XmlAttribute] public int field; + [XmlAttribute] public bool indoor; + [XmlElement] public List portal = new(); + + // + public partial class Portal { + [XmlAttribute] public int id; + [XmlAttribute] public int targetField; + [XmlAttribute] public int targetPortal; + [XmlAttribute] public int portalType; + [XmlAttribute] public bool portalIndoor; + } +} diff --git a/Maple2.File.Tests/TableParserTest.cs b/Maple2.File.Tests/TableParserTest.cs index 2576faa..3ba5cbb 100644 --- a/Maple2.File.Tests/TableParserTest.cs +++ b/Maple2.File.Tests/TableParserTest.cs @@ -649,6 +649,32 @@ public void TestFieldMission() { } } + // fieldPortal is the client's sub-map to exploration-zone source. The counts and the + // Turtcoli Cave edge are what PrivateMaple2 ingests into FieldPortalZoneTable, so a + // parse that silently drops portalIndoor or indoor would change server behaviour. + [TestMethod] + public void TestFieldMetaData() { + Dictionary entries = _parser.ParseFieldMetaData() + .ToDictionary(result => result.Id, result => result.Portal); + + Assert.AreEqual(1008, entries.Count); + Assert.AreEqual(299, entries.Values.Count(entry => !entry.indoor)); + + // Turtcoli Cave reaches Royal Road Plaza through a portalIndoor edge (PrivateMaple2 #388). + Assert.IsTrue(entries.TryGetValue(2000250, out FieldPortal? cave)); + Assert.IsNotNull(cave); + Assert.IsTrue(cave.indoor); + Assert.IsTrue(cave.portal.Any(portal => portal.targetField == 2000114 && portal.portalIndoor)); + + // The PvP arena's portals all target field 0, so it joins no component. + Assert.IsTrue(entries.TryGetValue(65010004, out FieldPortal? arena)); + Assert.IsNotNull(arena); + Assert.IsTrue(arena.portal.All(portal => portal.targetField == 0)); + + // Wedding Village has no entry at all and never remaps. + Assert.IsFalse(entries.ContainsKey(84000001)); + } + [TestMethod] public void TestWorldMap() { foreach ((_, _) in _parser.ParseWorldMap()) { From a44ef28f3297c7320d6aacefc5639099b4c0b98e Mon Sep 17 00:00:00 2001 From: AngeloTadeucci <15664821+AngeloTadeucci@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:09:04 +0000 Subject: [PATCH 2/2] style: auto-format whitespace --- Maple2.File.Parser/Xml/Table/FieldMetaData.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Maple2.File.Parser/Xml/Table/FieldMetaData.cs b/Maple2.File.Parser/Xml/Table/FieldMetaData.cs index 947f7b6..7056529 100644 --- a/Maple2.File.Parser/Xml/Table/FieldMetaData.cs +++ b/Maple2.File.Parser/Xml/Table/FieldMetaData.cs @@ -1,4 +1,4 @@ -using System.Xml.Serialization; +using System.Xml.Serialization; using M2dXmlGenerator; namespace Maple2.File.Parser.Xml.Table;