From 5c2fde5cfda234001a2a6035eaabd23adcf9fb2c Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Thu, 19 Mar 2026 23:59:34 +0100 Subject: [PATCH] feat(rengoku): validate and log Hunting Road config on startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- common/decryption/ecd.go | 162 ++++++++++++++++ common/decryption/ecd_test.go | 140 ++++++++++++++ server/channelserver/rengoku_binary.go | 181 +++++++++++++++++ server/channelserver/rengoku_binary_test.go | 182 ++++++++++++++++++ server/channelserver/sys_channel_server.go | 39 +++- .../channelserver/sys_channel_server_test.go | 3 +- 6 files changed, 698 insertions(+), 9 deletions(-) create mode 100644 common/decryption/ecd.go create mode 100644 common/decryption/ecd_test.go create mode 100644 server/channelserver/rengoku_binary.go create mode 100644 server/channelserver/rengoku_binary_test.go diff --git a/common/decryption/ecd.go b/common/decryption/ecd.go new file mode 100644 index 000000000..6acf8b087 --- /dev/null +++ b/common/decryption/ecd.go @@ -0,0 +1,162 @@ +package decryption + +/* + ECD encryption/decryption ported from: + - ReFrontier (C#): https://github.com/Chakratos/ReFrontier (LibReFrontier/Crypto.cs) + - FrontierTextHandler (Python): src/crypto.py + + ECD is a stream cipher used to protect MHF game data files. All known + MHF files use key index 4 (DefaultECDKey). The cipher uses a 32-bit LCG + for key-stream generation with a Feistel-like nibble transformation and + ciphertext-feedback (CFB) chaining. +*/ + +import ( + "encoding/binary" + "errors" + "fmt" + "hash/crc32" +) + +// ECDMagic is the ECD container magic ("ecd\x1a"), stored little-endian on disk. +// On-disk bytes: 65 63 64 1A; decoded as LE uint32: 0x1A646365. +const ECDMagic = uint32(0x1A646365) + +// DefaultECDKey is the LCG key index used by all known MHF game files. +const DefaultECDKey = 4 + +const ecdHeaderSize = 16 + +// rndBufECD holds the 6 LCG key-parameter sets. Each entry is an 8-byte pair +// of (multiplier, increment) stored big-endian, indexed by the key field in +// the ECD header. +var rndBufECD = [...]byte{ + 0x4A, 0x4B, 0x52, 0x2E, 0x00, 0x00, 0x00, 0x01, // key 0 + 0x00, 0x01, 0x0D, 0xCD, 0x00, 0x00, 0x00, 0x01, // key 1 + 0x00, 0x01, 0x0D, 0xCD, 0x00, 0x00, 0x00, 0x01, // key 2 + 0x00, 0x01, 0x0D, 0xCD, 0x00, 0x00, 0x00, 0x01, // key 3 + 0x00, 0x19, 0x66, 0x0D, 0x00, 0x00, 0x00, 0x03, // key 4 (default; all MHF files) + 0x7D, 0x2B, 0x89, 0xDD, 0x00, 0x00, 0x00, 0x01, // key 5 +} + +const numECDKeys = len(rndBufECD) / 8 + +// getRndECD advances the LCG by one step using the selected key's parameters +// and returns the new 32-bit state. +func getRndECD(key int, rnd uint32) uint32 { + offset := key * 8 + multiplier := binary.BigEndian.Uint32(rndBufECD[offset:]) + increment := binary.BigEndian.Uint32(rndBufECD[offset+4:]) + return rnd*multiplier + increment +} + +// DecodeECD decrypts an ECD-encrypted buffer and returns the plaintext payload. +// The 16-byte ECD header is consumed; only the decrypted payload is returned. +// +// The cipher uses the CRC32 stored in the header to seed the LCG key stream. +// No post-decryption CRC check is performed (matching reference implementations). +func DecodeECD(data []byte) ([]byte, error) { + if len(data) < ecdHeaderSize { + return nil, errors.New("ecd: buffer too small for header") + } + if binary.LittleEndian.Uint32(data[:4]) != ECDMagic { + return nil, errors.New("ecd: invalid magic") + } + + key := int(binary.LittleEndian.Uint16(data[4:6])) + if key >= numECDKeys { + return nil, fmt.Errorf("ecd: invalid key index %d", key) + } + + payloadSize := int(binary.LittleEndian.Uint32(data[8:12])) + if len(data) < ecdHeaderSize+payloadSize { + return nil, fmt.Errorf("ecd: declared payload size %d exceeds buffer (%d bytes available)", + payloadSize, len(data)-ecdHeaderSize) + } + + // Seed LCG: rotate the stored CRC32 by 16 bits and set LSB to 1. + storedCRC := binary.LittleEndian.Uint32(data[12:16]) + rnd := (storedCRC<<16 | storedCRC>>16) | 1 + + // Initial LCG step establishes the cipher-feedback byte r8. + rnd = getRndECD(key, rnd) + r8 := byte(rnd) + + out := make([]byte, payloadSize) + for i := 0; i < payloadSize; i++ { + rnd = getRndECD(key, rnd) + xorpad := rnd + + // Nibble-feedback decryption: XOR with previous decrypted byte, then + // apply 8 rounds of Feistel-like nibble mixing using the key stream. + r11 := uint32(data[ecdHeaderSize+i]) ^ uint32(r8) + r12 := (r11 >> 4) & 0xFF + + for j := 0; j < 8; j++ { + r10 := xorpad ^ r11 + r11 = r12 + r12 = (r12 ^ r10) & 0xFF + xorpad >>= 4 + } + + r8 = byte((r12 & 0xF) | ((r11 & 0xF) << 4)) + out[i] = r8 + } + + return out, nil +} + +// EncodeECD encrypts plaintext using the ECD cipher and returns the complete +// ECD container (16-byte header + encrypted payload). Use DefaultECDKey (4) +// for all MHF-compatible output. +func EncodeECD(data []byte, key int) ([]byte, error) { + if key < 0 || key >= numECDKeys { + return nil, fmt.Errorf("ecd: invalid key index %d", key) + } + + payloadSize := len(data) + checksum := crc32.ChecksumIEEE(data) + + out := make([]byte, ecdHeaderSize+payloadSize) + binary.LittleEndian.PutUint32(out[0:], ECDMagic) + binary.LittleEndian.PutUint16(out[4:], uint16(key)) + // out[6:8] = 0 (reserved padding) + binary.LittleEndian.PutUint32(out[8:], uint32(payloadSize)) + binary.LittleEndian.PutUint32(out[12:], checksum) + + // Seed LCG identically to decryption so the streams stay in sync. + rnd := (checksum<<16 | checksum>>16) | 1 + rnd = getRndECD(key, rnd) + r8 := byte(rnd) + + for i := 0; i < payloadSize; i++ { + rnd = getRndECD(key, rnd) + xorpad := rnd + + // Inverse Feistel: compute the nibble-mixed values using a zeroed + // initial state, then XOR the plaintext nibbles through. + r11 := uint32(0) + r12 := uint32(0) + + for j := 0; j < 8; j++ { + r10 := xorpad ^ r11 + r11 = r12 + r12 = (r12 ^ r10) & 0xFF + xorpad >>= 4 + } + + b := data[i] + dig2 := uint32(b) + dig1 := (dig2 >> 4) & 0xFF + dig1 ^= r11 + dig2 ^= r12 + dig1 ^= dig2 + + rr := byte((dig2 & 0xF) | ((dig1 & 0xF) << 4)) + rr ^= r8 + out[ecdHeaderSize+i] = rr + r8 = b // Cipher-feedback: next iteration uses current plaintext byte. + } + + return out, nil +} diff --git a/common/decryption/ecd_test.go b/common/decryption/ecd_test.go new file mode 100644 index 000000000..82da56102 --- /dev/null +++ b/common/decryption/ecd_test.go @@ -0,0 +1,140 @@ +package decryption + +import ( + "bytes" + "testing" +) + +// TestEncodeDecodeECD_RoundTrip verifies that encoding then decoding returns +// the original plaintext for various payloads and key indices. +func TestEncodeDecodeECD_RoundTrip(t *testing.T) { + cases := []struct { + name string + payload []byte + key int + }{ + {"empty", []byte{}, DefaultECDKey}, + {"single_byte", []byte{0x42}, DefaultECDKey}, + {"all_zeros", make([]byte, 64), DefaultECDKey}, + {"all_ones", bytes.Repeat([]byte{0xFF}, 64), DefaultECDKey}, + {"sequential", func() []byte { + b := make([]byte, 256) + for i := range b { + b[i] = byte(i) + } + return b + }(), DefaultECDKey}, + {"key0", []byte("hello world"), 0}, + {"key1", []byte("hello world"), 1}, + {"key5", []byte("hello world"), 5}, + {"large", bytes.Repeat([]byte("MHFrontier"), 1000), DefaultECDKey}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + enc, err := EncodeECD(tc.payload, tc.key) + if err != nil { + t.Fatalf("EncodeECD: %v", err) + } + + // Encoded output must start with ECD magic. + if len(enc) < 4 { + t.Fatalf("encoded output too short: %d bytes", len(enc)) + } + + dec, err := DecodeECD(enc) + if err != nil { + t.Fatalf("DecodeECD: %v", err) + } + + if !bytes.Equal(dec, tc.payload) { + t.Errorf("round-trip mismatch:\n got %x\n want %x", dec, tc.payload) + } + }) + } +} + +// TestDecodeECD_Errors verifies that invalid inputs are rejected with errors. +func TestDecodeECD_Errors(t *testing.T) { + cases := []struct { + name string + data []byte + wantErr string + }{ + { + name: "too_small", + data: []byte{0x65, 0x63, 0x64}, + wantErr: "too small", + }, + { + name: "bad_magic", + data: func() []byte { + b := make([]byte, 16) + b[0] = 0xDE + return b + }(), + wantErr: "invalid magic", + }, + { + name: "invalid_key", + data: func() []byte { + b := make([]byte, 16) + // ECD magic + b[0], b[1], b[2], b[3] = 0x65, 0x63, 0x64, 0x1A + // key index = 99 (out of range) + b[4] = 99 + return b + }(), + wantErr: "invalid key", + }, + { + name: "payload_exceeds_buffer", + data: func() []byte { + b := make([]byte, 16) + b[0], b[1], b[2], b[3] = 0x65, 0x63, 0x64, 0x1A + // key 4 + b[4] = DefaultECDKey + // declare payload size larger than the buffer + b[8], b[9], b[10], b[11] = 0xFF, 0xFF, 0xFF, 0x00 + return b + }(), + wantErr: "exceeds buffer", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := DecodeECD(tc.data) + if err == nil { + t.Fatal("expected error, got nil") + } + if !bytes.Contains([]byte(err.Error()), []byte(tc.wantErr)) { + t.Errorf("error %q does not contain %q", err.Error(), tc.wantErr) + } + }) + } +} + +// TestEncodeECD_InvalidKey verifies that an out-of-range key is rejected. +func TestEncodeECD_InvalidKey(t *testing.T) { + _, err := EncodeECD([]byte("test"), 99) + if err == nil { + t.Fatal("expected error for invalid key, got nil") + } +} + +// TestDecodeECD_EmptyPayload verifies that a valid header with zero payload +// decodes to an empty slice without error. +func TestDecodeECD_EmptyPayload(t *testing.T) { + enc, err := EncodeECD([]byte{}, DefaultECDKey) + if err != nil { + t.Fatalf("EncodeECD: %v", err) + } + dec, err := DecodeECD(enc) + if err != nil { + t.Fatalf("DecodeECD: %v", err) + } + if len(dec) != 0 { + t.Errorf("expected empty payload, got %d bytes", len(dec)) + } +} diff --git a/server/channelserver/rengoku_binary.go b/server/channelserver/rengoku_binary.go new file mode 100644 index 000000000..a8559c6b8 --- /dev/null +++ b/server/channelserver/rengoku_binary.go @@ -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 +} diff --git a/server/channelserver/rengoku_binary_test.go b/server/channelserver/rengoku_binary_test.go new file mode 100644 index 000000000..da21d917c --- /dev/null +++ b/server/channelserver/rengoku_binary_test.go @@ -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: +// +// 0x00–0x13 header (magic + version + padding) +// 0x14–0x2B multiDef RoadMode +// 0x2C–0x43 soloDef RoadMode +// 0x44–0x5B multiDef FloorStats (24 bytes) +// 0x5C–0x63 multiDef spawnTablePtrs (1×u32 = 4 bytes) +// 0x64–0x67 multiDef spawnCountPtrs (1×u32 = 4 bytes) +// 0x68–0x87 multiDef SpawnTable (32 bytes) +// 0x88–0x9F soloDef FloorStats (24 bytes) +// 0xA0–0xA3 soloDef spawnTablePtrs (1×u32) +// 0xA4–0xA7 soloDef spawnCountPtrs (1×u32) +// 0xA8–0xC7 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) + } + }) + } +} diff --git a/server/channelserver/sys_channel_server.go b/server/channelserver/sys_channel_server.go index 712c61b9c..4d4357b06 100644 --- a/server/channelserver/sys_channel_server.go +++ b/server/channelserver/sys_channel_server.go @@ -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 } diff --git a/server/channelserver/sys_channel_server_test.go b/server/channelserver/sys_channel_server_test.go index cc38bf55e..a78df9e6b 100644 --- a/server/channelserver/sys_channel_server_test.go +++ b/server/channelserver/sys_channel_server_test.go @@ -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) }