mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-22 07:32:32 +01:00
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:
162
common/decryption/ecd.go
Normal file
162
common/decryption/ecd.go
Normal file
@@ -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
|
||||
}
|
||||
140
common/decryption/ecd_test.go
Normal file
140
common/decryption/ecd_test.go
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
181
server/channelserver/rengoku_binary.go
Normal file
181
server/channelserver/rengoku_binary.go
Normal 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
|
||||
}
|
||||
182
server/channelserver/rengoku_binary_test.go
Normal file
182
server/channelserver/rengoku_binary_test.go
Normal 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:
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user