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..7056529 --- /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()) {