From 72088db4ff3d55b5a6cc6f42e690e1c6970f97ab Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Mon, 23 Mar 2026 22:00:06 +0100 Subject: [PATCH 1/2] fix(savedata): write playtime back to binary blob on save updateSaveDataWithStruct only wrote RP and KQF into the blob, leaving the playtime field stale. On each reconnect, GetCharacterSaveData read the old in-game counter from the blob and reset s.playtime to it, rolling back all progress accumulated during the previous session. Playtime is now persisted into the blob alongside RP, using the same S6 mode guard as the read path in updateStructWithSaveData. --- CHANGELOG.md | 4 ++++ server/channelserver/model_character.go | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bda44230..a2c370aa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- 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. + ## [9.3.1] - 2026-03-23 ### Added 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) } From abab6dc3a1921cf4435d9809ec5cc1162ec2cb0c Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Mon, 23 Mar 2026 22:20:32 +0100 Subject: [PATCH 2/2] fix(handlers): fix softlock on forge purchases and N-points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MSG_CA_EXCHANGE_ITEM and MSG_MHF_USE_UD_SHOP_COIN had Parse() returning "NOT IMPLEMENTED". The dispatch loop in handlePacketGroup treats any Parse error as a silent drop — no ACK is sent, causing the client to wait indefinitely (softlock). Reported on 9.3.0-rc1 for forge item purchases and Hunting Road N-point interactions. Fix follows the pattern from d27da5e: parse only the AckHandle, return nil from Parse, and respond with doAckBufFail so the client's error branch exits cleanly without reading response fields. --- CHANGELOG.md | 2 ++ network/mhfpacket/msg_batch_parse_test.go | 1 - network/mhfpacket/msg_ca_exchange_item.go | 7 +++++-- network/mhfpacket/msg_comprehensive_test.go | 2 ++ network/mhfpacket/msg_mhf_use_ud_shop_coin.go | 7 +++++-- network/mhfpacket/msg_parse_small_test.go | 2 +- server/channelserver/handlers_misc.go | 8 +++++++- server/channelserver/handlers_misc_test.go | 10 ++-------- server/channelserver/handlers_session.go | 8 +++++++- server/channelserver/handlers_session_test.go | 11 ++--------- server/channelserver/handlers_simple_test.go | 2 -- 11 files changed, 33 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2c370aa4..cebd7e764 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - 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 ea5225900..8e97fe1e1 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 b27524d16..5bf69d474 100644 --- a/network/mhfpacket/msg_parse_small_test.go +++ b/network/mhfpacket/msg_parse_small_test.go @@ -37,7 +37,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 58f150b99..0e64453e1 100644 --- a/server/channelserver/handlers_misc.go +++ b/server/channelserver/handlers_misc.go @@ -215,7 +215,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 4aef45aa2..97f68b653 100644 --- a/server/channelserver/handlers_simple_test.go +++ b/server/channelserver/handlers_simple_test.go @@ -279,7 +279,6 @@ func TestEmptyHandlers_NoDb(t *testing.T) { {"handleMsgSysUpdateRight", handleMsgSysUpdateRight}, {"handleMsgSysAuthQuery", handleMsgSysAuthQuery}, {"handleMsgSysAuthTerminal", handleMsgSysAuthTerminal}, - {"handleMsgCaExchangeItem", handleMsgCaExchangeItem}, {"handleMsgMhfServerCommand", handleMsgMhfServerCommand}, {"handleMsgMhfSetLoginwindow", handleMsgMhfSetLoginwindow}, {"handleMsgSysTransBinary", handleMsgSysTransBinary}, @@ -294,7 +293,6 @@ func TestEmptyHandlers_NoDb(t *testing.T) { {"handleMsgMhfKickExportForce", handleMsgMhfKickExportForce}, {"handleMsgSysSetStatus", handleMsgSysSetStatus}, {"handleMsgSysEcho", handleMsgSysEcho}, - {"handleMsgMhfUseUdShopCoin", handleMsgMhfUseUdShopCoin}, {"handleMsgMhfEnterTournamentQuest", handleMsgMhfEnterTournamentQuest}, }