feat(achievement): add rank-up notifications (#165)

RE'd putDisplayed_achievement from ZZ client DLL via Ghidra: the packet
sends opcode + 1 zero byte with no achievement ID, acting as a blanket
"I saw everything" signal.

Server changes:
- Track per-character last-displayed levels in new displayed_levels
  column (migration 0008)
- GetAchievement compares current vs displayed levels per entry
- DisplayedAchievement snapshots current levels to clear notifications
- Repo, service, mock, and 3 new service tests

Protbot changes:
- New --action achievement: fetches achievements, shows rank-up markers,
  sends DISPLAYED_ACHIEVEMENT, re-fetches to verify notifications clear
- Packet builders for GET/ADD/DISPLAYED_ACHIEVEMENT
This commit is contained in:
Houmgaor
2026-03-18 11:35:31 +01:00
parent 476882e1fb
commit 61d85e749f
13 changed files with 383 additions and 13 deletions

View File

@@ -15,6 +15,7 @@ import (
"os"
"os/signal"
"syscall"
"time"
"erupe-ce/cmd/protbot/scenario"
)
@@ -23,7 +24,7 @@ func main() {
signAddr := flag.String("sign-addr", "127.0.0.1:53312", "Sign server address (host:port)")
user := flag.String("user", "", "Username")
pass := flag.String("pass", "", "Password")
action := flag.String("action", "login", "Action to perform: login, lobby, session, chat, quests")
action := flag.String("action", "login", "Action to perform: login, lobby, session, chat, quests, achievement")
message := flag.String("message", "", "Chat message to send (used with --action chat)")
flag.Parse()
@@ -139,8 +140,78 @@ func main() {
fmt.Printf("[quests] Received %d bytes of quest data\n", len(data))
_ = scenario.Logout(result.Channel)
case "achievement":
result, err := scenario.Login(*signAddr, *user, *pass)
if err != nil {
fmt.Fprintf(os.Stderr, "login failed: %v\n", err)
os.Exit(1)
}
charID := result.Sign.CharIDs[0]
if _, err := scenario.SetupSession(result.Channel, charID); err != nil {
fmt.Fprintf(os.Stderr, "session setup failed: %v\n", err)
_ = result.Channel.Close()
os.Exit(1)
}
if err := scenario.EnterLobby(result.Channel); err != nil {
fmt.Fprintf(os.Stderr, "enter lobby failed: %v\n", err)
_ = result.Channel.Close()
os.Exit(1)
}
// Step 1: Get current achievements.
achs, err := scenario.GetAchievements(result.Channel, charID)
if err != nil {
fmt.Fprintf(os.Stderr, "get achievements failed: %v\n", err)
_ = scenario.Logout(result.Channel)
os.Exit(1)
}
fmt.Printf("[achievement] Total points: %d\n", achs.Points)
hasNotify := false
for _, e := range achs.Entries {
marker := ""
if e.Notify {
marker = " ** RANK UP **"
hasNotify = true
}
if e.Level > 0 || e.Notify {
fmt.Printf(" [%2d] Level %d Progress %d/%d Trophy 0x%02X%s\n",
e.ID, e.Level, e.Progress, e.Required, e.Trophy, marker)
}
}
// Step 2: Mark as displayed if there were notifications.
if hasNotify {
fmt.Println("[achievement] Sending DISPLAYED_ACHIEVEMENT to acknowledge rank-ups...")
if err := scenario.DisplayedAchievement(result.Channel); err != nil {
fmt.Fprintf(os.Stderr, "displayed achievement failed: %v\n", err)
}
// Brief pause for fire-and-forget packet to be processed.
<-time.After(500 * time.Millisecond)
// Step 3: Re-fetch to verify notifications are cleared.
achs2, err := scenario.GetAchievements(result.Channel, charID)
if err != nil {
fmt.Fprintf(os.Stderr, "re-fetch achievements failed: %v\n", err)
} else {
cleared := true
for _, e := range achs2.Entries {
if e.Notify {
fmt.Printf(" [%2d] STILL notifying (level %d) — not cleared!\n", e.ID, e.Level)
cleared = false
}
}
if cleared {
fmt.Println("[achievement] All rank-up notifications cleared successfully.")
}
}
} else {
fmt.Println("[achievement] No pending rank-up notifications.")
}
_ = scenario.Logout(result.Channel)
default:
fmt.Fprintf(os.Stderr, "unknown action: %s (supported: login, lobby, session, chat, quests)\n", *action)
fmt.Fprintf(os.Stderr, "unknown action: %s (supported: login, lobby, session, chat, quests, achievement)\n", *action)
os.Exit(1)
}
}

View File

@@ -19,5 +19,8 @@ const (
MSG_SYS_RIGHTS_RELOAD uint16 = 0x005D
MSG_MHF_LOADDATA uint16 = 0x0061
MSG_MHF_ENUMERATE_QUEST uint16 = 0x009F
MSG_MHF_GET_WEEKLY_SCHED uint16 = 0x00E1
MSG_MHF_GET_ACHIEVEMENT uint16 = 0x00D4
MSG_MHF_ADD_ACHIEVEMENT uint16 = 0x00D6
MSG_MHF_DISPLAYED_ACHIEVEMENT uint16 = 0x00D8
MSG_MHF_GET_WEEKLY_SCHED uint16 = 0x00E1
)

View File

@@ -215,6 +215,56 @@ func BuildEnumerateQuestPacket(ackHandle uint32, world uint8, counter, offset ui
return bf.Data()
}
// BuildGetAchievementPacket builds a MSG_MHF_GET_ACHIEVEMENT packet.
// Layout mirrors Erupe's MsgMhfGetAchievement.Parse:
//
// uint16 opcode
// uint32 ackHandle
// uint32 charID
// uint32 zeroed
// 0x00 0x10 terminator
func BuildGetAchievementPacket(ackHandle, charID uint32) []byte {
bf := byteframe.NewByteFrame()
bf.WriteUint16(MSG_MHF_GET_ACHIEVEMENT)
bf.WriteUint32(ackHandle)
bf.WriteUint32(charID)
bf.WriteUint32(0) // Zeroed
bf.WriteBytes([]byte{0x00, 0x10})
return bf.Data()
}
// BuildAddAchievementPacket builds a MSG_MHF_ADD_ACHIEVEMENT packet (fire-and-forget, no ACK).
// Layout mirrors Erupe's MsgMhfAddAchievement.Parse:
//
// uint16 opcode
// uint8 achievementID
// uint16 unk1
// uint16 unk2
// 0x00 0x10 terminator
func BuildAddAchievementPacket(achievementID uint8) []byte {
bf := byteframe.NewByteFrame()
bf.WriteUint16(MSG_MHF_ADD_ACHIEVEMENT)
bf.WriteUint8(achievementID)
bf.WriteUint16(0) // Unk1
bf.WriteUint16(0) // Unk2
bf.WriteBytes([]byte{0x00, 0x10})
return bf.Data()
}
// BuildDisplayedAchievementPacket builds a MSG_MHF_DISPLAYED_ACHIEVEMENT packet (fire-and-forget).
// Layout mirrors Erupe's MsgMhfDisplayedAchievement.Parse:
//
// uint16 opcode
// uint8 zeroed
// 0x00 0x10 terminator
func BuildDisplayedAchievementPacket() []byte {
bf := byteframe.NewByteFrame()
bf.WriteUint16(MSG_MHF_DISPLAYED_ACHIEVEMENT)
bf.WriteUint8(0) // Zeroed
bf.WriteBytes([]byte{0x00, 0x10})
return bf.Data()
}
// BuildGetWeeklySchedulePacket builds a MSG_MHF_GET_WEEKLY_SCHEDULE packet.
//
// uint16 opcode

View File

@@ -0,0 +1,93 @@
package scenario
import (
"fmt"
"time"
"erupe-ce/cmd/protbot/protocol"
"erupe-ce/common/byteframe"
)
// AchievementEntry holds parsed achievement data from the server response.
type AchievementEntry struct {
ID uint8
Level uint8
Next uint16
Required uint32
Notify bool
Trophy uint8
Progress uint32
}
// AchievementResult holds the full parsed GET_ACHIEVEMENT response.
type AchievementResult struct {
Points uint32
Entries []AchievementEntry
}
// GetAchievements sends MSG_MHF_GET_ACHIEVEMENT and returns the parsed response.
func GetAchievements(ch *protocol.ChannelConn, charID uint32) (*AchievementResult, error) {
ack := ch.NextAckHandle()
pkt := protocol.BuildGetAchievementPacket(ack, charID)
fmt.Printf("[achievement] Sending GET_ACHIEVEMENT (charID=%d, ackHandle=%d)...\n", charID, ack)
if err := ch.SendPacket(pkt); err != nil {
return nil, fmt.Errorf("get achievement send: %w", err)
}
resp, err := ch.WaitForAck(ack, 10*time.Second)
if err != nil {
return nil, fmt.Errorf("get achievement ack: %w", err)
}
if resp.ErrorCode != 0 {
return nil, fmt.Errorf("get achievement failed: error code %d", resp.ErrorCode)
}
fmt.Printf("[achievement] ACK received (%d bytes)\n", len(resp.Data))
return parseAchievementResponse(resp.Data)
}
// IncrementAchievement sends MSG_MHF_ADD_ACHIEVEMENT (fire-and-forget, no ACK).
func IncrementAchievement(ch *protocol.ChannelConn, achievementID uint8) error {
pkt := protocol.BuildAddAchievementPacket(achievementID)
fmt.Printf("[achievement] Sending ADD_ACHIEVEMENT (id=%d)...\n", achievementID)
return ch.SendPacket(pkt)
}
// DisplayedAchievement sends MSG_MHF_DISPLAYED_ACHIEVEMENT to tell the server
// the client has seen all rank-up notifications (fire-and-forget, no ACK).
func DisplayedAchievement(ch *protocol.ChannelConn) error {
pkt := protocol.BuildDisplayedAchievementPacket()
fmt.Printf("[achievement] Sending DISPLAYED_ACHIEVEMENT...\n")
return ch.SendPacket(pkt)
}
func parseAchievementResponse(data []byte) (*AchievementResult, error) {
if len(data) < 20 {
return nil, fmt.Errorf("achievement response too short: %d bytes", len(data))
}
bf := byteframe.NewByteFrameFromBytes(data)
result := &AchievementResult{}
// Header: 4x uint32 points (all same value), 3 bytes unk, 1 byte count
result.Points = bf.ReadUint32()
bf.ReadUint32() // Points repeated
bf.ReadUint32() // Points repeated
bf.ReadUint32() // Points repeated
bf.ReadBytes(3) // Unk (0x02, 0x00, 0x00)
count := bf.ReadUint8()
for i := uint8(0); i < count; i++ {
entry := AchievementEntry{}
entry.ID = bf.ReadUint8()
entry.Level = bf.ReadUint8()
entry.Next = bf.ReadUint16()
entry.Required = bf.ReadUint32()
entry.Notify = bf.ReadBool()
entry.Trophy = bf.ReadUint8()
bf.ReadUint16() // Unk
entry.Progress = bf.ReadUint32()
result.Entries = append(result.Entries, entry)
}
return result, nil
}