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()) {