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.
This commit is contained in:
Houmgaor
2026-03-19 23:59:34 +01:00
parent 08e7de2c5e
commit 5c2fde5cfd
6 changed files with 698 additions and 9 deletions

View File

@@ -0,0 +1,181 @@
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
}

View File

@@ -0,0 +1,182 @@
package channelserver
import (
"encoding/binary"
"strings"
"testing"
)
// buildRengokuData constructs a minimal but structurally valid rengoku binary
// for testing. It contains one floor and one spawn table per road mode.
//
// Layout:
//
// 0x000x13 header (magic + version + padding)
// 0x140x2B multiDef RoadMode
// 0x2C0x43 soloDef RoadMode
// 0x440x5B multiDef FloorStats (24 bytes)
// 0x5C0x63 multiDef spawnTablePtrs (1×u32 = 4 bytes)
// 0x640x67 multiDef spawnCountPtrs (1×u32 = 4 bytes)
// 0x680x87 multiDef SpawnTable (32 bytes)
// 0x880x9F soloDef FloorStats (24 bytes)
// 0xA00xA3 soloDef spawnTablePtrs (1×u32)
// 0xA40xA7 soloDef spawnCountPtrs (1×u32)
// 0xA80xC7 soloDef SpawnTable (32 bytes)
func buildRengokuData(multiMonster1, multiMonster2, soloMonster1, soloMonster2 uint32) []byte {
buf := make([]byte, 0xC8)
// Header
buf[0] = 'r'
buf[1] = 'e'
buf[2] = 'f'
buf[3] = 0x1A
buf[4] = 1 // version
le := binary.LittleEndian
// multiDef RoadMode at 0x14
le.PutUint32(buf[0x14:], 1) // floorStatsCount
le.PutUint32(buf[0x18:], 1) // spawnCountCount
le.PutUint32(buf[0x1C:], 1) // spawnTablePtrCount
le.PutUint32(buf[0x20:], 0x44) // floorStatsPtr
le.PutUint32(buf[0x24:], 0x5C) // spawnTablePtrsPtr
le.PutUint32(buf[0x28:], 0x64) // spawnCountPtrsPtr
// soloDef RoadMode at 0x2C
le.PutUint32(buf[0x2C:], 1) // floorStatsCount
le.PutUint32(buf[0x30:], 1) // spawnCountCount
le.PutUint32(buf[0x34:], 1) // spawnTablePtrCount
le.PutUint32(buf[0x38:], 0x88) // floorStatsPtr
le.PutUint32(buf[0x3C:], 0xA0) // spawnTablePtrsPtr
le.PutUint32(buf[0x40:], 0xA4) // spawnCountPtrsPtr
// multiDef FloorStats at 0x44 (24 bytes)
le.PutUint32(buf[0x44:], 1) // floorNumber
// multiDef spawnTablePtrs at 0x5C: points to SpawnTable at 0x68
le.PutUint32(buf[0x5C:], 0x68)
// multiDef SpawnTable at 0x68 (32 bytes)
le.PutUint32(buf[0x68:], multiMonster1)
le.PutUint32(buf[0x70:], multiMonster2)
// soloDef FloorStats at 0x88 (24 bytes)
le.PutUint32(buf[0x88:], 1) // floorNumber
// soloDef spawnTablePtrs at 0xA0: points to SpawnTable at 0xA8
le.PutUint32(buf[0xA0:], 0xA8)
// soloDef SpawnTable at 0xA8 (32 bytes)
le.PutUint32(buf[0xA8:], soloMonster1)
le.PutUint32(buf[0xB0:], soloMonster2)
return buf
}
func TestParseRengokuBinary_ValidMinimal(t *testing.T) {
data := buildRengokuData(101, 102, 103, 101) // monster 101 appears in both roads
info, err := parseRengokuBinary(data)
if err != nil {
t.Fatalf("parseRengokuBinary: %v", err)
}
if info.MultiFloors != 1 {
t.Errorf("MultiFloors = %d, want 1", info.MultiFloors)
}
if info.MultiSpawnTables != 1 {
t.Errorf("MultiSpawnTables = %d, want 1", info.MultiSpawnTables)
}
if info.SoloFloors != 1 {
t.Errorf("SoloFloors = %d, want 1", info.SoloFloors)
}
if info.SoloSpawnTables != 1 {
t.Errorf("SoloSpawnTables = %d, want 1", info.SoloSpawnTables)
}
// IDs present: 101, 102, 103 → 3 unique (101 shared between roads)
if info.UniqueMonsters != 3 {
t.Errorf("UniqueMonsters = %d, want 3", info.UniqueMonsters)
}
}
func TestParseRengokuBinary_ZeroMonsterIDsExcluded(t *testing.T) {
data := buildRengokuData(0, 55, 0, 0) // only monster 55 is non-zero
info, err := parseRengokuBinary(data)
if err != nil {
t.Fatalf("parseRengokuBinary: %v", err)
}
if info.UniqueMonsters != 1 {
t.Errorf("UniqueMonsters = %d, want 1 (zeros excluded)", info.UniqueMonsters)
}
}
func TestParseRengokuBinary_Errors(t *testing.T) {
validData := buildRengokuData(1, 2, 3, 4)
cases := []struct {
name string
data []byte
wantErr string
}{
{
name: "too_small",
data: make([]byte, 10),
wantErr: "too small",
},
{
name: "bad_magic",
data: func() []byte {
d := make([]byte, len(validData))
copy(d, validData)
d[0] = 0xFF
return d
}(),
wantErr: "invalid magic",
},
{
name: "wrong_version",
data: func() []byte {
d := make([]byte, len(validData))
copy(d, validData)
d[4] = 2
return d
}(),
wantErr: "unexpected version",
},
{
name: "floorStats_ptr_out_of_bounds",
data: func() []byte {
d := make([]byte, len(validData))
copy(d, validData)
// Set multiDef floorStatsPtr to beyond file end
binary.LittleEndian.PutUint32(d[0x20:], uint32(len(d)+1))
return d
}(),
wantErr: "out of bounds",
},
{
name: "spawnTable_ptr_target_out_of_bounds",
data: func() []byte {
d := make([]byte, len(validData))
copy(d, validData)
// Point the spawn table pointer to just before the end so SpawnTable
// (32 bytes) would extend beyond the file.
binary.LittleEndian.PutUint32(d[0x5C:], uint32(len(d)-4))
return d
}(),
wantErr: "out of bounds",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := parseRengokuBinary(tc.data)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Errorf("error %q does not contain %q", err.Error(), tc.wantErr)
}
})
}
}

