From 2aac12d983620c222b2689b5e60e28fb30676e94 Mon Sep 17 00:00:00 2001 From: zhyupe Date: Tue, 8 Oct 2024 00:03:31 +0800 Subject: [PATCH] feat: migrate to new Universalis api --- Cafe.Matcha/Network/NetworkMonitor.cs | 151 ++++---- Cafe.Matcha/Network/Packet.cs | 92 +++++ Cafe.Matcha/Network/Universalis/Api.cs | 115 +++--- Cafe.Matcha/Network/Universalis/Client.cs | 12 +- .../Universalis/MarketBoardItemRequest.cs | 17 - .../Network/Universalis/PacketProcessor.cs | 331 ++++++++++-------- .../IMarketBoardCurrentOfferings.cs | 117 +++++++ .../Structures/IMarketBoardHistory.cs | 57 +++ .../Structures/IMarketBoardPurchase.cs | 19 + .../Structures/IMarketBoardPurchaseHandler.cs | 49 +++ .../Universalis/Structures/IMarketTaxRates.cs | 60 ++++ .../Structures/MarketBoardCurrentOfferings.cs | 247 +++++++++++++ .../Structures/MarketBoardHistory.cs | 129 +++++++ .../Structures/MarketBoardItemRequest.cs | 56 +++ .../Structures/MarketBoardPurchase.cs | 47 +++ .../Structures/MarketBoardPurchaseHandler.cs | 87 +++++ .../Universalis/Structures/MarketTaxRates.cs | 125 +++++++ .../Types/MarketBoardCurrentOfferings.cs | 119 ------- .../Universalis/Types/MarketBoardHistory.cs | 67 ---- Cafe.Matcha/Utils/Log.cs | 3 +- 20 files changed, 1382 insertions(+), 518 deletions(-) create mode 100644 Cafe.Matcha/Network/Packet.cs delete mode 100644 Cafe.Matcha/Network/Universalis/MarketBoardItemRequest.cs create mode 100644 Cafe.Matcha/Network/Universalis/Structures/IMarketBoardCurrentOfferings.cs create mode 100644 Cafe.Matcha/Network/Universalis/Structures/IMarketBoardHistory.cs create mode 100644 Cafe.Matcha/Network/Universalis/Structures/IMarketBoardPurchase.cs create mode 100644 Cafe.Matcha/Network/Universalis/Structures/IMarketBoardPurchaseHandler.cs create mode 100644 Cafe.Matcha/Network/Universalis/Structures/IMarketTaxRates.cs create mode 100644 Cafe.Matcha/Network/Universalis/Structures/MarketBoardCurrentOfferings.cs create mode 100644 Cafe.Matcha/Network/Universalis/Structures/MarketBoardHistory.cs create mode 100644 Cafe.Matcha/Network/Universalis/Structures/MarketBoardItemRequest.cs create mode 100644 Cafe.Matcha/Network/Universalis/Structures/MarketBoardPurchase.cs create mode 100644 Cafe.Matcha/Network/Universalis/Structures/MarketBoardPurchaseHandler.cs create mode 100644 Cafe.Matcha/Network/Universalis/Structures/MarketTaxRates.cs delete mode 100644 Cafe.Matcha/Network/Universalis/Types/MarketBoardCurrentOfferings.cs delete mode 100644 Cafe.Matcha/Network/Universalis/Types/MarketBoardHistory.cs diff --git a/Cafe.Matcha/Network/NetworkMonitor.cs b/Cafe.Matcha/Network/NetworkMonitor.cs index 197c0e4..9fd7f6d 100644 --- a/Cafe.Matcha/Network/NetworkMonitor.cs +++ b/Cafe.Matcha/Network/NetworkMonitor.cs @@ -11,6 +11,8 @@ namespace Cafe.Matcha.Network using System.Threading; using Cafe.Matcha.Constant; using Cafe.Matcha.DTO; + using Cafe.Matcha.Models; + using Cafe.Matcha.Network.Universalis; using Cafe.Matcha.Utils; internal interface INetworkMonitor @@ -21,28 +23,11 @@ internal interface INetworkMonitor internal class NetworkMonitor : INetworkMonitor { - private bool ToMatchaOpcode(ushort opcode, out MatchaOpcode matchaOpcode) - { - var region = Config.Instance.Region; - switch (region) - { - case Region.Global: - return OpcodeStorage.Global.TryGetValue(opcode, out matchaOpcode); - - case Region.China: - return OpcodeStorage.China.TryGetValue(opcode, out matchaOpcode); - - default: - matchaOpcode = default; - return false; - } - } - public void HandleMessageReceived(string connection, long epoch, byte[] message) { try { - HandleMessage(message); + HandleMessage(new Packet(message)); } catch (Exception e) { @@ -54,36 +39,34 @@ public void HandleMessageReceived(string connection, long epoch, byte[] message) } } - private void HandleMessage(byte[] message) + private void HandleMessage(Packet packet) { - var segmentType = message[12]; - // Deucalion gives wrong type (0) - if (message.Length < 32 || (segmentType != 0 && segmentType != 3)) + if (!packet.Valid) { return; } - var processed = HandleMessageByOpcode(message); + var processed = HandleMessageByOpcode(packet); if (!processed) { #if DEBUG - if (ToMatchaOpcode(BitConverter.ToUInt16(message, 18), out var opcode)) + if (packet.GetMatchaOpcode(out var opcode)) { - LogIncorrectPacketSize(opcode, message.Length); - Log.Packet(message); + LogIncorrectPacketSize(opcode, packet.Length); + Log.Packet(packet.Bytes); } #endif - TryHandleMessage(message); + TryHandleMessage(packet); } } - private void TryHandleMessage(byte[] message) + private void TryHandleMessage(Packet packet) { - var data = message.Skip(32).ToArray(); // Treasure Shifting Wheel Result - if (message.Length == 88) + if (packet.DataLength == 88) { + var data = packet.GetRawData(); var level = BitConverter.ToUInt32(data, 24); if ( level == 7636061 || // G10 运河宝物库神殿 @@ -133,8 +116,9 @@ private void TryHandleMessage(byte[] message) } } } - else if (message.Length == 96) + else if (packet.DataLength == 96) { + var data = packet.GetRawData(); var flag = BitConverter.ToUInt32(data, 16); if (flag == 0x04482c03) { @@ -147,22 +131,19 @@ private void TryHandleMessage(byte[] message) } } - private bool HandleMessageByOpcode(byte[] message) + private bool HandleMessageByOpcode(Packet packet) { - if (!ToMatchaOpcode(BitConverter.ToUInt16(message, 18), out var opcode)) + if (!packet.GetMatchaOpcode(out var opcode)) { return false; } - Universalis.Client.HandlePacket(opcode, message); - - var source = BitConverter.ToUInt32(message, 4); - var target = BitConverter.ToUInt32(message, 8); - var data = message.Skip(32).ToArray(); + Universalis.Client.HandlePacket(opcode, packet); + var data = packet.GetRawData(); if (opcode == MatchaOpcode.DirectorStart) { - if (message.Length != 168) + if (packet.Length != 168) { return false; } @@ -181,7 +162,7 @@ private bool HandleMessageByOpcode(byte[] message) } else if (opcode == MatchaOpcode.NpcSpawn) { - if (message.Length != 680) + if (packet.Length != 680) { return false; } @@ -201,7 +182,7 @@ private bool HandleMessageByOpcode(byte[] message) BitConverter.ToSingle(data, posOffset + 4), BitConverter.ToSingle(data, posOffset + 8)); - State.Instance.Npc.Update(source, (npc) => + State.Instance.Npc.Update(packet.Source, (npc) => { if (npc.BNpcName != bNpcName) { @@ -221,7 +202,7 @@ private bool HandleMessageByOpcode(byte[] message) } else if (opcode == MatchaOpcode.ActorControl) { - if (message.Length != 56) + if (packet.Length != 56) { return false; } @@ -234,7 +215,7 @@ private bool HandleMessageByOpcode(byte[] message) var status = BitConverter.ToUInt32(data, 4); if (status == 2) { - State.Instance.Npc.Remove(source); + State.Instance.Npc.Remove(packet.Source); } break; @@ -243,7 +224,7 @@ private bool HandleMessageByOpcode(byte[] message) } else if (opcode == MatchaOpcode.FateInfo) { - if (message.Length != 56) + if (packet.Length != 56) { return false; } @@ -272,7 +253,7 @@ private bool HandleMessageByOpcode(byte[] message) } else if (opcode == MatchaOpcode.ActorControlSelf) { - if (message.Length != 64) + if (packet.Length != 64) { return false; } @@ -377,7 +358,7 @@ private bool HandleMessageByOpcode(byte[] message) } else if (opcode == MatchaOpcode.ContentFinderNotifyPop) { - if (message.Length != 72) + if (packet.Length != 72) { return false; } @@ -393,7 +374,7 @@ private bool HandleMessageByOpcode(byte[] message) } else if (opcode == MatchaOpcode.CompanyAirshipStatus) { - if (message.Length != 176) + if (packet.Length != 176) { return false; } @@ -425,7 +406,7 @@ private bool HandleMessageByOpcode(byte[] message) } else if (opcode == MatchaOpcode.CompanySubmersibleStatus) { - if (message.Length != 176) + if (packet.Length != 176) { return false; } @@ -457,7 +438,7 @@ private bool HandleMessageByOpcode(byte[] message) } else if (opcode == MatchaOpcode.InitZone) { - if (message.Length != 136) + if (packet.Length != 136) { return false; } @@ -476,12 +457,12 @@ private bool HandleMessageByOpcode(byte[] message) } else if (opcode == MatchaOpcode.EventPlay) { - if (message.Length != 72) + if (packet.Length != 72) { return false; } - var targetActorId = BitConverter.ToUInt32(message, 8); + var targetActorId = packet.Target; var fishActorId = BitConverter.ToUInt32(data, 0); if (targetActorId != fishActorId) @@ -519,65 +500,51 @@ private bool HandleMessageByOpcode(byte[] message) break; } } + else if (opcode == MatchaOpcode.MarketBoardItemListingHistory) + { + return true; + } else if (opcode == MatchaOpcode.MarketBoardItemListingCount) { - if (message.Length != 48) - { - return false; - } - - var itemId = BitConverter.ToUInt32(data, 0); - var count = data[0x0B]; - - FireEvent(new MarketBoardItemListingCountDTO() - { - Item = (int)itemId, - Count = count, - World = State.Instance.WorldId - }); - ThreadPool.QueueUserWorkItem(o => Universalis.Client.QueryItem(State.Instance.WorldId, itemId, FireEvent)); + // Useless as ItemId is removed in 7.0 + return true; } else if (opcode == MatchaOpcode.MarketBoardItemListing) { - if (message.Length != 1560) - { - return false; - } - - var detail = new List(); - - var itemId = (int)BitConverter.ToUInt32(data, 0x2c); + var result = MarketBoardCurrentOfferings.Read(data); var items = new List(); - const int LISTING_LENGTH = 152; - for (int i = 0; i < 10; i++) + uint itemId = 0; + foreach (var item in result.ItemListings) { - var pricePerUnit = BitConverter.ToUInt32(data, 0x20 + (LISTING_LENGTH * i)); - if (pricePerUnit == 0) + if (item.PricePerUnit == 0) { break; } - var quantity = BitConverter.ToUInt32(data, 0x28 + (LISTING_LENGTH * i)); - var hq = data[0x8c + (LISTING_LENGTH * i)]; + itemId = item.ItemId; items.Add(new MarketBoardItemListingItem() { - Price = (int)(pricePerUnit * 1.05), - Quantity = (int)quantity, - HQ = hq != 0 + // Price = (int)(pricePerUnit * 1.05), + Price = (int)item.PricePerUnit, + Quantity = (int)item.ItemQuantity, + HQ = item.IsHq }); } - FireEvent(new MarketBoardItemListingDTO() + if (itemId != 0) { - Item = itemId, - Data = items, - World = State.Instance.WorldId - }); + FireEvent(new MarketBoardItemListingDTO() + { + Item = (int)itemId, + Data = items, + World = State.Instance.WorldId + }); + } } else if (opcode == MatchaOpcode.ItemInfo) { - if (message.Length != 96) + if (packet.Length != 96) { return false; } @@ -611,7 +578,7 @@ private bool HandleMessageByOpcode(byte[] message) } else if (opcode == MatchaOpcode.InventoryTransaction) { - if (message.Length != 80) + if (packet.Length != 80) { return false; } @@ -635,7 +602,7 @@ private bool HandleMessageByOpcode(byte[] message) } else if (opcode == MatchaOpcode.Examine) { - if (message.Length != 960) + if (packet.Length != 960) { return false; } @@ -682,7 +649,7 @@ private bool HandleMessageByOpcode(byte[] message) } else if (opcode == MatchaOpcode.PlayerSpawn) { - var isCurrentPlayer = source == target; + var isCurrentPlayer = packet.Source == packet.Target; var currentWorldId = BitConverter.ToUInt16(data, 4); State.Instance.HandleWorldId(currentWorldId, isCurrentPlayer); diff --git a/Cafe.Matcha/Network/Packet.cs b/Cafe.Matcha/Network/Packet.cs new file mode 100644 index 0000000..795c443 --- /dev/null +++ b/Cafe.Matcha/Network/Packet.cs @@ -0,0 +1,92 @@ +namespace Cafe.Matcha.Network +{ + using System; + using System.Linq; + using System.Windows.Forms; + using Cafe.Matcha.Constant; + + internal class Packet + { + private const int HeaderLength = 32; + + /// + /// Raw packet, including headers. + /// + public byte[] Bytes; + + /// + /// Is this packet valid. + /// + public bool Valid = false; + + /// + /// SegmentType. + /// + public byte SegmentType; + + /// + /// Source. + /// + public uint Source; + + /// + /// Target. + /// + public uint Target; + + /// + /// Opcode. + /// + public ushort Opcode; + + /// + /// Gets packet length, including headers. + /// + public int Length => Bytes.Length; + + public int DataLength => Bytes.Length - HeaderLength; + + public Packet(byte[] bytes) + { + Bytes = bytes; + if (bytes.Length < HeaderLength) + { + return; + } + + // Deucalion gives wrong type (0) + SegmentType = bytes[12]; + if (SegmentType != 0 && SegmentType != 3) + { + return; + } + + Source = BitConverter.ToUInt32(Bytes, 4); + Target = BitConverter.ToUInt32(Bytes, 8); + Opcode = BitConverter.ToUInt16(Bytes, 18); + Valid = true; + } + + public bool GetMatchaOpcode(out MatchaOpcode matchaOpcode) + { + var region = Config.Instance.Region; + switch (region) + { + case Region.Global: + return OpcodeStorage.Global.TryGetValue(Opcode, out matchaOpcode); + + case Region.China: + return OpcodeStorage.China.TryGetValue(Opcode, out matchaOpcode); + + default: + matchaOpcode = default; + return false; + } + } + + public byte[] GetRawData() + { + return Bytes.Skip(HeaderLength).ToArray(); + } + } +} diff --git a/Cafe.Matcha/Network/Universalis/Api.cs b/Cafe.Matcha/Network/Universalis/Api.cs index 5313715..c0af97b 100644 --- a/Cafe.Matcha/Network/Universalis/Api.cs +++ b/Cafe.Matcha/Network/Universalis/Api.cs @@ -3,7 +3,6 @@ namespace Cafe.Matcha.Network.Universalis using System; using System.Collections.Generic; using System.Linq; - using System.Net; using System.Threading.Tasks; using Cafe.Matcha.Utils; using Newtonsoft.Json; @@ -21,76 +20,70 @@ public Api(PacketProcessor packetProcessor, string apiKey) _apiKey = apiKey; } - public async void Upload(ushort worldId, MarketBoardItemRequest request) + public async void Upload(uint worldId, MarketBoardItemRequest request) { - using (var client = new WebClient()) - { - _packetProcessor.Log?.Invoke(this, "Starting Universalis upload."); - var uploader = _packetProcessor.LocalContentId; - - var listingsRequestObject = new UniversalisItemListingsUploadRequest(); - listingsRequestObject.WorldId = worldId; - listingsRequestObject.UploaderId = uploader; - listingsRequestObject.ItemId = request.CatalogId; + _packetProcessor.Log?.Invoke(this, "Starting Universalis upload."); + var uploader = _packetProcessor.LocalContentId; - listingsRequestObject.Listings = new List(); - foreach (var marketBoardItemListing in request.Listings) + var uploadObject = new UniversalisItemUploadRequest + { + WorldId = worldId, + UploaderId = uploader.ToString(), + ItemId = request.Listings.FirstOrDefault()?.ItemId ?? 0, + Listings = new List(), + Sales = new List(), + }; + + foreach (var marketBoardItemListing in request.Listings) + { +#pragma warning disable CS0618 // Type or member is obsolete + var universalisListing = new UniversalisItemListingsEntry { - var universalisListing = new UniversalisItemListingsEntry - { - ListingId = marketBoardItemListing.ListingId, - Hq = marketBoardItemListing.IsHq, - SellerId = marketBoardItemListing.RetainerOwnerId, - RetainerName = marketBoardItemListing.RetainerName, - RetainerId = marketBoardItemListing.RetainerId, - CreatorId = marketBoardItemListing.ArtisanId, - CreatorName = marketBoardItemListing.PlayerName, - OnMannequin = marketBoardItemListing.OnMannequin, - LastReviewTime = ((DateTimeOffset)marketBoardItemListing.LastReviewTime).ToUnixTimeSeconds(), - PricePerUnit = marketBoardItemListing.PricePerUnit, - Quantity = marketBoardItemListing.ItemQuantity, - RetainerCity = marketBoardItemListing.RetainerCityId - }; - - universalisListing.Materia = new List(); - foreach (var itemMateria in marketBoardItemListing.Materia) - { - universalisListing.Materia.Add(new UniversalisItemMateria - { - MateriaId = itemMateria.MateriaId, - SlotId = itemMateria.Index - }); - } - - listingsRequestObject.Listings.Add(universalisListing); - } - - await Request.SendAsJson($"{ApiBase}/upload/{_apiKey}", "", listingsRequestObject); - - var historyRequestObject = new UniversalisHistoryUploadRequest(); - historyRequestObject.WorldId = worldId; - historyRequestObject.UploaderId = uploader; - historyRequestObject.ItemId = request.CatalogId; - - historyRequestObject.Entries = new List(); - foreach (var marketBoardHistoryListing in request.History) + ListingId = marketBoardItemListing.ListingId.ToString(), + Hq = marketBoardItemListing.IsHq, + SellerId = marketBoardItemListing.RetainerOwnerId.ToString(), + RetainerName = marketBoardItemListing.RetainerName, + RetainerId = marketBoardItemListing.RetainerId.ToString(), + CreatorId = marketBoardItemListing.ArtisanId.ToString(), + CreatorName = marketBoardItemListing.PlayerName, + OnMannequin = marketBoardItemListing.OnMannequin, + LastReviewTime = ((DateTimeOffset)marketBoardItemListing.LastReviewTime).ToUnixTimeSeconds(), + PricePerUnit = marketBoardItemListing.PricePerUnit, + Quantity = marketBoardItemListing.ItemQuantity, + RetainerCity = marketBoardItemListing.RetainerCityId, + Materia = new List(), + }; +#pragma warning restore CS0618 // Type or member is obsolete + + foreach (var itemMateria in marketBoardItemListing.Materia) { - historyRequestObject.Entries.Add(new UniversalisHistoryEntry + universalisListing.Materia.Add(new UniversalisItemMateria { - BuyerName = marketBoardHistoryListing.BuyerName, - Hq = marketBoardHistoryListing.IsHq, - OnMannequin = marketBoardHistoryListing.OnMannequin, - PricePerUnit = marketBoardHistoryListing.SalePrice, - Quantity = marketBoardHistoryListing.Quantity, - Timestamp = ((DateTimeOffset)marketBoardHistoryListing.PurchaseTime).ToUnixTimeSeconds() + MateriaId = itemMateria.MateriaId, + SlotId = itemMateria.Index, }); } - await Request.SendAsJson($"{ApiBase}/upload/{_apiKey}", "", historyRequestObject); + uploadObject.Listings.Add(universalisListing); + } - _packetProcessor.Log?.Invoke(this, - $"Universalis data upload for item#{request.CatalogId} to world#{historyRequestObject.WorldId} completed."); + foreach (var marketBoardHistoryListing in request.History) + { + uploadObject.Sales.Add(new UniversalisHistoryEntry + { + BuyerName = marketBoardHistoryListing.BuyerName, + Hq = marketBoardHistoryListing.IsHq, + OnMannequin = marketBoardHistoryListing.OnMannequin, + PricePerUnit = marketBoardHistoryListing.SalePrice, + Quantity = marketBoardHistoryListing.Quantity, + Timestamp = ((DateTimeOffset)marketBoardHistoryListing.PurchaseTime).ToUnixTimeSeconds(), + }); } + + var uploadPath = "/upload"; + await Request.SendAsJson($"{ApiBase}{uploadPath}/{_apiKey}", "", uploadObject); + + _packetProcessor.Log?.Invoke(this, $"Universalis data upload for item#{request.Listings.FirstOrDefault()?.CatalogId ?? 0} completed"); } public static async Task>> ListByDC(ushort worldId, uint itemId) diff --git a/Cafe.Matcha/Network/Universalis/Client.cs b/Cafe.Matcha/Network/Universalis/Client.cs index 1a6d448..e8dc01c 100644 --- a/Cafe.Matcha/Network/Universalis/Client.cs +++ b/Cafe.Matcha/Network/Universalis/Client.cs @@ -13,22 +13,16 @@ internal class Client private static bool Enabled => Config.Instance.Overlay.Universalis; private static object objLock = new object(); - public static void HandlePacket(MatchaOpcode opcode, byte[] message) + public static void HandlePacket(MatchaOpcode opcode, Packet packet) { - if (!Enabled) - { - return; - } - - if (opcode == MatchaOpcode.PlayerSpawn - || opcode == MatchaOpcode.PlayerSetup + if (opcode == MatchaOpcode.PlayerSetup || opcode == MatchaOpcode.MarketBoardItemListingCount || opcode == MatchaOpcode.MarketBoardItemListing || opcode == MatchaOpcode.MarketBoardItemListingHistory) { lock (objLock) { - UniversalisProcessor.ProcessZonePacket(opcode, message); + UniversalisProcessor.ProcessZonePacket(opcode, packet); } } } diff --git a/Cafe.Matcha/Network/Universalis/MarketBoardItemRequest.cs b/Cafe.Matcha/Network/Universalis/MarketBoardItemRequest.cs deleted file mode 100644 index b909309..0000000 --- a/Cafe.Matcha/Network/Universalis/MarketBoardItemRequest.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Cafe.Matcha.Network.Universalis -{ - using System.Collections.Generic; - - internal class MarketBoardItemRequest - { - public uint CatalogId { get; set; } - public byte AmountToArrive { get; set; } - - public List Listings { get; set; } - public List History { get; set; } - - public int ListingsRequestId { get; set; } = -1; - - public bool IsDone => Listings.Count == AmountToArrive && History.Count != 0; - } -} diff --git a/Cafe.Matcha/Network/Universalis/PacketProcessor.cs b/Cafe.Matcha/Network/Universalis/PacketProcessor.cs index d1f1d5e..4dffdf1 100644 --- a/Cafe.Matcha/Network/Universalis/PacketProcessor.cs +++ b/Cafe.Matcha/Network/Universalis/PacketProcessor.cs @@ -3,15 +3,37 @@ using System; using System.Collections.Generic; using System.Linq; - using System.Threading; + using System.Reactive.Concurrency; + using System.Reactive.Linq; + using System.Threading.Tasks; using Cafe.Matcha.Constant; - using Cafe.Matcha.Utils; - internal class PacketProcessor + internal class PacketProcessor : IDisposable { - private readonly List _marketBoardRequests = new List(); + private MarketBoardItemRequest _marketBoardRequest = null; private readonly Api _uploader; + private readonly IDisposable handleMarketBoardItemRequest; + + private event Action MarketBoardHistoryReceived; + private event Action MarketBoardItemRequestStartReceived; + private event Action MarketBoardOfferingsReceived; + + /// + /// Gets an observable to track marketboard history events. + /// + public IObservable MbHistoryObservable { get; } + + /// + /// Gets an observable to track marketboard item request events. + /// + public IObservable MbItemRequestObservable { get; } + + /// + /// Gets an observable to track marketboard offerings events. + /// + public IObservable MbOfferingsObservable { get; } + public ushort CurrentWorldId { get @@ -27,201 +49,196 @@ public ushort CurrentWorldId public PacketProcessor(string apiKey) { _uploader = new Api(this, apiKey); - } + MbHistoryObservable = Observable.Create(observer => + { + MarketBoardHistoryReceived += Observe; + return () => { MarketBoardHistoryReceived -= Observe; }; - /// - /// Process a zone proto message and scan for relevant data. - /// - /// Opcode. - /// The message bytes. - /// True if an upload succeeded. - public bool ProcessZonePacket(MatchaOpcode opcode, byte[] message) - { - MarketBoardItemRequest request; - lock (_marketBoardRequests) + void Observe(Packet packet) + { + observer.OnNext(MarketBoardHistory.Read(packet.GetRawData())); + } + }); + + MbItemRequestObservable = Observable.Create(observer => { - request = GetRequestData(opcode, message); - if (request != null) + MarketBoardItemRequestStartReceived += Observe; + return () => MarketBoardItemRequestStartReceived -= Observe; + + void Observe(Packet packet) { - _marketBoardRequests.Remove(request); + observer.OnNext(MarketBoardItemRequest.Read(packet.GetRawData())); } - } + }); - if (request != null) + MbOfferingsObservable = Observable.Create(observer => { - ThreadPool.QueueUserWorkItem(o => + MarketBoardOfferingsReceived += Observe; + return () => { MarketBoardOfferingsReceived -= Observe; }; + + void Observe(Packet packet) { - try - { - _uploader.Upload(CurrentWorldId, request); - } - catch (Exception ex) - { - Log?.Invoke(this, "[ERROR] Market Board data upload failed:\n" + ex); - } - }); - return true; - } + observer.OnNext(MarketBoardCurrentOfferings.Read(packet.GetRawData())); + } + }); - return false; + handleMarketBoardItemRequest = HandleMarketBoardItemRequest(); } /// /// Process a zone proto message and scan for relevant data. /// - /// The message bytes. - /// True if an upload succeeded. - private MarketBoardItemRequest GetRequestData(MatchaOpcode opcode, byte[] message) + /// Opcode. + /// The packet. + public void ProcessZonePacket(MatchaOpcode opcode, Packet packet) { if (opcode == MatchaOpcode.PlayerSetup) { // Mask lower-32bit for privacy concern - LocalContentId = BitConverter.ToUInt64(message, 0x20) & 0xffffffff00000000; + LocalContentId = BitConverter.ToUInt64(packet.Bytes, 0x20) & 0xffffffff00000000; LocalContentId = LocalContentId | GetClientIdentifier(); Log?.Invoke(this, $"New CID: {LocalContentId.ToString("X")}"); - return null; } - - if (opcode == MatchaOpcode.MarketBoardItemListingCount) + else if (opcode == MatchaOpcode.MarketBoardItemListingCount) { - var catalogId = (uint)BitConverter.ToInt32(message, 0x20); - var status = BitConverter.ToInt32(message, 0x24); - var amount = message[0x2B]; - - if (status != 0) - { - Log?.Invoke(this, $"MB Query Failed: item#{catalogId} status#{status}"); - return null; - } - - var request = _marketBoardRequests.LastOrDefault(r => r.CatalogId == catalogId); - if (request == null) - { - _marketBoardRequests.Add(new MarketBoardItemRequest - { - CatalogId = catalogId, - AmountToArrive = amount, - Listings = new List(), - History = new List() - }); - } - else - { - request.AmountToArrive = amount; - request.Listings.Clear(); - } - - Log?.Invoke(this, $"NEW MB REQUEST START: item#{catalogId} amount#{amount}"); - return null; + MarketBoardItemRequestStartReceived?.Invoke(packet); } - - if (opcode == MatchaOpcode.MarketBoardItemListing) + else if (opcode == MatchaOpcode.MarketBoardItemListing) { - var listing = MarketBoardCurrentOfferings.Read(message.Skip(0x20).ToArray()); - - var request = - _marketBoardRequests.LastOrDefault( - r => r.CatalogId == listing.ItemListings[0].CatalogId && !r.IsDone); - - if (request == null) - { - Log?.Invoke(this, - $"[ERROR] Market Board data arrived without a corresponding request: item#{listing.ItemListings[0].CatalogId}"); - return null; - } + MarketBoardOfferingsReceived?.Invoke(packet); + } + else if (opcode == MatchaOpcode.MarketBoardItemListingHistory) + { + MarketBoardHistoryReceived?.Invoke(packet); + } + } - if (request.Listings.Count + listing.ItemListings.Count > request.AmountToArrive) - { - Log?.Invoke(this, - $"[ERROR] Too many Market Board listings received for request: {request.Listings.Count + listing.ItemListings.Count} > {request.AmountToArrive} item#{listing.ItemListings[0].CatalogId}"); - _marketBoardRequests.Remove(request); - return null; - } + private IObservable> OnMarketBoardListingsBatch( + IObservable start) + { + var offeringsObservable = MbOfferingsObservable.Publish().RefCount(); - if (request.ListingsRequestId != -1 && request.ListingsRequestId != listing.RequestId) - { - Log?.Invoke(this, - $"[ERROR] Non-matching RequestIds for Market Board data request: {request.ListingsRequestId}, {listing.RequestId}"); - _marketBoardRequests.Remove(request); - return null; - } + void LogEndObserved(MarketBoardCurrentOfferings offerings) + { + Log?.Invoke(this, $"Observed end of request {offerings.RequestId}"); + } - if (request.ListingsRequestId == -1 && request.Listings.Count > 0) - { - Log?.Invoke(this, - $"[ERROR] Market Board data request sequence break: {request.ListingsRequestId}, {request.Listings.Count}"); - _marketBoardRequests.Remove(request); - return null; - } + void LogOfferingsObserved(MarketBoardCurrentOfferings offerings) + { + Log?.Invoke(this, $"Observed element of request {offerings.RequestId} with {offerings.InternalItemListings.Count} listings"); + } - if (request.ListingsRequestId == -1) + IObservable UntilBatchEnd(MarketBoardItemRequest request) + { + var totalPackets = Convert.ToInt32(Math.Ceiling((double)request.AmountToArrive / 10)); + if (totalPackets == 0) { - request.ListingsRequestId = listing.RequestId; - Log?.Invoke(this, $"First Market Board packet in sequence: {listing.RequestId}"); + return Observable.Empty(); } - request.Listings.AddRange(listing.ItemListings); + return offeringsObservable + .Where(offerings => offerings.InternalItemListings.All(l => l.CatalogId != 0)) + .Skip(totalPackets - 1) + .Do(LogEndObserved); + } - Log?.Invoke(this, - $"Added {listing.ItemListings.Count} ItemListings to request#{request.ListingsRequestId}, now {request.Listings.Count}/{request.AmountToArrive}, item#{request.CatalogId}"); + // When a start packet is observed, begin observing a window of listings packets + // according to the count described by the start packet. Aggregate the listings + // packets, and then flatten them to the listings themselves. + return offeringsObservable + .Do(LogOfferingsObserved) + .Window(start, UntilBatchEnd) + .SelectMany( + o => o.Aggregate( + new List(), + (agg, next) => + { + agg.AddRange(next.InternalItemListings); + return agg; + })); + } - if (request.IsDone) - { - return Commit(request); - } + private IObservable> OnMarketBoardSalesBatch( + IObservable start) + { + var historyObservable = MbHistoryObservable.Publish().RefCount(); - return null; + void LogHistoryObserved(MarketBoardHistory history) + { + Log?.Invoke(this, $"Observed history for item {history.CatalogId} with {history.InternalHistoryListings.Count} sales"); } - if (opcode == MatchaOpcode.MarketBoardItemListingHistory) + IObservable UntilBatchEnd(MarketBoardItemRequest request) { - var listing = MarketBoardHistory.Read(message.Skip(0x20).ToArray()); - - var request = _marketBoardRequests.LastOrDefault(r => r.CatalogId == listing.CatalogId); - - if (request == null) - { - request = new MarketBoardItemRequest - { - CatalogId = listing.CatalogId, - AmountToArrive = 0, - Listings = new List(), - History = new List() - }; - _marketBoardRequests.Add(request); - } - - request.History.AddRange(listing.HistoryListings); - - if (request.IsDone) - { - return Commit(request); - } - - Log?.Invoke(this, $"Added history for item#{listing.CatalogId}"); - return null; + return historyObservable + .Where(history => history.CatalogId != 0) + .Take(1); } - return null; + // When a start packet is observed, begin observing a window of history packets. + // We should only get one packet, which the window closing function ensures. + // This packet is flattened to its sale entries and emitted. + return historyObservable + .Do(LogHistoryObserved) + .Window(start, UntilBatchEnd) + .SelectMany( + o => o.Aggregate( + new List(), + (agg, next) => + { + agg.AddRange(next.InternalHistoryListings); + return agg; + })); } - private MarketBoardItemRequest Commit(MarketBoardItemRequest request) + private IDisposable HandleMarketBoardItemRequest() { - if (CurrentWorldId == 0) + void LogStartObserved(MarketBoardItemRequest request) { - Log?.Invoke(this, "[ERROR] Not sure about your current world. Please move your character between zones once to start uploading."); - _marketBoardRequests.Remove(request); - return null; + Log?.Invoke(this, $"Observed start of request for item with {request.AmountToArrive} expected listings"); } - if (LocalContentId == 0) + var startObservable = MbItemRequestObservable + .Where(request => request.Ok).Do(LogStartObserved) + .Publish() + .RefCount(); + return Observable.When( + startObservable + .And(OnMarketBoardSalesBatch(startObservable)) + .And(OnMarketBoardListingsBatch(startObservable)) + .Then((request, sales, listings) => (request, sales, listings))) + .Where(ShouldUpload) + .SubscribeOn(ThreadPoolScheduler.Instance) + .Subscribe( + data => + { + var (request, sales, listings) = data; + UploadMarketBoardData(request, sales, listings); + }, + ex => Log?.Invoke(this, $"Failed to handle Market Board item request event: {ex}")); + } + + private void UploadMarketBoardData( + MarketBoardItemRequest request, + ICollection sales, + ICollection listings) + { + var catalogId = listings.FirstOrDefault()?.CatalogId ?? 0; + if (listings.Count != request.AmountToArrive) { - Log?.Invoke(this, "Not sure about your character information. Please log in once with your character while having the program open to verify it."); + Log?.Invoke(this, $"Wrong number of Market Board listings received for request: {listings.Count} != {request.AmountToArrive} item#{catalogId}"); + return; } - Log?.Invoke(this, - $"Market Board request finished, starting upload: request#{request.ListingsRequestId} item#{request.CatalogId} amount#{request.AmountToArrive}"); - return request; + Log?.Invoke(this, $"Market Board request resolved, starting upload: item#{catalogId} listings#{listings.Count} sales#{sales.Count}"); + + request.Listings.AddRange(listings); + request.History.AddRange(sales); + + Task.Run(() => _uploader.Upload(CurrentWorldId, request)) + .ContinueWith( + task => Log?.Invoke(this, "Market Board offerings data upload failed"), + TaskContinuationOptions.OnlyOnFaulted); } private uint GetClientIdentifier() @@ -242,5 +259,15 @@ private uint GetClientIdentifier() return 0; } } + + private bool ShouldUpload(T any) + { + return Config.Instance.Overlay.Universalis; + } + + public void Dispose() + { + handleMarketBoardItemRequest.Dispose(); + } } } \ No newline at end of file diff --git a/Cafe.Matcha/Network/Universalis/Structures/IMarketBoardCurrentOfferings.cs b/Cafe.Matcha/Network/Universalis/Structures/IMarketBoardCurrentOfferings.cs new file mode 100644 index 0000000..fbe18c8 --- /dev/null +++ b/Cafe.Matcha/Network/Universalis/Structures/IMarketBoardCurrentOfferings.cs @@ -0,0 +1,117 @@ +namespace Cafe.Matcha.Network.Universalis +{ + using System.Collections.Generic; + + /// + /// An interface that represents the current market board offerings. + /// + public interface IMarketBoardCurrentOfferings + { + /// + /// Gets the list of individual item listings. + /// + IReadOnlyList ItemListings { get; } + + /// + /// Gets the request ID. + /// + int RequestId { get; } + } + + /// + /// An interface that represents the current market board offering of a single item from the . + /// + public interface IMarketBoardItemListing + { + /// + /// Gets the artisan ID. + /// + ulong ArtisanId { get; } + + /// + /// Gets the item ID. + /// + uint ItemId { get; } + + /// + /// Gets a value indicating whether the item is HQ. + /// + bool IsHq { get; } + + /// + /// Gets the item quantity. + /// + uint ItemQuantity { get; } + + /// + /// Gets the listing ID. + /// + ulong ListingId { get; } + + /// + /// Gets the list of materia attached to this item. + /// + IReadOnlyList Materia { get; } + + /// + /// Gets the amount of attached materia. + /// + int MateriaCount { get; } + + /// + /// Gets a value indicating whether this item is on a mannequin. + /// + bool OnMannequin { get; } + + /// + /// Gets the price per unit. + /// + uint PricePerUnit { get; } + + /// + /// Gets the city ID of the retainer selling the item. + /// + int RetainerCityId { get; } + + /// + /// Gets the ID of the retainer selling the item. + /// + ulong RetainerId { get; } + + /// + /// Gets the name of the retainer. + /// + string RetainerName { get; } + + /// + /// Gets the first stain or applied dye of the item. + /// + int Stain1Id { get; } + + /// + /// Gets the second stain or applied dye of the item. + /// + int Stain2Id { get; } + + /// + /// Gets the total tax. + /// + uint TotalTax { get; } + } + + /// + /// An interface that represents the materia slotted to an . + /// + public interface IItemMateria + { + /// + /// Gets the materia index. + /// + int Index { get; } + + /// + /// Gets the materia ID. + /// + int MateriaId { get; } + } +} diff --git a/Cafe.Matcha/Network/Universalis/Structures/IMarketBoardHistory.cs b/Cafe.Matcha/Network/Universalis/Structures/IMarketBoardHistory.cs new file mode 100644 index 0000000..c4f38c7 --- /dev/null +++ b/Cafe.Matcha/Network/Universalis/Structures/IMarketBoardHistory.cs @@ -0,0 +1,57 @@ +namespace Cafe.Matcha.Network.Universalis +{ + using System; + using System.Collections.Generic; + + /// + /// An interface that represents the market board history from the game. + /// + public interface IMarketBoardHistory + { + /// + /// Gets the item ID. + /// + uint ItemId { get; } + + /// + /// Gets the list of individual item history listings. + /// + IReadOnlyList HistoryListings { get; } + } + + /// + /// An interface that represents the market board history of a single item from . + /// + public interface IMarketBoardHistoryListing + { + /// + /// Gets the buyer's name. + /// + string BuyerName { get; } + + /// + /// Gets a value indicating whether the item is HQ. + /// + bool IsHq { get; } + + /// + /// Gets a value indicating whether the item is on a mannequin. + /// + bool OnMannequin { get; } + + /// + /// Gets the time of purchase. + /// + DateTime PurchaseTime { get; } + + /// + /// Gets the quantity. + /// + uint Quantity { get; } + + /// + /// Gets the sale price. + /// + uint SalePrice { get; } + } +} diff --git a/Cafe.Matcha/Network/Universalis/Structures/IMarketBoardPurchase.cs b/Cafe.Matcha/Network/Universalis/Structures/IMarketBoardPurchase.cs new file mode 100644 index 0000000..551e8ef --- /dev/null +++ b/Cafe.Matcha/Network/Universalis/Structures/IMarketBoardPurchase.cs @@ -0,0 +1,19 @@ +namespace Cafe.Matcha.Network.Universalis +{ + /// + /// An interface that represents market board purchase information. This message is received from the + /// server when a purchase is made at a market board. + /// + public interface IMarketBoardPurchase + { + /// + /// Gets the item ID of the item that was purchased. + /// + uint CatalogId { get; } + + /// + /// Gets the quantity of the item that was purchased. + /// + uint ItemQuantity { get; } + } +} diff --git a/Cafe.Matcha/Network/Universalis/Structures/IMarketBoardPurchaseHandler.cs b/Cafe.Matcha/Network/Universalis/Structures/IMarketBoardPurchaseHandler.cs new file mode 100644 index 0000000..06222c1 --- /dev/null +++ b/Cafe.Matcha/Network/Universalis/Structures/IMarketBoardPurchaseHandler.cs @@ -0,0 +1,49 @@ +namespace Cafe.Matcha.Network.Universalis +{ + /// + /// An interface that represents market board purchase information. This message is sent from the + /// client when a purchase is made at a market board. + /// + public interface IMarketBoardPurchaseHandler + { + /// + /// Gets the object ID of the retainer associated with the sale. + /// + ulong RetainerId { get; } + + /// + /// Gets the object ID of the item listing. + /// + ulong ListingId { get; } + + /// + /// Gets the item ID of the item that was purchased. + /// + uint CatalogId { get; } + + /// + /// Gets the quantity of the item that was purchased. + /// + uint ItemQuantity { get; } + + /// + /// Gets the unit price of the item. + /// + uint PricePerUnit { get; } + + /// + /// Gets a value indicating whether the item is HQ. + /// + bool IsHq { get; } + + /// + /// Gets the total tax. + /// + uint TotalTax { get; } + + /// + /// Gets the city ID of the retainer selling the item. + /// + int RetainerCityId { get; } + } +} diff --git a/Cafe.Matcha/Network/Universalis/Structures/IMarketTaxRates.cs b/Cafe.Matcha/Network/Universalis/Structures/IMarketTaxRates.cs new file mode 100644 index 0000000..bfe741a --- /dev/null +++ b/Cafe.Matcha/Network/Universalis/Structures/IMarketTaxRates.cs @@ -0,0 +1,60 @@ +namespace Cafe.Matcha.Network.Universalis +{ + using System; + + /// + /// An interface that represents the tax rates received by the client when interacting with a retainer vocate. + /// + public interface IMarketTaxRates + { + /// + /// Gets the category of this ResultDialog packet. + /// + uint Category { get; } + + /// + /// Gets the tax rate in Limsa Lominsa. + /// + uint LimsaLominsaTax { get; } + + /// + /// Gets the tax rate in Gridania. + /// + uint GridaniaTax { get; } + + /// + /// Gets the tax rate in Ul'dah. + /// + uint UldahTax { get; } + + /// + /// Gets the tax rate in Ishgard. + /// + uint IshgardTax { get; } + + /// + /// Gets the tax rate in Kugane. + /// + uint KuganeTax { get; } + + /// + /// Gets the tax rate in the Crystarium. + /// + uint CrystariumTax { get; } + + /// + /// Gets the tax rate in the Crystarium. + /// + uint SharlayanTax { get; } + + /// + /// Gets the tax rate in Tuliyollal. + /// + uint TuliyollalTax { get; } + + /// + /// Gets until when these values are valid. + /// + DateTime ValidUntil { get; } + } +} diff --git a/Cafe.Matcha/Network/Universalis/Structures/MarketBoardCurrentOfferings.cs b/Cafe.Matcha/Network/Universalis/Structures/MarketBoardCurrentOfferings.cs new file mode 100644 index 0000000..db50ac2 --- /dev/null +++ b/Cafe.Matcha/Network/Universalis/Structures/MarketBoardCurrentOfferings.cs @@ -0,0 +1,247 @@ +namespace Cafe.Matcha.Network.Universalis +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Text; + + /// + /// This class represents the current market board offerings from a game network packet. + /// + public class MarketBoardCurrentOfferings : IMarketBoardCurrentOfferings + { + private MarketBoardCurrentOfferings() + { + } + + /// + /// Gets the list of individual item listings. + /// + public IReadOnlyList ItemListings => this.InternalItemListings; + + /// + /// Gets the request ID. + /// + public int RequestId { get; internal set; } + + /// + /// Gets or sets the internal read-write list of marketboard item listings. + /// + internal List InternalItemListings { get; set; } = new List(); + + /// + /// Read a object from memory. + /// + /// Data to read. + /// A new object. + public static MarketBoardCurrentOfferings Read(byte[] data) + { + var output = new MarketBoardCurrentOfferings(); + + using (var stream = new MemoryStream(data)) + { + using (var reader = new BinaryReader(stream)) + { + var listings = new List(); + + for (var i = 0; i < 10; i++) + { + var listingEntry = new MarketBoardItemListing(); + + listingEntry.ListingId = reader.ReadUInt64(); + listingEntry.RetainerId = reader.ReadUInt64(); + listingEntry.RetainerOwnerId = reader.ReadUInt64(); + listingEntry.ArtisanId = reader.ReadUInt64(); + + listingEntry.PricePerUnit = reader.ReadUInt32(); + listingEntry.TotalTax = reader.ReadUInt32(); + listingEntry.ItemQuantity = reader.ReadUInt32(); + listingEntry.CatalogId = reader.ReadUInt32(); + + reader.ReadUInt16(); // Slot + reader.ReadUInt16(); // Durability + reader.ReadUInt16(); // Spiritbond + + var materiaList = new List(); + for (var materiaIndex = 0; materiaIndex < 5; materiaIndex++) + { + var materiaVal = reader.ReadUInt16(); + var materiaEntry = new MarketBoardItemListing.ItemMateria() + { + MateriaId = (materiaVal & 0xFF0) >> 4, + Index = materiaVal & 0xF, + }; + + if (materiaEntry.MateriaId != 0) + materiaList.Add(materiaEntry); + } + + listingEntry.Materia = materiaList; + + reader.ReadBytes(0x6); // Padding + + listingEntry.RetainerName = Encoding.UTF8.GetString(reader.ReadBytes(0x20)).TrimEnd('\u0000'); + reader.ReadBytes(0x20); // Empty Buffer, was PlayerName pre 7.0 + + listingEntry.IsHq = reader.ReadBoolean(); + listingEntry.MateriaCount = reader.ReadByte(); + listingEntry.OnMannequin = reader.ReadBoolean(); + listingEntry.RetainerCityId = reader.ReadByte(); + + listingEntry.Stain1Id = reader.ReadByte(); + listingEntry.Stain2Id = reader.ReadByte(); + + reader.ReadBytes(0x4); // Padding + + if (listingEntry.CatalogId != 0) + listings.Add(listingEntry); + } + + output.InternalItemListings = listings; + reader.ReadByte(); // Was ListingIndexEnd + reader.ReadByte(); // Was ListingIndexStart + output.RequestId = reader.ReadUInt16(); + + return output; + } + } + } + + /// + /// This class represents the current market board offering of a single item from the network packet. + /// + public class MarketBoardItemListing : IMarketBoardItemListing + { + /// + /// Initializes a new instance of the class. + /// + internal MarketBoardItemListing() + { + } + + /// + /// Gets the artisan ID. + /// + public ulong ArtisanId { get; internal set; } + + /// + public uint ItemId => this.CatalogId; + + /// + /// Gets the catalog ID. + /// + public uint CatalogId { get; internal set; } + + /// + /// Gets a value indicating whether the item is HQ. + /// + public bool IsHq { get; internal set; } + + /// + /// Gets the item quantity. + /// + public uint ItemQuantity { get; internal set; } + + /// + /// Gets the listing ID. + /// + public ulong ListingId { get; internal set; } + + /// + /// Gets the list of materia attached to this item. + /// + public IReadOnlyList Materia { get; internal set; } = new List(); + + /// + /// Gets the amount of attached materia. + /// + public int MateriaCount { get; internal set; } + + /// + /// Gets a value indicating whether this item is on a mannequin. + /// + public bool OnMannequin { get; internal set; } + + /// + /// Gets the price per unit. + /// + public uint PricePerUnit { get; internal set; } + + /// + /// Gets the city ID of the retainer selling the item. + /// + public int RetainerCityId { get; internal set; } + + /// + /// Gets the ID of the retainer selling the item. + /// + public ulong RetainerId { get; internal set; } + + /// + /// Gets the name of the retainer. + /// + public string RetainerName { get; internal set; } + + /// + /// Gets the ID of the retainer's owner. + /// + public ulong RetainerOwnerId { get; internal set; } + + /// + /// Gets the first stain or applied dye of the item. + /// + public int Stain1Id { get; internal set; } + + /// + /// Gets the second stain or applied dye of the item. + /// + public int Stain2Id { get; internal set; } + + /// + /// Gets the total tax. + /// + public uint TotalTax { get; internal set; } + + /// + /// Gets or sets the time this offering was last reviewed. + /// + [Obsolete("Universalis Compatibility, contains a fake value", false)] + internal DateTime LastReviewTime { get; set; } = DateTime.UtcNow; + + /// + /// Gets the stain or applied dye of the item. + /// + [Obsolete("Universalis Compatibility, use Stain1Id and Stain2Id", false)] + internal int StainId => (this.Stain2Id << 8) | this.Stain1Id; + + /// + /// Gets or sets the player name. + /// + [Obsolete("Universalis Compatibility, contains a fake value", false)] + internal string PlayerName { get; set; } = string.Empty; + + /// + /// This represents the materia slotted to an . + /// + public class ItemMateria : IItemMateria + { + /// + /// Initializes a new instance of the class. + /// + internal ItemMateria() + { + } + + /// + /// Gets the materia index. + /// + public int Index { get; internal set; } + + /// + /// Gets the materia ID. + /// + public int MateriaId { get; internal set; } + } + } + } +} diff --git a/Cafe.Matcha/Network/Universalis/Structures/MarketBoardHistory.cs b/Cafe.Matcha/Network/Universalis/Structures/MarketBoardHistory.cs new file mode 100644 index 0000000..a5b459a --- /dev/null +++ b/Cafe.Matcha/Network/Universalis/Structures/MarketBoardHistory.cs @@ -0,0 +1,129 @@ +namespace Cafe.Matcha.Network.Universalis +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Text; + + /// + /// This class represents the market board history from a game network packet. + /// + public class MarketBoardHistory : IMarketBoardHistory + { + /// + /// Initializes a new instance of the class. + /// + internal MarketBoardHistory() + { + } + + /// + /// Gets the catalog ID. + /// + public uint CatalogId { get; private set; } + + /// + /// Gets the ID (for EXD) for the item being sold. + /// + public uint ItemId => this.CatalogId; + + /// + /// Gets the list of individual item listings. + /// + public IReadOnlyList HistoryListings => this.InternalHistoryListings; + + /// + /// Gets or sets a list of individual item listings. + /// + internal List InternalHistoryListings { get; set; } = new List(); + + /// + /// Read a object from memory. + /// + /// Data to read. + /// A new object. + public static MarketBoardHistory Read(byte[] data) + { + using (var stream = new MemoryStream(data)) + { + using (var reader = new BinaryReader(stream)) + { + var output = new MarketBoardHistory { CatalogId = reader.ReadUInt32() }; + + var historyListings = new List(); + for (var i = 0; i < 20; i++) + { + var price = reader.ReadUInt32(); + if (price == 0) + { + // no price means we reached the end of available listings + break; + } + + var listingEntry = new MarketBoardHistoryListing + { + SalePrice = price, + PurchaseTime = DateTimeOffset.FromUnixTimeSeconds(reader.ReadUInt32()).UtcDateTime, + Quantity = reader.ReadUInt32(), + IsHq = reader.ReadBoolean(), + OnMannequin = reader.ReadBoolean(), + BuyerName = Encoding.UTF8.GetString(reader.ReadBytes(0x20)).TrimEnd('\u0000'), + }; + + // Skip padding + reader.ReadBytes(0x2); + + historyListings.Add(listingEntry); + } + + output.InternalHistoryListings = historyListings; + + return output; + } + } + } + + /// + /// This class represents the market board history of a single item from the network packet. + /// + public class MarketBoardHistoryListing : IMarketBoardHistoryListing + { + /// + /// Initializes a new instance of the class. + /// + internal MarketBoardHistoryListing() + { + } + + /// + /// Gets the buyer's name. + /// + public string BuyerName { get; internal set; } + + /// + /// Gets a value indicating whether the item is HQ. + /// + public bool IsHq { get; internal set; } + + /// + /// Gets a value indicating whether the item is on a mannequin. + /// + public bool OnMannequin { get; internal set; } + + /// + /// Gets the time of purchase. + /// + public DateTime PurchaseTime { get; internal set; } + + /// + /// Gets the quantity. + /// + public uint Quantity { get; internal set; } + + /// + /// Gets the sale price. + /// + public uint SalePrice { get; internal set; } + } + } +} diff --git a/Cafe.Matcha/Network/Universalis/Structures/MarketBoardItemRequest.cs b/Cafe.Matcha/Network/Universalis/Structures/MarketBoardItemRequest.cs new file mode 100644 index 0000000..d41a20d --- /dev/null +++ b/Cafe.Matcha/Network/Universalis/Structures/MarketBoardItemRequest.cs @@ -0,0 +1,56 @@ +namespace Cafe.Matcha.Network.Universalis +{ + using System.Collections.Generic; + using System.IO; + + internal class MarketBoardItemRequest + { + /// + /// Gets the request status. Nonzero statuses are errors. + /// Known values: default=0; rate limited=0x70000003. + /// + public uint Status { get; private set; } + + /// + /// Gets a value indicating whether or not this request was successful. + /// + public bool Ok => this.Status == 0; + + /// + /// Gets the amount to arrive. + /// + public uint AmountToArrive { get; private set; } + + /// + /// Gets the offered item listings. + /// + public List Listings { get; } = new List(); + + /// + /// Gets the historical item listings. + /// + public List History { get; } = new List(); + + /// + /// Read a packet off the wire. + /// + /// Packet data (without header). + /// An object representing the data read. + public static MarketBoardItemRequest Read(byte[] data) + { + using (var stream = new MemoryStream(data)) + { + using (var reader = new BinaryReader(stream)) + { + var output = new MarketBoardItemRequest + { + Status = reader.ReadUInt32(), + AmountToArrive = reader.ReadUInt32(), + }; + + return output; + } + } + } + } +} diff --git a/Cafe.Matcha/Network/Universalis/Structures/MarketBoardPurchase.cs b/Cafe.Matcha/Network/Universalis/Structures/MarketBoardPurchase.cs new file mode 100644 index 0000000..ddbd215 --- /dev/null +++ b/Cafe.Matcha/Network/Universalis/Structures/MarketBoardPurchase.cs @@ -0,0 +1,47 @@ +namespace Cafe.Matcha.Network.Universalis +{ + using System.IO; + + /// + /// Represents market board purchase information. This message is received from the + /// server when a purchase is made at a market board. + /// + public class MarketBoardPurchase : IMarketBoardPurchase + { + private MarketBoardPurchase() + { + } + + /// + /// Gets the item ID of the item that was purchased. + /// + public uint CatalogId { get; private set; } + + /// + /// Gets the quantity of the item that was purchased. + /// + public uint ItemQuantity { get; private set; } + + /// + /// Reads market board purchase information from the struct at the provided pointer. + /// + /// Data to read. + /// An object representing the data read. + public static MarketBoardPurchase Read(byte[] data) + { + using (var stream = new MemoryStream(data)) + { + using (var reader = new BinaryReader(stream)) + { + var output = new MarketBoardPurchase(); + + output.CatalogId = reader.ReadUInt32(); + reader.ReadBytes(0x4); // Padding + output.ItemQuantity = reader.ReadUInt32(); + + return output; + } + } + } + } +} diff --git a/Cafe.Matcha/Network/Universalis/Structures/MarketBoardPurchaseHandler.cs b/Cafe.Matcha/Network/Universalis/Structures/MarketBoardPurchaseHandler.cs new file mode 100644 index 0000000..f799d19 --- /dev/null +++ b/Cafe.Matcha/Network/Universalis/Structures/MarketBoardPurchaseHandler.cs @@ -0,0 +1,87 @@ +namespace Cafe.Matcha.Network.Universalis +{ + using System.IO; + using System.Windows.Markup; + + /// + /// Represents market board purchase information. This message is sent from the + /// client when a purchase is made at a market board. + /// + public class MarketBoardPurchaseHandler : IMarketBoardPurchaseHandler + { + private MarketBoardPurchaseHandler() + { + } + + /// + /// Gets the object ID of the retainer associated with the sale. + /// + public ulong RetainerId { get; private set; } + + /// + /// Gets the object ID of the item listing. + /// + public ulong ListingId { get; private set; } + + /// + /// Gets the item ID of the item that was purchased. + /// + public uint CatalogId { get; private set; } + + /// + /// Gets the quantity of the item that was purchased. + /// + public uint ItemQuantity { get; private set; } + + /// + /// Gets the unit price of the item. + /// + public uint PricePerUnit { get; private set; } + + /// + /// Gets a value indicating whether the item is HQ. + /// + public bool IsHq { get; private set; } + + /// + /// Gets the total tax. + /// + public uint TotalTax { get; private set; } + + /// + /// Gets the city ID of the retainer selling the item. + /// + public int RetainerCityId { get; private set; } + + /// + /// Reads market board purchase information from the struct at the provided pointer. + /// + /// Data to read. + /// An object representing the data read. + public static MarketBoardPurchaseHandler Read(byte[] data) + { + using (var stream = new MemoryStream(data)) + { + using (var reader = new BinaryReader(stream)) + { + var output = new MarketBoardPurchaseHandler(); + + output.RetainerId = reader.ReadUInt64(); + output.ListingId = reader.ReadUInt64(); + + output.CatalogId = reader.ReadUInt32(); + output.ItemQuantity = reader.ReadUInt32(); + output.PricePerUnit = reader.ReadUInt32(); + output.TotalTax = reader.ReadUInt32(); + + reader.ReadUInt16(); // Slot + + output.IsHq = reader.ReadBoolean(); + output.RetainerCityId = reader.ReadByte(); + + return output; + } + } + } + } +} diff --git a/Cafe.Matcha/Network/Universalis/Structures/MarketTaxRates.cs b/Cafe.Matcha/Network/Universalis/Structures/MarketTaxRates.cs new file mode 100644 index 0000000..5784a62 --- /dev/null +++ b/Cafe.Matcha/Network/Universalis/Structures/MarketTaxRates.cs @@ -0,0 +1,125 @@ +namespace Cafe.Matcha.Network.Universalis +{ + using System; + using System.IO; + + /// + /// This class represents the "Result Dialog" packet. This is also used e.g. for reduction results, but we only care about tax rates. + /// We can do that by checking the "Category" field. + /// + public class MarketTaxRates : IMarketTaxRates + { + private MarketTaxRates() + { + } + + /// + /// Gets the category of this ResultDialog packet. + /// + public uint Category { get; private set; } + + /// + /// Gets the tax rate in Limsa Lominsa. + /// + public uint LimsaLominsaTax { get; private set; } + + /// + /// Gets the tax rate in Gridania. + /// + public uint GridaniaTax { get; private set; } + + /// + /// Gets the tax rate in Ul'dah. + /// + public uint UldahTax { get; private set; } + + /// + /// Gets the tax rate in Ishgard. + /// + public uint IshgardTax { get; private set; } + + /// + /// Gets the tax rate in Kugane. + /// + public uint KuganeTax { get; private set; } + + /// + /// Gets the tax rate in the Crystarium. + /// + public uint CrystariumTax { get; private set; } + + /// + /// Gets the tax rate in Sharlayan. + /// + public uint SharlayanTax { get; private set; } + + /// + /// Gets the tax rate in Tuliyollal. + /// + public uint TuliyollalTax { get; private set; } + + /// + /// Gets until when these values are valid. + /// + public DateTime ValidUntil { get; private set; } + + /// + /// Read a object from memory. + /// + /// Data to read. + /// A new object. + public static MarketTaxRates Read(byte[] data) + { + using (var stream = new MemoryStream(data)) + { + using (var reader = new BinaryReader(stream)) + { + var output = new MarketTaxRates(); + + output.Category = reader.ReadUInt32(); + stream.Position += 4; + output.LimsaLominsaTax = reader.ReadUInt32(); + output.GridaniaTax = reader.ReadUInt32(); + output.UldahTax = reader.ReadUInt32(); + output.IshgardTax = reader.ReadUInt32(); + output.KuganeTax = reader.ReadUInt32(); + output.CrystariumTax = reader.ReadUInt32(); + output.SharlayanTax = reader.ReadUInt32(); + output.TuliyollalTax = reader.ReadUInt32(); + + output.ValidUntil = DateTime.Now; // Dalamud never reads this packet, so setting it to Now just to be safe + + return output; + } + } + } + + /// + /// Generate a MarketTaxRates wrapper class from information located in a CustomTalk packet. + /// + /// Data to read. + /// Returns a wrapped and ready-to-go MarketTaxRates record. + public static MarketTaxRates ReadFromCustomTalk(byte[] data) + { + using (var stream = new MemoryStream(data)) + { + using (var reader = new BinaryReader(stream)) + { + return new MarketTaxRates + { + Category = 0xB0009, // shim + LimsaLominsaTax = reader.ReadUInt32(), + GridaniaTax = reader.ReadUInt32(), + UldahTax = reader.ReadUInt32(), + IshgardTax = reader.ReadUInt32(), + KuganeTax = reader.ReadUInt32(), + CrystariumTax = reader.ReadUInt32(), + SharlayanTax = reader.ReadUInt32(), + TuliyollalTax = reader.ReadUInt32(), + ValidUntil = DateTimeOffset.FromUnixTimeSeconds(reader.ReadUInt32()).UtcDateTime, + }; + } + } + } + } +} diff --git a/Cafe.Matcha/Network/Universalis/Types/MarketBoardCurrentOfferings.cs b/Cafe.Matcha/Network/Universalis/Types/MarketBoardCurrentOfferings.cs deleted file mode 100644 index ac35be7..0000000 --- a/Cafe.Matcha/Network/Universalis/Types/MarketBoardCurrentOfferings.cs +++ /dev/null @@ -1,119 +0,0 @@ -namespace Cafe.Matcha.Network.Universalis -{ - using System; - using System.Collections.Generic; - using System.IO; - using System.Text; - - internal class MarketBoardCurrentOfferings - { - public class MarketBoardItemListing - { - public ulong ListingId; - public ulong RetainerId; - public ulong RetainerOwnerId; - public ulong ArtisanId; - public uint PricePerUnit; - public uint TotalTax; - public uint ItemQuantity; - public uint CatalogId; - public DateTime LastReviewTime; - - public class ItemMateria - { - public int MateriaId; - public int Index; - } - - public List Materia; - - public string RetainerName; - public string PlayerName; - public bool IsHq; - public int MateriaCount; - public bool OnMannequin; - public int RetainerCityId; - public int StainId; - } - - public List ItemListings; - - public int ListingIndexEnd; - public int ListingIndexStart; - public int RequestId; - - public static MarketBoardCurrentOfferings Read(byte[] message) - { - var output = new MarketBoardCurrentOfferings(); - - using (var stream = new MemoryStream(message)) - { - using (var reader = new BinaryReader(stream)) - { - output.ItemListings = new List(); - - for (var i = 0; i < 10; i++) - { - var listingEntry = new MarketBoardItemListing(); - - listingEntry.ListingId = reader.ReadUInt64(); - listingEntry.RetainerId = reader.ReadUInt64(); - listingEntry.RetainerOwnerId = reader.ReadUInt64(); - listingEntry.ArtisanId = reader.ReadUInt64(); - listingEntry.PricePerUnit = reader.ReadUInt32(); - listingEntry.TotalTax = reader.ReadUInt32(); - listingEntry.ItemQuantity = reader.ReadUInt32(); - listingEntry.CatalogId = reader.ReadUInt32(); - listingEntry.LastReviewTime = DateTimeOffset.UtcNow.AddSeconds(-reader.ReadUInt16()).DateTime; - - reader.ReadUInt16(); // container - reader.ReadUInt32(); // slot - reader.ReadUInt16(); // durability - reader.ReadUInt16(); // spiritbond - - listingEntry.Materia = new List(); - - for (var materiaIndex = 0; materiaIndex < 5; materiaIndex++) - { - var materiaVal = reader.ReadUInt16(); - - var materiaEntry = new MarketBoardItemListing.ItemMateria(); - materiaEntry.MateriaId = (materiaVal & 0xFF0) >> 4; - materiaEntry.Index = materiaVal & 0xF; - - if (materiaEntry.MateriaId != 0) - { - listingEntry.Materia.Add(materiaEntry); - } - } - - reader.ReadUInt16(); - reader.ReadUInt32(); - - listingEntry.RetainerName = Encoding.UTF8.GetString(reader.ReadBytes(32)).TrimEnd(new[] { '\u0000' }); - listingEntry.PlayerName = Encoding.UTF8.GetString(reader.ReadBytes(32)).TrimEnd(new[] { '\u0000' }); - listingEntry.IsHq = reader.ReadBoolean(); - listingEntry.MateriaCount = reader.ReadByte(); - listingEntry.OnMannequin = reader.ReadBoolean(); - listingEntry.RetainerCityId = reader.ReadByte(); - listingEntry.StainId = reader.ReadUInt16(); - - reader.ReadUInt16(); - reader.ReadUInt32(); - - if (listingEntry.CatalogId != 0) - { - output.ItemListings.Add(listingEntry); - } - } - - output.ListingIndexEnd = reader.ReadByte(); - output.ListingIndexStart = reader.ReadByte(); - output.RequestId = reader.ReadUInt16(); - } - } - - return output; - } - } -} diff --git a/Cafe.Matcha/Network/Universalis/Types/MarketBoardHistory.cs b/Cafe.Matcha/Network/Universalis/Types/MarketBoardHistory.cs deleted file mode 100644 index 40002a8..0000000 --- a/Cafe.Matcha/Network/Universalis/Types/MarketBoardHistory.cs +++ /dev/null @@ -1,67 +0,0 @@ -namespace Cafe.Matcha.Network.Universalis -{ - using System; - using System.Collections.Generic; - using System.IO; - using System.Text; - - internal class MarketBoardHistory - { - public uint CatalogId; - public uint CatalogId2; - - public class MarketBoardHistoryListing - { - public uint SalePrice; - public DateTime PurchaseTime; - public uint Quantity; - public bool IsHq; - public bool OnMannequin; - - public string BuyerName; - - public uint CatalogId; - } - - public List HistoryListings; - - public static MarketBoardHistory Read(byte[] message) - { - var output = new MarketBoardHistory(); - - using (var stream = new MemoryStream(message)) - { - using (var reader = new BinaryReader(stream)) - { - output.CatalogId = reader.ReadUInt32(); - output.CatalogId2 = reader.ReadUInt32(); - - output.HistoryListings = new List(); - - for (var i = 0; i < 10; i++) - { - var listingEntry = new MarketBoardHistoryListing(); - - listingEntry.SalePrice = reader.ReadUInt32(); - listingEntry.PurchaseTime = DateTimeOffset.FromUnixTimeSeconds(reader.ReadUInt32()).UtcDateTime; - listingEntry.Quantity = reader.ReadUInt32(); - listingEntry.IsHq = reader.ReadBoolean(); - - reader.ReadBoolean(); - - listingEntry.OnMannequin = reader.ReadBoolean(); - listingEntry.BuyerName = Encoding.UTF8.GetString(reader.ReadBytes(33)).TrimEnd(new[] { '\u0000' }); - listingEntry.CatalogId = reader.ReadUInt32(); - - if (listingEntry.CatalogId != 0) - { - output.HistoryListings.Add(listingEntry); - } - } - } - } - - return output; - } - } -} diff --git a/Cafe.Matcha/Utils/Log.cs b/Cafe.Matcha/Utils/Log.cs index 553cad6..dcc2905 100644 --- a/Cafe.Matcha/Utils/Log.cs +++ b/Cafe.Matcha/Utils/Log.cs @@ -36,7 +36,8 @@ public static void Debug(LogType type, string message) Add(type, 'D', message); } - public static void Packet(byte[] byteArray) { + public static void Packet(byte[] byteArray) + { StringBuilder hexDump = new StringBuilder(); const int lineLength = 16;