From 555cc8e023e0088b7c708486405773fa949930f5 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 11:19:16 -0400 Subject: [PATCH 1/4] L186: send 0xDF on every field change v83 sends PLAYER_UPDATE (0xDF) from FUN_00a03eba inside CField::OnSetField whenever a field is set, not only when the character data arrives. HeavenClient sent it only on entering the game, so a plain map change sent nothing. The server's handler is still a no-op. --- HeavenClient/Net/Handlers/SetFieldHandlers.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/HeavenClient/Net/Handlers/SetFieldHandlers.cpp b/HeavenClient/Net/Handlers/SetFieldHandlers.cpp index 85b1c1510..e7f9cc647 100644 --- a/HeavenClient/Net/Handlers/SetFieldHandlers.cpp +++ b/HeavenClient/Net/Handlers/SetFieldHandlers.cpp @@ -123,6 +123,10 @@ namespace ms if (auto room = UI::get().get_element()) room->close(); + // v83 (FUN_00a03eba, from CField::OnSetField) sends this on every + // field change, not only on entering the game. + PlayerUpdatePacket().dispatch(); + transition(mapid, portalid, chase); } From fcc4475d2bb907395b588b130c41b77038cc8b9a Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 11:25:52 -0400 Subject: [PATCH 2/4] L186: hold the cash shop to one request at a time v83 keeps a pending flag in CCashShop (+0x18). Every cash shop request checks it first and returns silently while it is set, and sets it once the request is sent. Only a QUERY_CASH or CASHSHOP_OPERATION result clears it. The checks happen before the buy and gift dialogs open, so a click on Buy while an answer is awaited does nothing. HeavenClient now does the same. The Check button, the balance check on regaining focus, the wish list buttons, buying, gifting, slot expansions, and taking items out of or putting them back in the Cash Inventory all go through UICashShop::request. Results 324 and 325 clear the flag. The server already answers every request the client sends, including a failure for the modes it does not serve. --- HeavenClient/IO/UI.cpp | 2 +- HeavenClient/IO/UITypes/UICashBuy.cpp | 22 +++++++----- HeavenClient/IO/UITypes/UICashGift.cpp | 5 ++- HeavenClient/IO/UITypes/UICashShop.cpp | 34 +++++++++++++++---- HeavenClient/IO/UITypes/UICashShop.h | 8 +++++ .../Net/Handlers/CashShopHandlers.cpp | 5 +++ 6 files changed, 59 insertions(+), 17 deletions(-) diff --git a/HeavenClient/IO/UI.cpp b/HeavenClient/IO/UI.cpp index eb590892b..b7e6a8763 100644 --- a/HeavenClient/IO/UI.cpp +++ b/HeavenClient/IO/UI.cpp @@ -129,7 +129,7 @@ namespace ms Music::set_bgmvolume(Music::configured_volume()); // v83 checks the balances on coming back to the Cash Shop. - if (get_element()) + if (auto cashshop = get_element(); cashshop && cashshop->request()) CheckCashPacket().dispatch(); } else { diff --git a/HeavenClient/IO/UITypes/UICashBuy.cpp b/HeavenClient/IO/UITypes/UICashBuy.cpp index 480359282..2cf87844b 100644 --- a/HeavenClient/IO/UITypes/UICashBuy.cpp +++ b/HeavenClient/IO/UITypes/UICashBuy.cpp @@ -16,8 +16,10 @@ // along with this program. If not, see . // ////////////////////////////////////////////////////////////////////////////////// #include "UICashBuy.h" +#include "UICashShop.h" #include "../KeyAction.h" +#include "../UI.h" #include "../Components/MapleButton.h" @@ -164,15 +166,19 @@ namespace ms void UICashBuy::buy() { CashType cash_type = PAYMENTS[payment]; + auto cashshop = UI::get().get_element(); - if (is_package(item_id)) - BuyCashPackagePacket(cash_type, sn).dispatch(); - else if (is_expansion(item_id) && sn == 0) - ExpandSlotsPacket(cash_type, expanded(item_id)).dispatch(); - else if (is_expansion(item_id)) - ExpandSlotsPacket(cash_type, expanded(item_id), sn).dispatch(); - else - BuyCashItemPacket(cash_type, sn).dispatch(); + if (cashshop && cashshop->request()) + { + if (is_package(item_id)) + BuyCashPackagePacket(cash_type, sn).dispatch(); + else if (is_expansion(item_id) && sn == 0) + ExpandSlotsPacket(cash_type, expanded(item_id)).dispatch(); + else if (is_expansion(item_id)) + ExpandSlotsPacket(cash_type, expanded(item_id), sn).dispatch(); + else + BuyCashItemPacket(cash_type, sn).dispatch(); + } deactivate(); } diff --git a/HeavenClient/IO/UITypes/UICashGift.cpp b/HeavenClient/IO/UITypes/UICashGift.cpp index 1008f52f9..6a875d9af 100644 --- a/HeavenClient/IO/UITypes/UICashGift.cpp +++ b/HeavenClient/IO/UITypes/UICashGift.cpp @@ -16,6 +16,7 @@ // along with this program. If not, see . // ////////////////////////////////////////////////////////////////////////////////// #include "UICashGift.h" +#include "UICashShop.h" #include "../KeyAction.h" #include "../UI.h" @@ -181,7 +182,9 @@ namespace ms if (asking_birthday) { // The server answers a wrong code with its own notice. - GiftCashItemPacket(birthday_code(birthday.get_text()), sn, to.get_text(), message.get_text()).dispatch(); + if (auto cashshop = UI::get().get_element(); cashshop && cashshop->request()) + GiftCashItemPacket(birthday_code(birthday.get_text()), sn, to.get_text(), message.get_text()).dispatch(); + close(); return; diff --git a/HeavenClient/IO/UITypes/UICashShop.cpp b/HeavenClient/IO/UITypes/UICashShop.cpp index a2930c5a5..7d7d91142 100644 --- a/HeavenClient/IO/UITypes/UICashShop.cpp +++ b/HeavenClient/IO/UITypes/UICashShop.cpp @@ -96,7 +96,7 @@ namespace ms } } - UICashShop::UICashShop() : tab(EVENT_TAB), subtab(0), page(0), preview_index(0), female(false), credit(0), points(0), prepaid(0), cash_row(0), item_tab(InventoryType::Id::EQUIP), item_row(0) + UICashShop::UICashShop() : tab(EVENT_TAB), subtab(0), page(0), preview_index(0), female(false), credit(0), points(0), prepaid(0), pending(false), cash_row(0), item_tab(InventoryType::Id::EQUIP), item_row(0) { nl::node CashShop = NxFiles::ui_classic["CashShop.img"]; nl::node Base = CashShop["Base"]; @@ -430,7 +430,8 @@ namespace ms return Button::State::NORMAL; } case Buttons::BT_CHECK: - CheckCashPacket().dispatch(); + if (request()) + CheckCashPacket().dispatch(); return Button::State::NORMAL; default: @@ -448,18 +449,21 @@ namespace ms { const Commodity& commodity = listed[index]; - if (remove) + if (pending) + return Button::State::NORMAL; + + if (remove && request()) SetWishListPacket(unwish(wish_list, commodity.sn)).dispatch(); else if (list_button % 3 == 0) UI::get().emplace(commodity, credit, points, prepaid); else if (list_button % 3 == 1) UI::get().emplace(commodity); - else if (list_button % 3 == 2) + else if (list_button % 3 == 2 && request()) SetWishListPacket(wish(wish_list, commodity.sn)).dispatch(); } } - if (buttonid >= Buttons::BT_EX_EQUIP && buttonid <= Buttons::BT_EX_TRUNK) + if (buttonid >= Buttons::BT_EX_EQUIP && buttonid <= Buttons::BT_EX_TRUNK && !pending) { // Four slots for 4,000 NX, shown as the expansion item: 9111000 // to 9114000 for the bags, 9110000 for the storage. @@ -585,7 +589,8 @@ namespace ms InventoryType::Id type = InventoryType::by_item_id(item.item_id); // The server picks another slot, or refuses, if this one is taken. - TakeOutCashItemPacket(item.cash_id, type, inventory.find_free_slot(type)).dispatch(); + if (request()) + TakeOutCashItemPacket(item.cash_id, type, inventory.find_free_slot(type)).dispatch(); return; } @@ -595,11 +600,26 @@ namespace ms { int16_t slot = static_cast(item_row * ITEM_COLUMNS + item_cell + 1); - if (int64_t cash_id = inventory.get_cash_id(item_tab, slot)) + if (int64_t cash_id = inventory.get_cash_id(item_tab, slot); cash_id && request()) PutBackCashItemPacket(cash_id, item_tab).dispatch(); } } + bool UICashShop::request() + { + if (pending) + return false; + + pending = true; + + return true; + } + + void UICashShop::answered() + { + pending = false; + } + int16_t UICashShop::grid_cell(Point cursor, Point grid, size_t columns, size_t rows) const { Point in_grid = cursor - grid; diff --git a/HeavenClient/IO/UITypes/UICashShop.h b/HeavenClient/IO/UITypes/UICashShop.h index e3a3f1799..bc2614606 100644 --- a/HeavenClient/IO/UITypes/UICashShop.h +++ b/HeavenClient/IO/UITypes/UICashShop.h @@ -76,6 +76,13 @@ namespace ms void remove_cash_item(int64_t cash_id); void set_wish_list(const std::vector& sns); + // v83 (CCashShop+0x18) has one request out at a time and ignores the + // buttons until its answer. Returns whether one may be sent, counting + // it as sent. + bool request(); + // A balance or an operation result came. + void answered(); + private: void change_tab(cash_catalog::Tab tab); void change_subtab(size_t subtab); @@ -165,6 +172,7 @@ namespace ms int32_t credit; int32_t points; int32_t prepaid; + bool pending; std::vector cash_items; Slider cash_slider; diff --git a/HeavenClient/Net/Handlers/CashShopHandlers.cpp b/HeavenClient/Net/Handlers/CashShopHandlers.cpp index 5b74875f4..5cb0e79aa 100644 --- a/HeavenClient/Net/Handlers/CashShopHandlers.cpp +++ b/HeavenClient/Net/Handlers/CashShopHandlers.cpp @@ -113,7 +113,10 @@ namespace ms int32_t prepaid = recv.read_int(); if (auto cashshop = UI::get().get_element()) + { + cashshop->answered(); cashshop->set_balance(credit, points, prepaid); + } } namespace @@ -222,6 +225,8 @@ namespace ms if (!cashshop) return; + cashshop->answered(); + uint8_t mode = static_cast(recv.read_byte()); switch (mode) From f9b780a7f3fe2c7b40a85ed4be047593482f1168 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 11:29:05 -0400 Subject: [PATCH 3/4] L186: let the cash shop take the cursor as soon as a dialog closes When a focused dialog such as the purchase notice went inactive, the cash shop's UI state cleared the focus but returned the old cursor state without passing the event on. The button under the cursor never saw it hover, so the first click after closing the dialog did nothing. The event now falls through to the front window, so the first Buy click after the notice opens the buy dialog. --- HeavenClient/IO/UIStateCashShop.cpp | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/HeavenClient/IO/UIStateCashShop.cpp b/HeavenClient/IO/UIStateCashShop.cpp index 21656ad17..a3fbc0f47 100644 --- a/HeavenClient/IO/UIStateCashShop.cpp +++ b/HeavenClient/IO/UIStateCashShop.cpp @@ -90,26 +90,20 @@ namespace ms return focusedelement->send_cursor(clicked, cursorpos); } - else - { - focused = UIElement::NONE; - return cursorstate; - } + // A closed dialog lets the window under it have the cursor at + // once, or the first click after it would find no button hovered. + focused = UIElement::NONE; } - else + + if (auto front = get_front()) { - if (auto front = get_front()) - { - remove_cursor(front->get_type()); + remove_cursor(front->get_type()); - return front->send_cursor(clicked, cursorpos); - } - else - { - return Cursor::State::IDLE; - } + return front->send_cursor(clicked, cursorpos); } + + return Cursor::State::IDLE; } UIState::Iterator UIStateCashShop::pre_add(UIElement::Type type, bool toggled, bool is_focused) From 73cd2af4b590ccc66d7eaea4badc41327521354b Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sun, 18 Oct 2026 11:57:35 -0400 Subject: [PATCH 4/4] L190: wear cash equips 100 above their slot, as v83 does The v83 client decides an equip is cash by its Cash Shop serial (4f2cbb) and wears it at -(100 + slot), over whatever regular item is in that slot. The equip window draws the cash item in the cell when there is one, and dragging or double-clicking the cell takes that item off. The server already wrote positions under -100 as the cash list in the character data, but nothing ever put an item there. The server now only lets an item with a cash serial be worn 100 below its slot, and any other item at the slot itself. The overall and two-handed conflicts stay with the regular slots. HeavenClient used to read the worn cash items into the regular slots, where they overwrote the regular items. It now keeps them 100 above, shows them in the equip window and on the character over the regular ones, and sends the cash position when wearing an item with a serial or taking off the one a cell shows. Scrolls dropped on a cell go to the item it shows. The character keeps drawing the regular weapon, because HeavenClient has no cash weapon art over a weapon's stances. --- .../Character/Inventory/Inventory.cpp | 12 +++++++ HeavenClient/Character/Inventory/Inventory.h | 12 +++++++ HeavenClient/Character/Player.cpp | 13 ++++++-- HeavenClient/IO/UITypes/UIEquipInventory.cpp | 26 +++++++++------ HeavenClient/IO/UITypes/UIEquipInventory.h | 4 ++- HeavenClient/IO/UITypes/UIItemInventory.cpp | 22 +++++++++---- .../Net/Handlers/Helpers/CharacterParser.cpp | 6 ++-- HeavenClient/Net/Packets/InventoryPackets.h | 7 ++-- game/src/inventory.rs | 33 +++++++++++++++++-- 9 files changed, 108 insertions(+), 27 deletions(-) diff --git a/HeavenClient/Character/Inventory/Inventory.cpp b/HeavenClient/Character/Inventory/Inventory.cpp index 0b79e2072..6886bf225 100644 --- a/HeavenClient/Character/Inventory/Inventory.cpp +++ b/HeavenClient/Character/Inventory/Inventory.cpp @@ -321,6 +321,18 @@ namespace ms } } + int16_t Inventory::equip_position(int16_t slot, EquipSlot::Id part) const + { + return get_cash_id(InventoryType::Id::EQUIP, slot) ? part + CASH_EQUIP : part; + } + + int16_t Inventory::shown_equip(EquipSlot::Id part) const + { + int16_t cash = part + CASH_EQUIP; + + return inventories[InventoryType::Id::EQUIPPED].count(cash) ? cash : part; + } + int16_t Inventory::find_free_slot(InventoryType::Id type) const { int16_t counter = 1; diff --git a/HeavenClient/Character/Inventory/Inventory.h b/HeavenClient/Character/Inventory/Inventory.h index c32d20735..f20cb2da9 100644 --- a/HeavenClient/Character/Inventory/Inventory.h +++ b/HeavenClient/Character/Inventory/Inventory.h @@ -57,6 +57,9 @@ namespace ms ADDCOUNT }; + // How far above its slot a cash item is worn. + static constexpr int16_t CASH_EQUIP = 100; + // Return the move type by value static Movement movementbyvalue(int8_t value); @@ -121,6 +124,15 @@ namespace ms // Find a free slot for the specified equip. EquipSlot::Id find_equipslot(int32_t itemid) const; + // The worn position for the equip in Equip tab `slot` at `part`. Like + // v83 (FUN_004f2cbb), an item with a Cash Shop serial is worn 100 + // above its slot, over whatever regular item is there. + int16_t equip_position(int16_t slot, EquipSlot::Id part) const; + + // The worn position whose item is shown at `part`: the cash item's + // if there is one (FUN_007fec81), else the regular one's. + int16_t shown_equip(EquipSlot::Id part) const; + // Find a free slot in the specified inventory. int16_t find_free_slot(InventoryType::Id type) const; diff --git a/HeavenClient/Character/Player.cpp b/HeavenClient/Character/Player.cpp index e2906a7f2..47e50f82c 100644 --- a/HeavenClient/Character/Player.cpp +++ b/HeavenClient/Character/Player.cpp @@ -193,10 +193,19 @@ namespace ms void Player::change_equip(int16_t slot) { - if (int32_t itemid = inventory.get_item_id(InventoryType::Id::EQUIPPED, slot)) + EquipSlot::Id part = EquipSlot::by_id(slot % Inventory::CASH_EQUIP); + + if (part == EquipSlot::Id::NONE) + return; + + // The look wears a cash item over the regular one, but has no cash + // weapons (v83 draws them over the regular weapon's stances). + int16_t shown = part == EquipSlot::Id::WEAPON ? part : inventory.shown_equip(part); + + if (int32_t itemid = inventory.get_item_id(InventoryType::Id::EQUIPPED, shown)) look.add_equip(itemid); else - look.remove_equip(EquipSlot::by_id(slot)); + look.remove_equip(part); } void Player::use_item(int32_t itemid) diff --git a/HeavenClient/IO/UITypes/UIEquipInventory.cpp b/HeavenClient/IO/UITypes/UIEquipInventory.cpp index 9fd6da444..93284e628 100644 --- a/HeavenClient/IO/UITypes/UIEquipInventory.cpp +++ b/HeavenClient/IO/UITypes/UIEquipInventory.cpp @@ -219,12 +219,14 @@ namespace ms void UIEquipInventory::update_slot(EquipSlot::Id slot) { - if (int32_t item_id = inventory.get_item_id(InventoryType::Id::EQUIPPED, slot)) + int16_t shown = inventory.shown_equip(slot); + + if (int32_t item_id = inventory.get_item_id(InventoryType::Id::EQUIPPED, shown)) { const Texture &texture = ItemData::get(item_id).get_icon(false); icons[slot] = std::make_unique( - std::make_unique(slot), + std::make_unique(shown), texture, -1 ); @@ -270,7 +272,7 @@ namespace ms return Cursor::State::GRABBING; } else { - show_equip(slot); + show_equip(inventory.shown_equip(slot)); return Cursor::State::CANGRAB; } @@ -299,7 +301,8 @@ namespace ms if (icons[slot]) if (int16_t freeslot = inventory.find_free_slot(InventoryType::Id::EQUIP)) - Stage::get().get_player().request(UnequipItemPacket(slot, freeslot), ExclRequest::WAIT); + Stage::get().get_player().request(UnequipItemPacket(inventory.shown_equip(slot), freeslot), + ExclRequest::WAIT); } bool UIEquipInventory::is_in_range(Point cursorpos) const @@ -334,8 +337,8 @@ namespace ms void UIEquipInventory::modify(int16_t pos, int8_t mode, int16_t arg) { - EquipSlot::Id eqpos = EquipSlot::by_id(pos); - EquipSlot::Id eqarg = EquipSlot::by_id(arg); + EquipSlot::Id eqpos = EquipSlot::by_id(pos % Inventory::CASH_EQUIP); + EquipSlot::Id eqarg = EquipSlot::by_id(arg % Inventory::CASH_EQUIP); switch (mode) { @@ -350,7 +353,7 @@ namespace ms } } - void UIEquipInventory::show_equip(EquipSlot::Id slot) + void UIEquipInventory::show_equip(int16_t slot) { UI::get().show_equip(Tooltip::Parent::EQUIPINVENTORY, slot); } @@ -386,7 +389,7 @@ namespace ms void UIEquipInventory::EquipIcon::drop_on_equips(EquipSlot::Id slot) const { - if (source == slot) + if (source % Inventory::CASH_EQUIP == slot) Sound(Sound::Name::DRAGEND).play(); } @@ -406,8 +409,11 @@ namespace ms if (equip) { - if (eqslot == source) - Stage::get().get_player().request(EquipItemPacket(slot, eqslot), ExclRequest::WAIT); + // Only an item worn at the same position swaps with this one. + int16_t worn = Stage::get().get_player().get_inventory().equip_position(slot, eqslot); + + if (worn == source) + Stage::get().get_player().request(EquipItemPacket(slot, worn), ExclRequest::WAIT); } else { Stage::get().get_player().request(UnequipItemPacket(source, slot), ExclRequest::WAIT); diff --git a/HeavenClient/IO/UITypes/UIEquipInventory.h b/HeavenClient/IO/UITypes/UIEquipInventory.h index 1dabfcae7..5b42ce749 100644 --- a/HeavenClient/IO/UITypes/UIEquipInventory.h +++ b/HeavenClient/IO/UITypes/UIEquipInventory.h @@ -54,7 +54,8 @@ namespace ms Button::State button_pressed(uint16_t buttonid) override; private: - void show_equip(EquipSlot::Id slot); + // Shows the tooltip for worn position `slot`. + void show_equip(int16_t slot); void clear_tooltip(); void load_icons(); @@ -66,6 +67,7 @@ namespace ms class EquipIcon : public Icon::Type { public: + // For the item at worn position `source`. EquipIcon(int16_t source); void drop_on_stage() const override; diff --git a/HeavenClient/IO/UITypes/UIItemInventory.cpp b/HeavenClient/IO/UITypes/UIItemInventory.cpp index 1f633eac3..12e4054db 100644 --- a/HeavenClient/IO/UITypes/UIItemInventory.cpp +++ b/HeavenClient/IO/UITypes/UIItemInventory.cpp @@ -676,7 +676,11 @@ namespace ms { case InventoryType::Id::EQUIP: if (can_wear_equip(slot)) - Stage::get().get_player().request(EquipItemPacket(slot, inventory.find_equipslot(item_id)), ExclRequest::WAIT); + { + int16_t worn = inventory.equip_position(slot, inventory.find_equipslot(item_id)); + + Stage::get().get_player().request(EquipItemPacket(slot, worn), ExclRequest::WAIT); + } break; case InventoryType::Id::USE: @@ -1269,8 +1273,12 @@ namespace ms { case InventoryType::Id::EQUIP: if (eqsource == eqslot) + { + int16_t worn = Stage::get().get_player().get_inventory().equip_position(source, eqslot); + if (parent.can_wear_equip(source)) - Stage::get().get_player().request(EquipItemPacket(source, eqslot), ExclRequest::WAIT); + Stage::get().get_player().request(EquipItemPacket(source, worn), ExclRequest::WAIT); + } Sound(Sound::Name::DRAGEND).play(); @@ -1300,13 +1308,15 @@ namespace ms const Inventory &inventory = player.get_inventory(); - // Also refuses an empty slot. - if (!scroll_rules::fits(item_id, inventory.get_item_id(InventoryType::Id::EQUIPPED, eqslot))) + // The scroll goes on the item shown there. Also refuses an empty slot. + int16_t target = inventory.shown_equip(eqslot); + + if (!scroll_rules::fits(item_id, inventory.get_item_id(InventoryType::Id::EQUIPPED, target))) break; - auto send = [slot = source, eqslot](bool whitescroll) + auto send = [slot = source, target](bool whitescroll) { - Stage::get().get_player().request(ScrollEquipPacket(slot, eqslot, whitescroll), ExclRequest::WAIT); + Stage::get().get_player().request(ScrollEquipPacket(slot, target, whitescroll), ExclRequest::WAIT); }; // Either answer scrolls; only Yes uses the White Scroll. diff --git a/HeavenClient/Net/Handlers/Helpers/CharacterParser.cpp b/HeavenClient/Net/Handlers/Helpers/CharacterParser.cpp index d44ed8705..226470e5f 100644 --- a/HeavenClient/Net/Handlers/Helpers/CharacterParser.cpp +++ b/HeavenClient/Net/Handlers/Helpers/CharacterParser.cpp @@ -32,14 +32,16 @@ namespace ms recv.skip(8); + // Worn regular items, worn cash items, and the Equip tab. Like v83, the + // cash items are kept 100 above their slot. for (size_t i = 0; i < 3; i++) { - InventoryType::Id inv = (i == 0) ? InventoryType::EQUIPPED : InventoryType::EQUIP; + InventoryType::Id inv = (i < 2) ? InventoryType::EQUIPPED : InventoryType::EQUIP; int16_t pos = recv.read_short(); while (pos != 0) { - int16_t slot = (i == 1) ? -pos : pos; + int16_t slot = (i == 1) ? pos + Inventory::CASH_EQUIP : pos; ItemParser::parse_item(recv, inv, slot, invent); pos = recv.read_short(); } diff --git a/HeavenClient/Net/Packets/InventoryPackets.h b/HeavenClient/Net/Packets/InventoryPackets.h index 87af6e025..7bcfcd78f 100644 --- a/HeavenClient/Net/Packets/InventoryPackets.h +++ b/HeavenClient/Net/Packets/InventoryPackets.h @@ -76,12 +76,13 @@ namespace ms } }; - // Packet which requests that an item is equipped + // Packet which requests that an item is equipped at worn position `dest` + // (Inventory::equip_position). // Opcode: MOVE_ITEM(71) class EquipItemPacket : public MoveItemPacket { public: - EquipItemPacket(int16_t src, EquipSlot::Id dest) : MoveItemPacket(InventoryType::Id::EQUIP, src, -dest, 1) {} + EquipItemPacket(int16_t src, int16_t dest) : MoveItemPacket(InventoryType::Id::EQUIP, src, -dest, 1) {} }; // Packet which requests that an item is unequipped @@ -273,7 +274,7 @@ namespace ms class ScrollEquipPacket : public OutPacket { public: - ScrollEquipPacket(int16_t source, EquipSlot::Id target, bool whitescroll) : OutPacket(OutPacket::Opcode::SCROLL_EQUIP) + ScrollEquipPacket(int16_t source, int16_t target, bool whitescroll) : OutPacket(OutPacket::Opcode::SCROLL_EQUIP) { write_time(); write_short(source); diff --git a/game/src/inventory.rs b/game/src/inventory.rs index 1b61d2502..3a244fe4f 100644 --- a/game/src/inventory.rs +++ b/game/src/inventory.rs @@ -109,8 +109,14 @@ pub mod equip_slot { } } - /// Whether `item_id` may be worn at `position`. - pub fn fits(item_id: i32, position: i16) -> bool { + /// Whether `item_id` may be worn at `position`. As in v83, an item with + /// a cash serial is worn at its slot less 100, and any other at its slot. + pub fn fits(item_id: i32, cash: bool, position: i16) -> bool { + let position = match (cash, position) { + (true, -199..=-101) => position + 100, + (false, -99..=-1) => position, + _ => return false, + }; match for_item(item_id) { Some(slot) if RINGS.contains(&slot) => RINGS.contains(&position), Some(slot) => slot == position, @@ -570,7 +576,7 @@ impl Inventory { .get(InventoryType::Equip, from) .ok_or(InventoryError::InvalidSlot)? .clone(); - if item.equip.is_none() || !equip_slot::fits(item.item_id, to) { + if item.equip.is_none() || !equip_slot::fits(item.item_id, item.cash_serial.is_some(), to) { return Err(InventoryError::CannotEquip); } let worn = |position: i16| self.equipped.get(&position).map(|i| i.item_id); @@ -981,6 +987,27 @@ mod tests { ); } + #[test] + fn cash_equips_are_worn_a_hundred_below_their_slot() { + let mut inventory = Inventory::default(); + let mut cash_cap = equip(1002186); + cash_cap.cash_serial = Some(7); + inventory.insert(InventoryType::Equip, 1, cash_cap); + inventory.insert(InventoryType::Equip, 2, equip(1002008)); + assert_eq!( + inventory.equip(1, equip_slot::CAP), + Err(InventoryError::CannotEquip) + ); + assert_eq!( + inventory.equip(2, equip_slot::CAP - 100), + Err(InventoryError::CannotEquip) + ); + inventory.equip(1, equip_slot::CAP - 100).unwrap(); + inventory.equip(2, equip_slot::CAP).unwrap(); + assert_eq!(inventory.equipped_regular().count(), 1); + assert_eq!(inventory.equipped_cash().count(), 1); + } + #[test] fn overalls_and_pants_take_each_other_off() { let mut inventory = Inventory::default();