fix(channelserver): correct session handler retail mismatches (#167)

Lobby search now returns only quest-bound players (QuestReserved) instead
of all reserved slots, matching retail behavior. The new field is
pre-collected under server lock before stage iteration to respect
Server.Mutex → Stage.RWMutex lock ordering.

Replaced three TODOs with RE documentation from Ghidra decompilation of
mhfo-hd.dll ZZ:
- Log key off-by-one: putRecord_log/putTerminal_log pass size 0 for the
  key field in ZZ, so the stored key is unused beyond issuance
- User search padding: ZZ per-entry parser confirms 40-byte block via
  memcpy(dst, src+8, 0x28); G2 DLL analysis inconclusive (stripped)
- Player count: field at entry offset 0x08 maps to struct param_1[0xe]
This commit is contained in:
Houmgaor
2026-02-27 17:29:32 +01:00
parent 649eebe67c
commit 21f9a79b62
5 changed files with 62 additions and 26 deletions

View File

@@ -46,13 +46,14 @@ type SessionSnapshot struct {
// StageSnapshot is an immutable copy of stage data taken under lock.
type StageSnapshot struct {
ServerIP net.IP
ServerPort uint16
StageID string
ClientCount int
Reserved int
MaxPlayers uint16
RawBinData0 []byte
RawBinData1 []byte
RawBinData3 []byte
ServerIP net.IP
ServerPort uint16
StageID string
ClientCount int
Reserved int
QuestReserved int // Players who left to enter quest stages ("Qs" prefix)
MaxPlayers uint16
RawBinData0 []byte
RawBinData1 []byte
RawBinData3 []byte
}

View File

@@ -107,6 +107,19 @@ func (r *LocalChannelRegistry) SearchStages(stagePrefix string, max int) []Stage
if len(results) >= max {
break
}
// Pre-collect which charIDs are in quest stages under server lock,
// so we can count quest-reserved players without lock ordering issues
// (Server.Mutex must be acquired before Stage.RWMutex).
c.Lock()
inQuest := make(map[uint32]bool)
for _, sess := range c.sessions {
if sess.stage != nil && len(sess.stage.id) > 4 && sess.stage.id[3:5] == "Qs" {
inQuest[sess.charID] = true
}
}
c.Unlock()
cIP := net.ParseIP(c.IP).To4()
cPort := c.Port
c.stages.Range(func(_ string, stage *Stage) bool {
@@ -127,16 +140,24 @@ func (r *LocalChannelRegistry) SearchStages(stagePrefix string, max int) []Stage
bin3Copy := make([]byte, len(bin3))
copy(bin3Copy, bin3)
questReserved := 0
for charID := range stage.reservedClientSlots {
if inQuest[charID] {
questReserved++
}
}
results = append(results, StageSnapshot{
ServerIP: cIP,
ServerPort: cPort,
StageID: stage.id,
ClientCount: len(stage.clients) + len(stage.reservedClientSlots),
Reserved: len(stage.reservedClientSlots),
MaxPlayers: stage.maxPlayers,
RawBinData0: bin0Copy,
RawBinData1: bin1Copy,
RawBinData3: bin3Copy,
ServerIP: cIP,
ServerPort: cPort,
StageID: stage.id,
ClientCount: len(stage.clients) + len(stage.reservedClientSlots),
Reserved: len(stage.reservedClientSlots),
QuestReserved: questReserved,
MaxPlayers: stage.maxPlayers,
RawBinData0: bin0Copy,
RawBinData1: bin1Copy,
RawBinData3: bin3Copy,
})
stage.RUnlock()
return true

View File

@@ -407,8 +407,13 @@ func handleMsgSysIssueLogkey(s *Session, p mhfpacket.MHFPacket) {
return
}
// TODO(Andoryuuta): In the official client, the log key index is off by one,
// cutting off the last byte in _most uses_. Find and document these accordingly.
// Client log key off-by-one (RE'd from mhfo-hd.dll ZZ):
// putIssue_logkey (0x1D) requests and stores all 16 bytes correctly.
// putRecord_log (0x1E) and putTerminal_log (0x13) do NOT embed the log key
// in their packets — they pass size 0 to the packet builder for the key field.
// The original off-by-one note (Andoryuuta) may apply to pre-ZZ clients where
// these functions did use the key. In ZZ the key is stored but never sent back,
// so the server value is effectively unused beyond issuance.
s.Lock()
s.logKey = logKey
s.Unlock()
@@ -548,7 +553,11 @@ func handleMsgMhfTransitMessage(s *Session, p mhfpacket.MHFPacket) {
resp.WriteUint8(uint8(len(sjisName) + 1))
resp.WriteUint16(uint16(len(snap.UserBinary3)))
// TODO: This case might be <=G2
// User search response padding block (RE'd from mhfo-hd.dll ZZ):
// ZZ per-entry parser (FUN_115868a0) reads 0x28 (40) bytes at offset +8
// via memcpy into the result struct. G1 and earlier use 8 bytes.
// G2 DLL analysis was inconclusive (stripped binary, no shared struct
// sizes with ZZ) — the boundary may be <=G2 rather than <=G1.
if s.server.erupeConfig.RealClientMode <= cfg.G1 {
resp.WriteBytes(make([]byte, 8))
} else {
@@ -711,8 +720,10 @@ func handleMsgMhfTransitMessage(s *Session, p mhfpacket.MHFPacket) {
resp.WriteUint16(0) // Unk, [0 1 2]
resp.WriteUint16(uint16(sr.ClientCount))
resp.WriteUint16(sr.MaxPlayers)
// TODO: Retail returned the number of clients in quests, not workshop/my series
resp.WriteUint16(uint16(sr.Reserved))
// Retail returned only clients in quest stages ("Qs" prefix),
// not workshop/my series. RE'd from FUN_11586690 in mhfo-hd.dll ZZ:
// field at entry offset 0x08-0x09 → struct offset 0x1C (param_1[0xe]).
resp.WriteUint16(uint16(sr.QuestReserved))
resp.WriteUint8(0) // Static?
resp.WriteUint8(uint8(sr.MaxPlayers))