View File

@@ -11,6 +11,7 @@ import (
"time"
"erupe-ce/common/byteframe"
"erupe-ce/common/decryption"
cfg "erupe-ce/config"
"erupe-ce/network"
"erupe-ce/network/binpacket"
@@ -449,12 +450,11 @@ func (s *Server) Season() uint8 {
return uint8(((TimeAdjusted().Unix() / secsPerDay) + sid) % 3)
}
// ecdMagic is the ECD magic as read by binary.LittleEndian.Uint32.
// On-disk bytes: 65 63 64 1A ("ecd\x1a"), LE-decoded: 0x1A646365.
const ecdMagic = uint32(0x1A646365)
// loadRengokuBinary reads and validates rengoku_data.bin from binPath.
// Returns the raw bytes on success, or nil if the file is missing or invalid.
// loadRengokuBinary reads, validates, and caches rengoku_data.bin from binPath.
// The file is served to clients as-is (ECD-encrypted); decryption and parsing
// are performed only for structural validation and startup logging.
// Returns the raw encrypted bytes on success, or nil if the file is
// missing or structurally invalid.
func loadRengokuBinary(binPath string, logger *zap.Logger) []byte {
path := filepath.Join(binPath, "rengoku_data.bin")
data, err := os.ReadFile(path)
@@ -468,12 +468,35 @@ func loadRengokuBinary(binPath string, logger *zap.Logger) []byte {
zap.Int("bytes", len(data)))
return nil
}
if magic := binary.LittleEndian.Uint32(data[:4]); magic != ecdMagic {
if magic := binary.LittleEndian.Uint32(data[:4]); magic != decryption.ECDMagic {
logger.Warn("rengoku_data.bin has invalid ECD magic, ignoring",
zap.String("expected", "0x1a646365"),
zap.String("expected", fmt.Sprintf("0x%08x", decryption.ECDMagic)),
zap.String("got", fmt.Sprintf("0x%08x", magic)))
return nil
}
// Decrypt and decompress to validate the internal structure and emit a
// human-readable summary at startup. Failures here are non-fatal: the
// encrypted blob is still served to clients unchanged.
if plain, decErr := decryption.DecodeECD(data); decErr != nil {
logger.Warn("rengoku_data.bin ECD decryption failed — serving anyway",
zap.Error(decErr))
} else {
raw := decryption.UnpackSimple(plain)
if info, parseErr := parseRengokuBinary(raw); parseErr != nil {
logger.Warn("rengoku_data.bin structural validation failed",
zap.Error(parseErr))
} else {
logger.Info("Hunting Road config",
zap.Int("multi_floors", info.MultiFloors),
zap.Int("multi_spawn_tables", info.MultiSpawnTables),
zap.Int("solo_floors", info.SoloFloors),
zap.Int("solo_spawn_tables", info.SoloSpawnTables),
zap.Int("unique_monsters", info.UniqueMonsters),
)
}
}
logger.Info("Loaded rengoku_data.bin", zap.Int("bytes", len(data)))
return data
}

View File

@@ -11,6 +11,7 @@ import (
"time"
cfg "erupe-ce/config"
"erupe-ce/common/decryption"
"erupe-ce/network/clientctx"
"erupe-ce/network/mhfpacket"
@@ -737,7 +738,7 @@ func TestLoadRengokuBinary_ValidECD(t *testing.T) {
dir := t.TempDir()
// Build a minimal valid ECD file: magic + some payload
data := make([]byte, 16)
binary.LittleEndian.PutUint32(data[:4], ecdMagic)
binary.LittleEndian.PutUint32(data[:4], decryption.ECDMagic)
if err := os.WriteFile(filepath.Join(dir, "rengoku_data.bin"), data, 0644); err != nil {
t.Fatal(err)
}