From 99e6ea26f1dc2f5c28f0dd3c96f3f15c3dfdff6f Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Mon, 6 Apr 2026 16:05:19 +0200 Subject: [PATCH] feat(handlers): parse and log user binary types 1-3 Reverse-engineered from mhfo-hd.dll via Ghidra: type 1 = character name, type 2 = player profile (208B), type 3 = equipment snapshot (384B). Adds structured zap logging and size validation warnings to handleMsgSysSetUserBinary. --- CHANGELOG.md | 3 +- server/channelserver/handlers_users.go | 129 +++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbb9c6afa..001264dcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -<<<<<<< HEAD ### Added +- Reverse-engineered user binary data types from `mhfo-hd.dll` via Ghidra: type 1 = character name (max 17B SJIS), type 2 = player profile with self-introduction (208B), type 3 = equipment/appearance snapshot (384B). Added structured parsing with size validation warnings to `handleMsgSysSetUserBinary`. + - French (`fr`) and Spanish (`es`) server language translations. Set `"Language": "fr"` or `"Language": "es"` in `config.json` to activate. - `TestLangCompleteness` uses reflection to verify that every string field in `i18n` is populated for all registered languages — catches missing translations at CI time rather than silently serving empty strings in-game. - Server-generated strings (commands, mail templates, Raviente announcements, Diva bead names, guild names) are now split into one file per language (`lang_en.go`, `lang_jp.go`, etc.). Adding a new language requires only a single self-contained file and a one-line registration in `getLangStrings` ([#185](https://github.com/Mezeporta/Erupe/issues/185)). diff --git a/server/channelserver/handlers_users.go b/server/channelserver/handlers_users.go index d2f2f7fb0..713a40487 100644 --- a/server/channelserver/handlers_users.go +++ b/server/channelserver/handlers_users.go @@ -1,10 +1,41 @@ package channelserver import ( + "encoding/binary" + "erupe-ce/common/bfutil" + "erupe-ce/common/stringsupport" "erupe-ce/network/mhfpacket" "go.uber.org/zap" ) +// User binary expected sizes and offsets (from mhfo-hd.dll RE). +// Types 4-5 are accepted by the server but never sent by the ZZ client. +const ( + userBinaryNameMaxSize = 17 // Type 1: SJIS null-terminated name + userBinaryProfileSize = 208 // Type 2: 0xD0 — player profile + userBinaryEquipSize = 384 // Type 3: 0x180 — equipment/appearance + + // Type 2 profile offsets + profileNameOff = 0x0C // 25-byte SJIS name + profileNameLen = 25 + profileIntroOff = 0x25 // 35-byte SJIS self-introduction + profileIntroLen = 35 + profileGuildIDOff = 0x48 // u32 guild ID + + // Type 3 equipment offsets + equipHROff = 0x00 // u16 HR (XOR'd with session key) + equipWeaponOff = 0x08 // 12-byte weapon entry + equipHeadOff = 0x18 // 12-byte head armor entry + equipChestOff = 0x24 // 12-byte chest armor entry + equipArmsOff = 0x30 // 12-byte arms armor entry + equipWaistOff = 0x3C // 12-byte waist armor entry + equipLegsOff = 0x48 // 12-byte legs armor entry + equipGuildIDOff = 0x64 // u32 guild ID + equipGenderOff = 0x68 // u8 gender flag + equipSharpnessOff = 0x69 // u8 sharpness level + equipEntrySize = 12 // Each equipment entry: 3x u32 +) + func handleMsgSysInsertUser(s *Session, p mhfpacket.MHFPacket) {} // stub: unimplemented func handleMsgSysDeleteUser(s *Session, p mhfpacket.MHFPacket) {} // stub: unimplemented @@ -15,6 +46,9 @@ func handleMsgSysSetUserBinary(s *Session, p mhfpacket.MHFPacket) { s.logger.Warn("Invalid BinaryType", zap.Uint8("type", pkt.BinaryType)) return } + + logUserBinaryFields(s, pkt.BinaryType, pkt.RawDataPayload) + s.server.userBinary.Set(s.charID, pkt.BinaryType, pkt.RawDataPayload) s.server.BroadcastMHF(&mhfpacket.MsgSysNotifyUserBinary{ @@ -23,6 +57,101 @@ func handleMsgSysSetUserBinary(s *Session, p mhfpacket.MHFPacket) { }, s) } +// logUserBinaryFields parses and logs the structured fields of a user binary +// payload based on its type. Logs a warning if the payload size does not match +// the expected format from the client RE. +func logUserBinaryFields(s *Session, binaryType uint8, data []byte) { + switch binaryType { + case 1: + logUserBinaryName(s, data) + case 2: + logUserBinaryProfile(s, data) + case 3: + logUserBinaryEquipment(s, data) + default: + s.logger.Info("User binary received (unknown type)", + zap.Uint8("type", binaryType), + zap.Int("size", len(data)), + zap.Uint32("charID", s.charID), + ) + } +} + +// logUserBinaryName parses type 1: character name (SJIS, null-terminated). +func logUserBinaryName(s *Session, data []byte) { + if len(data) == 0 { + s.logger.Warn("User binary type 1 (name): empty payload", + zap.Uint32("charID", s.charID), + ) + return + } + if len(data) > userBinaryNameMaxSize { + s.logger.Warn("User binary type 1 (name): payload exceeds expected max", + zap.Int("size", len(data)), + zap.Int("expected_max", userBinaryNameMaxSize), + zap.Uint32("charID", s.charID), + ) + } + name := stringsupport.SJISToUTF8Lossy(bfutil.UpToNull(data)) + s.logger.Info("User binary type 1 (name)", + zap.String("name", name), + zap.Int("size", len(data)), + zap.Uint32("charID", s.charID), + ) +} + +// logUserBinaryProfile parses type 2: player profile (208 bytes). +func logUserBinaryProfile(s *Session, data []byte) { + if len(data) != userBinaryProfileSize { + s.logger.Warn("User binary type 2 (profile): unexpected size", + zap.Int("size", len(data)), + zap.Int("expected", userBinaryProfileSize), + zap.Uint32("charID", s.charID), + ) + return + } + nameBytes := bfutil.UpToNull(data[profileNameOff : profileNameOff+profileNameLen]) + name := stringsupport.SJISToUTF8Lossy(nameBytes) + + introBytes := bfutil.UpToNull(data[profileIntroOff : profileIntroOff+profileIntroLen]) + intro := stringsupport.SJISToUTF8Lossy(introBytes) + + guildID := binary.BigEndian.Uint32(data[profileGuildIDOff : profileGuildIDOff+4]) + + s.logger.Info("User binary type 2 (profile)", + zap.String("name", name), + zap.String("self_intro", intro), + zap.Uint32("guild_id", guildID), + zap.Int("size", len(data)), + zap.Uint32("charID", s.charID), + ) +} + +// logUserBinaryEquipment parses type 3: equipment/appearance (384 bytes). +func logUserBinaryEquipment(s *Session, data []byte) { + if len(data) != userBinaryEquipSize { + s.logger.Warn("User binary type 3 (equipment): unexpected size", + zap.Int("size", len(data)), + zap.Int("expected", userBinaryEquipSize), + zap.Uint32("charID", s.charID), + ) + return + } + hr := binary.BigEndian.Uint16(data[equipHROff : equipHROff+2]) + guildID := binary.BigEndian.Uint32(data[equipGuildIDOff : equipGuildIDOff+4]) + gender := data[equipGenderOff] + sharpness := data[equipSharpnessOff] + + s.logger.Info("User binary type 3 (equipment)", + zap.Uint16("hr_xored", hr), + zap.Uint32("guild_id", guildID), + zap.Uint8("gender", gender), + zap.Uint8("sharpness", sharpness), + zap.Int("size", len(data)), + zap.Uint32("charID", s.charID), + ) +} + func handleMsgSysGetUserBinary(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgSysGetUserBinary)