diff --git a/CHANGELOG.md b/CHANGELOG.md index 18788be11..fbb9c6afa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +<<<<<<< HEAD ### Added - French (`fr`) and Spanish (`es`) server language translations. Set `"Language": "fr"` or `"Language": "es"` in `config.json` to activate. @@ -30,6 +31,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fixed backup recovery panic: `recoverFromBackups` now rejects decompressed backup data smaller than the minimum save layout size, preventing a slice-bounds panic when nullcomp passes through garbage bytes as "already decompressed" data ([#182](https://github.com/Mezeporta/Erupe/pull/182)). +- Fixed playtime regression across sessions: `updateSaveDataWithStruct` now writes the accumulated playtime back into the binary save blob, preventing each reconnect from loading a stale in-game counter and rolling back progress. +- Fixed player softlock when buying items at the forge: `MSG_CA_EXCHANGE_ITEM` `Parse()` was returning `NOT IMPLEMENTED`, causing the dispatch loop to drop the packet without sending an ACK. Now parses the `AckHandle` and responds with `doAckBufFail` so the client's error branch exits cleanly. +- Fixed player softlock on N-points (Hunting Road) interactions: same root cause for `MSG_MHF_USE_UD_SHOP_COIN` — `Parse()` now reads the `AckHandle` and responds with `doAckBufFail`. ## [9.3.1] - 2026-03-23 diff --git a/network/mhfpacket/msg_batch_parse_test.go b/network/mhfpacket/msg_batch_parse_test.go index 9dc23038e..78b72c63f 100644 --- a/network/mhfpacket/msg_batch_parse_test.go +++ b/network/mhfpacket/msg_batch_parse_test.go @@ -1594,7 +1594,6 @@ func TestBatchParseNotImplemented(t *testing.T) { &MsgSysDispObject{}, &MsgSysHideObject{}, &MsgMhfServerCommand{}, &MsgMhfSetLoginwindow{}, &MsgMhfShutClient{}, &MsgMhfUpdateGuildcard{}, - &MsgCaExchangeItem{}, } for _, pkt := range packets { diff --git a/network/mhfpacket/msg_ca_exchange_item.go b/network/mhfpacket/msg_ca_exchange_item.go index feb201521..615d563ff 100644 --- a/network/mhfpacket/msg_ca_exchange_item.go +++ b/network/mhfpacket/msg_ca_exchange_item.go @@ -9,7 +9,9 @@ import ( ) // MsgCaExchangeItem represents the MSG_CA_EXCHANGE_ITEM -type MsgCaExchangeItem struct{} +type MsgCaExchangeItem struct { + AckHandle uint32 // TODO: complete reverse-engineering of request fields +} // Opcode returns the ID associated with this packet type. func (m *MsgCaExchangeItem) Opcode() network.PacketID { @@ -18,7 +20,8 @@ func (m *MsgCaExchangeItem) Opcode() network.PacketID { // Parse parses the packet from binary func (m *MsgCaExchangeItem) Parse(bf *byteframe.ByteFrame, ctx *clientctx.ClientContext) error { - return errors.New("NOT IMPLEMENTED") + m.AckHandle = bf.ReadUint32() + return nil } // Build builds a binary packet from the current data. diff --git a/network/mhfpacket/msg_comprehensive_test.go b/network/mhfpacket/msg_comprehensive_test.go index db9f51f75..ce96dceff 100644 --- a/network/mhfpacket/msg_comprehensive_test.go +++ b/network/mhfpacket/msg_comprehensive_test.go @@ -497,6 +497,8 @@ func TestAckHandlePacketsParse(t *testing.T) { {"MsgMhfGetKijuInfo", network.MSG_MHF_GET_KIJU_INFO}, {"MsgMhfGetExtraInfo", network.MSG_MHF_GET_EXTRA_INFO}, {"MsgMhfGetCogInfo", network.MSG_MHF_GET_COG_INFO}, + {"MsgCaExchangeItem", network.MSG_CA_EXCHANGE_ITEM}, + {"MsgMhfUseUdShopCoin", network.MSG_MHF_USE_UD_SHOP_COIN}, } ctx := &clientctx.ClientContext{RealClientMode: cfg.ZZ} diff --git a/network/mhfpacket/msg_mhf_use_ud_shop_coin.go b/network/mhfpacket/msg_mhf_use_ud_shop_coin.go index c3ab78bf1..081b10e1a 100644 --- a/network/mhfpacket/msg_mhf_use_ud_shop_coin.go +++ b/network/mhfpacket/msg_mhf_use_ud_shop_coin.go @@ -9,7 +9,9 @@ import ( ) // MsgMhfUseUdShopCoin represents the MSG_MHF_USE_UD_SHOP_COIN -type MsgMhfUseUdShopCoin struct{} +type MsgMhfUseUdShopCoin struct { + AckHandle uint32 // TODO: complete reverse-engineering of request fields +} // Opcode returns the ID associated with this packet type. func (m *MsgMhfUseUdShopCoin) Opcode() network.PacketID { @@ -18,7 +20,8 @@ func (m *MsgMhfUseUdShopCoin) Opcode() network.PacketID { // Parse parses the packet from binary func (m *MsgMhfUseUdShopCoin) Parse(bf *byteframe.ByteFrame, ctx *clientctx.ClientContext) error { - return errors.New("NOT IMPLEMENTED") + m.AckHandle = bf.ReadUint32() + return nil } // Build builds a binary packet from the current data. diff --git a/network/mhfpacket/msg_parse_small_test.go b/network/mhfpacket/msg_parse_small_test.go index 3449a16c5..1db2e9bc4 100644 --- a/network/mhfpacket/msg_parse_small_test.go +++ b/network/mhfpacket/msg_parse_small_test.go @@ -32,7 +32,7 @@ func TestParseSmallNotImplemented(t *testing.T) { {"MsgMhfSetUdTacticsFollower", &MsgMhfSetUdTacticsFollower{}}, {"MsgMhfStampcardPrize", &MsgMhfStampcardPrize{}}, {"MsgMhfUpdateForceGuildRank", &MsgMhfUpdateForceGuildRank{}}, - {"MsgMhfUseUdShopCoin", &MsgMhfUseUdShopCoin{}}, + // SYS packets - NOT IMPLEMENTED {"MsgSysAuthData", &MsgSysAuthData{}}, diff --git a/server/channelserver/handlers_misc.go b/server/channelserver/handlers_misc.go index 2091c46a4..525a13e84 100644 --- a/server/channelserver/handlers_misc.go +++ b/server/channelserver/handlers_misc.go @@ -243,7 +243,13 @@ func handleMsgMhfGetUdShopCoin(s *Session, p mhfpacket.MHFPacket) { doAckSimpleSucceed(s, pkt.AckHandle, bf.Data()) } -func handleMsgMhfUseUdShopCoin(s *Session, p mhfpacket.MHFPacket) {} // stub: unimplemented +func handleMsgMhfUseUdShopCoin(s *Session, p mhfpacket.MHFPacket) { + pkt := p.(*mhfpacket.MsgMhfUseUdShopCoin) + // TODO: full response format is not yet reverse-engineered. + // doAckBufFail sends a well-formed buf-type ACK with error code 1. + // The client's fail branch exits cleanly without reading response fields. + doAckBufFail(s, pkt.AckHandle, nil) +} func handleMsgMhfGetEnhancedMinidata(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfGetEnhancedMinidata) diff --git a/server/channelserver/handlers_misc_test.go b/server/channelserver/handlers_misc_test.go index 4a4631b18..576079b50 100644 --- a/server/channelserver/handlers_misc_test.go +++ b/server/channelserver/handlers_misc_test.go @@ -526,13 +526,8 @@ func TestHandleMsgMhfUseUdShopCoin(t *testing.T) { server := createMockServer() session := createMockSession(1, server) - defer func() { - if r := recover(); r != nil { - t.Errorf("handleMsgMhfUseUdShopCoin panicked: %v", r) - } - }() - - handleMsgMhfUseUdShopCoin(session, nil) + pkt := &mhfpacket.MsgMhfUseUdShopCoin{AckHandle: 1} + handleMsgMhfUseUdShopCoin(session, pkt) } func TestHandleMsgMhfGetLobbyCrowd(t *testing.T) { @@ -1028,7 +1023,6 @@ func TestEmptyHandlers_MiscFiles_Misc(t *testing.T) { fn func() }{ - {"handleMsgMhfUseUdShopCoin", func() { handleMsgMhfUseUdShopCoin(session, nil) }}, {"handleMsgMhfGetDailyMissionMaster", func() { handleMsgMhfGetDailyMissionMaster(session, nil) }}, {"handleMsgMhfGetDailyMissionPersonal", func() { handleMsgMhfGetDailyMissionPersonal(session, nil) }}, {"handleMsgMhfSetDailyMissionPersonal", func() { handleMsgMhfSetDailyMissionPersonal(session, nil) }}, diff --git a/server/channelserver/handlers_session.go b/server/channelserver/handlers_session.go index adc312776..71713b6c2 100644 --- a/server/channelserver/handlers_session.go +++ b/server/channelserver/handlers_session.go @@ -752,7 +752,13 @@ func handleMsgMhfTransitMessage(s *Session, p mhfpacket.MHFPacket) { doAckBufSucceed(s, pkt.AckHandle, resp.Data()) } -func handleMsgCaExchangeItem(s *Session, p mhfpacket.MHFPacket) {} // stub: unimplemented +func handleMsgCaExchangeItem(s *Session, p mhfpacket.MHFPacket) { + pkt := p.(*mhfpacket.MsgCaExchangeItem) + // TODO: full response format is not yet reverse-engineered. + // doAckBufFail sends a well-formed buf-type ACK with error code 1. + // The client's fail branch exits cleanly without reading response fields. + doAckBufFail(s, pkt.AckHandle, nil) +} func handleMsgMhfServerCommand(s *Session, p mhfpacket.MHFPacket) {} // stub: unimplemented diff --git a/server/channelserver/handlers_session_test.go b/server/channelserver/handlers_session_test.go index ebe5cf743..ad95635e1 100644 --- a/server/channelserver/handlers_session_test.go +++ b/server/channelserver/handlers_session_test.go @@ -559,13 +559,8 @@ func TestHandleMsgCaExchangeItem(t *testing.T) { server := createMockServer() session := createMockSession(1, server) - defer func() { - if r := recover(); r != nil { - t.Errorf("handleMsgCaExchangeItem panicked: %v", r) - } - }() - - handleMsgCaExchangeItem(session, nil) + pkt := &mhfpacket.MsgCaExchangeItem{AckHandle: 1} + handleMsgCaExchangeItem(session, pkt) } func TestHandleMsgMhfServerCommand(t *testing.T) { @@ -1068,7 +1063,6 @@ func TestEmptyHandlers_HandlersGo(t *testing.T) { {"handleMsgSysUpdateRight", func() { handleMsgSysUpdateRight(session, nil) }}, {"handleMsgSysAuthQuery", func() { handleMsgSysAuthQuery(session, nil) }}, {"handleMsgSysAuthTerminal", func() { handleMsgSysAuthTerminal(session, nil) }}, - {"handleMsgCaExchangeItem", func() { handleMsgCaExchangeItem(session, nil) }}, {"handleMsgMhfServerCommand", func() { handleMsgMhfServerCommand(session, nil) }}, {"handleMsgMhfSetLoginwindow", func() { handleMsgMhfSetLoginwindow(session, nil) }}, {"handleMsgSysTransBinary", func() { handleMsgSysTransBinary(session, nil) }}, @@ -1106,7 +1100,6 @@ func TestEmptyHandlers_Concurrent(t *testing.T) { handleMsgSysUpdateRight, handleMsgSysAuthQuery, handleMsgSysAuthTerminal, - handleMsgCaExchangeItem, handleMsgMhfServerCommand, handleMsgMhfSetLoginwindow, handleMsgSysTransBinary, diff --git a/server/channelserver/handlers_simple_test.go b/server/channelserver/handlers_simple_test.go index 7dc55900c..44ebb1464 100644 --- a/server/channelserver/handlers_simple_test.go +++ b/server/channelserver/handlers_simple_test.go @@ -281,7 +281,6 @@ func TestEmptyHandlers_NoDb(t *testing.T) { {"handleMsgSysUpdateRight", handleMsgSysUpdateRight}, {"handleMsgSysAuthQuery", handleMsgSysAuthQuery}, {"handleMsgSysAuthTerminal", handleMsgSysAuthTerminal}, - {"handleMsgCaExchangeItem", handleMsgCaExchangeItem}, {"handleMsgMhfServerCommand", handleMsgMhfServerCommand}, {"handleMsgMhfSetLoginwindow", handleMsgMhfSetLoginwindow}, {"handleMsgSysTransBinary", handleMsgSysTransBinary}, @@ -296,7 +295,6 @@ func TestEmptyHandlers_NoDb(t *testing.T) { {"handleMsgMhfKickExportForce", handleMsgMhfKickExportForce}, {"handleMsgSysSetStatus", handleMsgSysSetStatus}, {"handleMsgSysEcho", handleMsgSysEcho}, - {"handleMsgMhfUseUdShopCoin", handleMsgMhfUseUdShopCoin}, } for _, tt := range tests { diff --git a/server/channelserver/model_character.go b/server/channelserver/model_character.go index ca7c7e91c..488c6fbf5 100644 --- a/server/channelserver/model_character.go +++ b/server/channelserver/model_character.go @@ -148,6 +148,11 @@ func (save *CharacterSaveData) updateSaveDataWithStruct() { if save.Mode >= cfg.F4 { copy(save.decompSave[save.Pointers[pRP]:save.Pointers[pRP]+saveFieldRP], rpBytes) } + if save.Mode >= cfg.S6 { + playtimeBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(playtimeBytes, save.Playtime) + copy(save.decompSave[save.Pointers[pPlaytime]:save.Pointers[pPlaytime]+saveFieldPlaytime], playtimeBytes) + } if save.Mode >= cfg.G10 { copy(save.decompSave[save.Pointers[pKQF]:save.Pointers[pKQF]+saveFieldKQF], save.KQF) }