Files
Erupe/server/channelserver/rengoku_binary.go
Houmgaor 5c2fde5cfd feat(rengoku): validate and log Hunting Road config on startup
Port ECD encryption/decryption from ReFrontier (C#) and FrontierTextHandler
(Python) into common/decryption. The cipher uses a 32-bit LCG key stream with
an 8-round Feistel-like nibble transformation and CFB chaining; all six key
sets are supported, key 4 being the default for all MHF files.

On startup, loadRengokuBinary now decrypts (ECD) and decompresses (JKR) the
binary to validate pointer bounds and entry counts, then logs a structured
summary (floor counts, spawn table counts, unique monster IDs). Failures are
non-fatal — the encrypted blob is still cached and served to clients unchanged,
preserving existing behaviour. Closes #173.
2026-03-19 23:59:34 +01:00

182 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package channelserver
import (
"encoding/binary"
"fmt"
)
// rengoku binary layout (after ECD decryption + JKR decompression):
//
// @0x00: magic bytes 'r','e','f',0x1A
// @0x04: version (u8, expected 1)
// @0x05: 15 bytes of header offsets (unused by this parser)
// @0x14: RoadMode multiDef (24 bytes)
// @0x2C: RoadMode soloDef (24 bytes)
const (
rengokuMinSize = 0x44 // header (0x14) + two RoadModes (2×24)
rengokuMultiOffset = 0x14
rengokuSoloOffset = 0x2C
floorStatsByteSize = 24
spawnTableByteSize = 32
spawnPtrEntrySize = 4 // each spawn-table pointer is a u32
)
// rengokuRoadMode holds a parsed RoadMode struct. All pointer fields are file
// offsets into the raw (decrypted + decompressed) byte slice.
type rengokuRoadMode struct {
FloorStatsCount uint32
SpawnCountCount uint32
SpawnTablePtrCount uint32
FloorStatsPtr uint32 // → FloorStats[FloorStatsCount]
SpawnTablePtrsPtr uint32 // → u32[SpawnTablePtrCount] → SpawnTable[]
SpawnCountPtrsPtr uint32 // → u32[SpawnCountCount]
}
// RengokuBinaryInfo summarises the validated rengoku_data.bin contents for
// structured logging. It is populated by parseRengokuBinary.
type RengokuBinaryInfo struct {
MultiFloors int
MultiSpawnTables int
SoloFloors int
SoloSpawnTables int
UniqueMonsters int
}
// parseRengokuBinary validates the structural integrity of a decrypted and
// decompressed rengoku_data.bin and returns a summary of its contents.
//
// It checks:
// - magic bytes and version
// - all pointer-derived ranges lie within the file
// - individual spawn-table pointers fall within the file
func parseRengokuBinary(data []byte) (*RengokuBinaryInfo, error) {
if len(data) < rengokuMinSize {
return nil, fmt.Errorf("rengoku: file too small (%d bytes, need %d)", len(data), rengokuMinSize)
}
// Magic: 'r','e','f',0x1A
if data[0] != 'r' || data[1] != 'e' || data[2] != 'f' || data[3] != 0x1A {
return nil, fmt.Errorf("rengoku: invalid magic %02x %02x %02x %02x",
data[0], data[1], data[2], data[3])
}
if data[4] != 1 {
return nil, fmt.Errorf("rengoku: unexpected version %d (want 1)", data[4])
}
multi, err := readRoadMode(data, rengokuMultiOffset)
if err != nil {
return nil, fmt.Errorf("rengoku: multiDef: %w", err)
}
solo, err := readRoadMode(data, rengokuSoloOffset)
if err != nil {
return nil, fmt.Errorf("rengoku: soloDef: %w", err)
}
if err := validateRoadMode(data, multi, "multiDef"); err != nil {
return nil, err
}
if err := validateRoadMode(data, solo, "soloDef"); err != nil {
return nil, err
}
uniqueMonsters := countUniqueMonsters(data, multi)
for id := range countUniqueMonsters(data, solo) {
uniqueMonsters[id] = struct{}{}
}
return &RengokuBinaryInfo{
MultiFloors: int(multi.FloorStatsCount),
MultiSpawnTables: int(multi.SpawnTablePtrCount),
SoloFloors: int(solo.FloorStatsCount),
SoloSpawnTables: int(solo.SpawnTablePtrCount),
UniqueMonsters: len(uniqueMonsters),
}, nil
}
// readRoadMode reads a 24-byte RoadMode struct from data at offset.
func readRoadMode(data []byte, offset int) (rengokuRoadMode, error) {
end := offset + 24
if len(data) < end {
return rengokuRoadMode{}, fmt.Errorf("RoadMode at 0x%X extends beyond file", offset)
}
d := data[offset:]
return rengokuRoadMode{
FloorStatsCount: binary.LittleEndian.Uint32(d[0:]),
SpawnCountCount: binary.LittleEndian.Uint32(d[4:]),
SpawnTablePtrCount: binary.LittleEndian.Uint32(d[8:]),
FloorStatsPtr: binary.LittleEndian.Uint32(d[12:]),
SpawnTablePtrsPtr: binary.LittleEndian.Uint32(d[16:]),
SpawnCountPtrsPtr: binary.LittleEndian.Uint32(d[20:]),
}, nil
}
// ptrInBounds returns true if the region [ptr, ptr+size) fits within data.
// It guards against overflow when ptr+size wraps uint32.
func ptrInBounds(data []byte, ptr, size uint32) bool {
end := ptr + size
if end < ptr { // overflow
return false
}
return int(end) <= len(data)
}
// validateRoadMode checks that all pointer-derived byte ranges for a RoadMode
// lie within data.
func validateRoadMode(data []byte, rm rengokuRoadMode, label string) error {
fileLen := uint32(len(data))
// Floor-stats array bounds.
if !ptrInBounds(data, rm.FloorStatsPtr, rm.FloorStatsCount*floorStatsByteSize) {
return fmt.Errorf("rengoku: %s: floorStats array [0x%X, +%d×%d] out of bounds (file %d B)",
label, rm.FloorStatsPtr, rm.FloorStatsCount, floorStatsByteSize, fileLen)
}
// Spawn-table pointer array bounds.
if !ptrInBounds(data, rm.SpawnTablePtrsPtr, rm.SpawnTablePtrCount*spawnPtrEntrySize) {
return fmt.Errorf("rengoku: %s: spawnTablePtrs array [0x%X, +%d×4] out of bounds (file %d B)",
label, rm.SpawnTablePtrsPtr, rm.SpawnTablePtrCount, fileLen)
}
// Spawn-count pointer array bounds.
if !ptrInBounds(data, rm.SpawnCountPtrsPtr, rm.SpawnCountCount*spawnPtrEntrySize) {
return fmt.Errorf("rengoku: %s: spawnCountPtrs array [0x%X, +%d×4] out of bounds (file %d B)",
label, rm.SpawnCountPtrsPtr, rm.SpawnCountCount, fileLen)
}
// Individual spawn-table pointer targets.
ptrBase := rm.SpawnTablePtrsPtr
for i := uint32(0); i < rm.SpawnTablePtrCount; i++ {
tablePtr := binary.LittleEndian.Uint32(data[ptrBase+i*4:])
if !ptrInBounds(data, tablePtr, spawnTableByteSize) {
return fmt.Errorf("rengoku: %s: spawnTable[%d] at 0x%X is out of bounds (file %d B)",
label, i, tablePtr, fileLen)
}
}
return nil
}
// countUniqueMonsters iterates all SpawnTables for a RoadMode and returns a
// set of unique non-zero monster IDs (from both monsterID1 and monsterID2).
func countUniqueMonsters(data []byte, rm rengokuRoadMode) map[uint32]struct{} {
ids := make(map[uint32]struct{})
ptrBase := rm.SpawnTablePtrsPtr
for i := uint32(0); i < rm.SpawnTablePtrCount; i++ {
tablePtr := binary.LittleEndian.Uint32(data[ptrBase+i*4:])
if !ptrInBounds(data, tablePtr, spawnTableByteSize) {
continue
}
t := data[tablePtr:]
id1 := binary.LittleEndian.Uint32(t[0:])
id2 := binary.LittleEndian.Uint32(t[8:])
if id1 != 0 {
ids[id1] = struct{}{}
}
if id2 != 0 {
ids[id2] = struct{}{}
}
}
return ids
}