feat(rengoku): support rengoku_data.json as editable config source

Operators can now define Hunting Road configuration in a plain JSON file
(rengoku_data.json) instead of maintaining an opaque pre-encrypted binary.
The JSON is parsed, validated, assembled into the binary layout, and
ECD-encrypted at startup; rengoku_data.bin is still used as a fallback.

JSON schema covers both road modes (multi/solo) with typed floor and
spawn-table entries — floor number, spawn-table index, point multipliers,
and per-slot monster ID/variant/weighting fields. Out-of-range references
are caught at load time before any bytes are written.
This commit is contained in:
Houmgaor
2026-03-20 00:07:34 +01:00
parent 5c2fde5cfd
commit 34335b023d
3 changed files with 495 additions and 5 deletions

View File

@@ -0,0 +1,270 @@
package channelserver
/*
JSON-based rengoku_data.bin builder.
Operators can place rengoku_data.json in the bin/ directory instead of
(or alongside) rengoku_data.bin. When the JSON file is found it takes
precedence: it is parsed, validated, assembled into the raw binary layout,
and ECD-encrypted before being cached. The .bin file is used as a fallback.
Binary layout produced by BuildRengokuBinary:
0x000x13 header (20 bytes: magic + version + zeros)
0x140x2B multiDef RoadMode (24 bytes)
0x2C0x43 soloDef RoadMode (24 bytes)
-- multi road data --
floorStats[] (floorStatsCount × 24 bytes)
spawnTablePtrs[] (spawnTablePtrCount × 4 bytes)
spawnCountPtrs[] (spawnTablePtrCount × 4 bytes, zeroed)
spawnTables[] (spawnTablePtrCount × 32 bytes)
-- solo road data -- (same sub-layout)
*/
import (
"encoding/binary"
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"erupe-ce/common/decryption"
"go.uber.org/zap"
)
// ─── JSON schema ────────────────────────────────────────────────────────────
// RengokuConfig is the top-level JSON structure for rengoku_data.json.
type RengokuConfig struct {
MultiRoad RoadConfig `json:"multi_road"`
SoloRoad RoadConfig `json:"solo_road"`
}
// RoadConfig describes one road mode (multi or solo) with its floors and
// spawn tables. Floors reference spawn tables by zero-based index.
type RoadConfig struct {
Floors []FloorConfig `json:"floors"`
SpawnTables []SpawnTableConfig `json:"spawn_tables"`
}
// FloorConfig describes one floor within a road mode.
//
// - SpawnTableIndex: zero-based index into this road's SpawnTables slice,
// selecting which monster configuration is active on this floor.
// - PointMulti1/2: point multipliers applied to rewards on this floor.
// - FinalLoop: non-zero on the last floor of a loop cycle.
type FloorConfig struct {
FloorNumber uint32 `json:"floor_number"`
SpawnTableIndex uint32 `json:"spawn_table_index"`
Unk0 uint32 `json:"unk0,omitempty"`
PointMulti1 float32 `json:"point_multi_1"`
PointMulti2 float32 `json:"point_multi_2"`
FinalLoop uint32 `json:"final_loop,omitempty"`
}
// SpawnTableConfig describes the two monsters that appear together on a floor.
type SpawnTableConfig struct {
Monster1ID uint32 `json:"monster1_id"`
Monster1Variant uint32 `json:"monster1_variant,omitempty"`
Monster2ID uint32 `json:"monster2_id"`
Monster2Variant uint32 `json:"monster2_variant,omitempty"`
StatTable uint32 `json:"stat_table,omitempty"`
MapZoneOverride uint32 `json:"map_zone_override,omitempty"`
SpawnWeighting uint32 `json:"spawn_weighting,omitempty"`
AdditionalFlag uint32 `json:"additional_flag,omitempty"`
}
// ─── Builder ─────────────────────────────────────────────────────────────────
// BuildRengokuBinary assembles a raw (unencrypted, uncompressed) rengoku
// binary from a RengokuConfig. The result can be passed to EncodeECD and
// served directly to clients.
func BuildRengokuBinary(cfg RengokuConfig) ([]byte, error) {
if err := validateRengokuConfig(cfg); err != nil {
return nil, err
}
// ── Offset plan ──────────────────────────────────────────────────────────
// Fixed regions: header (0x14) + two RoadModes (2×24) = 0x44
const dataStart = uint32(rengokuMinSize) // 0x44
// Multi road sections
mFloorOff := dataStart
mFloorSz := uint32(len(cfg.MultiRoad.Floors)) * floorStatsByteSize
mPtrsOff := mFloorOff + mFloorSz
mPtrsSz := uint32(len(cfg.MultiRoad.SpawnTables)) * spawnPtrEntrySize
mCntOff := mPtrsOff + mPtrsSz
mCntSz := uint32(len(cfg.MultiRoad.SpawnTables)) * spawnPtrEntrySize
mTablesOff := mCntOff + mCntSz
mTablesSz := uint32(len(cfg.MultiRoad.SpawnTables)) * spawnTableByteSize
// Solo road sections (appended directly after multi)
sFloorOff := mTablesOff + mTablesSz
sFloorSz := uint32(len(cfg.SoloRoad.Floors)) * floorStatsByteSize
sPtrsOff := sFloorOff + sFloorSz
sPtrsSz := uint32(len(cfg.SoloRoad.SpawnTables)) * spawnPtrEntrySize
sCntOff := sPtrsOff + sPtrsSz
sCntSz := uint32(len(cfg.SoloRoad.SpawnTables)) * spawnPtrEntrySize
sTablesOff := sCntOff + sCntSz
sTablesSz := uint32(len(cfg.SoloRoad.SpawnTables)) * spawnTableByteSize
totalSize := sTablesOff + sTablesSz
buf := make([]byte, totalSize)
// ── Header ───────────────────────────────────────────────────────────────
buf[0], buf[1], buf[2], buf[3] = 'r', 'e', 'f', 0x1A
buf[4] = 1 // version
le := binary.LittleEndian
// ── RoadMode structs ─────────────────────────────────────────────────────
writeRoadMode(buf, 0x14, le, RoadModeFields{
FloorCount: uint32(len(cfg.MultiRoad.Floors)),
SpawnCount: uint32(len(cfg.MultiRoad.SpawnTables)),
TablePtrCnt: uint32(len(cfg.MultiRoad.SpawnTables)),
FloorPtr: mFloorOff,
TablePtrsPtr: mPtrsOff,
CountPtrsPtr: mCntOff,
})
writeRoadMode(buf, 0x2C, le, RoadModeFields{
FloorCount: uint32(len(cfg.SoloRoad.Floors)),
SpawnCount: uint32(len(cfg.SoloRoad.SpawnTables)),
TablePtrCnt: uint32(len(cfg.SoloRoad.SpawnTables)),
FloorPtr: sFloorOff,
TablePtrsPtr: sPtrsOff,
CountPtrsPtr: sCntOff,
})
// ── Data sections ────────────────────────────────────────────────────────
writeFloors(buf, cfg.MultiRoad.Floors, mFloorOff, le)
writeSpawnSection(buf, cfg.MultiRoad.SpawnTables, mPtrsOff, mTablesOff, le)
writeFloors(buf, cfg.SoloRoad.Floors, sFloorOff, le)
writeSpawnSection(buf, cfg.SoloRoad.SpawnTables, sPtrsOff, sTablesOff, le)
return buf, nil
}
// RoadModeFields carries the computed field values for one RoadMode struct.
type RoadModeFields struct {
FloorCount, SpawnCount, TablePtrCnt uint32
FloorPtr, TablePtrsPtr, CountPtrsPtr uint32
}
func writeRoadMode(buf []byte, offset int, le binary.ByteOrder, f RoadModeFields) {
le.PutUint32(buf[offset:], f.FloorCount)
le.PutUint32(buf[offset+4:], f.SpawnCount)
le.PutUint32(buf[offset+8:], f.TablePtrCnt)
le.PutUint32(buf[offset+12:], f.FloorPtr)
le.PutUint32(buf[offset+16:], f.TablePtrsPtr)
le.PutUint32(buf[offset+20:], f.CountPtrsPtr)
}
func writeFloors(buf []byte, floors []FloorConfig, base uint32, le binary.ByteOrder) {
for i, f := range floors {
off := base + uint32(i)*floorStatsByteSize
le.PutUint32(buf[off:], f.FloorNumber)
le.PutUint32(buf[off+4:], f.SpawnTableIndex)
le.PutUint32(buf[off+8:], f.Unk0)
le.PutUint32(buf[off+12:], math.Float32bits(f.PointMulti1))
le.PutUint32(buf[off+16:], math.Float32bits(f.PointMulti2))
le.PutUint32(buf[off+20:], f.FinalLoop)
}
}
func writeSpawnSection(buf []byte, tables []SpawnTableConfig, ptrsBase, tablesBase uint32, le binary.ByteOrder) {
for i, t := range tables {
tableOff := tablesBase + uint32(i)*spawnTableByteSize
// Pointer entry
le.PutUint32(buf[ptrsBase+uint32(i)*spawnPtrEntrySize:], tableOff)
// SpawnTable (32 bytes)
le.PutUint32(buf[tableOff:], t.Monster1ID)
le.PutUint32(buf[tableOff+4:], t.Monster1Variant)
le.PutUint32(buf[tableOff+8:], t.Monster2ID)
le.PutUint32(buf[tableOff+12:], t.Monster2Variant)
le.PutUint32(buf[tableOff+16:], t.StatTable)
le.PutUint32(buf[tableOff+20:], t.MapZoneOverride)
le.PutUint32(buf[tableOff+24:], t.SpawnWeighting)
le.PutUint32(buf[tableOff+28:], t.AdditionalFlag)
}
}
// validateRengokuConfig checks that all spawn_table_index references are
// within range for both road modes.
func validateRengokuConfig(cfg RengokuConfig) error {
for _, road := range []struct {
name string
r RoadConfig
}{{"multi_road", cfg.MultiRoad}, {"solo_road", cfg.SoloRoad}} {
n := len(road.r.SpawnTables)
for i, f := range road.r.Floors {
if int(f.SpawnTableIndex) >= n {
return fmt.Errorf("rengoku: %s floor %d: spawn_table_index %d out of range (have %d tables)",
road.name, i, f.SpawnTableIndex, n)
}
}
}
return nil
}
// ─── Shared helper ───────────────────────────────────────────────────────────
// encodeRengokuECD wraps decryption.EncodeECD with error logging.
func encodeRengokuECD(raw []byte, logger *zap.Logger) ([]byte, error) {
enc, err := decryption.EncodeECD(raw, decryption.DefaultECDKey)
if err != nil {
logger.Error("rengoku: ECD encryption failed", zap.Error(err))
}
return enc, err
}
// ─── JSON loader ─────────────────────────────────────────────────────────────
// loadRengokuFromJSON attempts to load rengoku configuration from
// rengoku_data.json in binPath. It returns the ECD-encrypted binary ready for
// caching, or nil if the file is absent or cannot be processed.
func loadRengokuFromJSON(binPath string, logger *zap.Logger) []byte {
path := filepath.Join(binPath, "rengoku_data.json")
raw, err := os.ReadFile(path)
if err != nil {
return nil // file absent — not an error
}
var cfg RengokuConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
logger.Error("rengoku_data.json: JSON parse error",
zap.String("path", path), zap.Error(err))
return nil
}
bin, err := BuildRengokuBinary(cfg)
if err != nil {
logger.Error("rengoku_data.json: binary build failed",
zap.String("path", path), zap.Error(err))
return nil
}
// Validate the freshly built binary (should always pass, but good to confirm).
info, parseErr := parseRengokuBinary(bin)
if parseErr != nil {
logger.Error("rengoku_data.json: structural validation of built binary failed",
zap.String("path", path), zap.Error(parseErr))
return nil
}
enc, err := encodeRengokuECD(bin, logger)
if err != nil {
return nil
}
logger.Info("Hunting Road config (from JSON)",
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.json", zap.Int("bytes", len(enc)))
return enc
}