diff --git a/server/channelserver/rengoku_build.go b/server/channelserver/rengoku_build.go new file mode 100644 index 000000000..c48efde0f --- /dev/null +++ b/server/channelserver/rengoku_build.go @@ -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: + 0x00–0x13 header (20 bytes: magic + version + zeros) + 0x14–0x2B multiDef RoadMode (24 bytes) + 0x2C–0x43 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 +} diff --git a/server/channelserver/rengoku_build_test.go b/server/channelserver/rengoku_build_test.go new file mode 100644 index 000000000..5fcf9a3a1 --- /dev/null +++ b/server/channelserver/rengoku_build_test.go @@ -0,0 +1,217 @@ +package channelserver + +import ( + "encoding/json" + "math" + "os" + "path/filepath" + "strings" + "testing" + + "go.uber.org/zap" +) + +// sampleRengokuConfig returns a small but complete RengokuConfig for tests. +func sampleRengokuConfig() RengokuConfig { + spawnTables := []SpawnTableConfig{ + {Monster1ID: 101, Monster1Variant: 0, Monster2ID: 102, Monster2Variant: 1, + StatTable: 3, SpawnWeighting: 10}, + {Monster1ID: 103, Monster1Variant: 2, Monster2ID: 104, Monster2Variant: 0, + SpawnWeighting: 20}, + } + floors := []FloorConfig{ + {FloorNumber: 1, SpawnTableIndex: 0, PointMulti1: 1.0, PointMulti2: 1.5}, + {FloorNumber: 2, SpawnTableIndex: 1, PointMulti1: 1.2, PointMulti2: 2.0}, + {FloorNumber: 3, SpawnTableIndex: 0, PointMulti1: 1.5, PointMulti2: 2.5, FinalLoop: 1}, + } + soloFloors := []FloorConfig{ + {FloorNumber: 1, SpawnTableIndex: 0, PointMulti1: 1.0, PointMulti2: 1.5}, + {FloorNumber: 2, SpawnTableIndex: 0, PointMulti1: 1.2, PointMulti2: 2.0}, + } + return RengokuConfig{ + MultiRoad: RoadConfig{Floors: floors, SpawnTables: spawnTables}, + SoloRoad: RoadConfig{Floors: soloFloors, SpawnTables: spawnTables[1:]}, + } +} + +// TestBuildRengokuBinary_RoundTrip builds a binary from a config and verifies +// that parseRengokuBinary accepts it and reports the expected summary. +func TestBuildRengokuBinary_RoundTrip(t *testing.T) { + cfg := sampleRengokuConfig() + + bin, err := BuildRengokuBinary(cfg) + if err != nil { + t.Fatalf("BuildRengokuBinary: %v", err) + } + + info, err := parseRengokuBinary(bin) + if err != nil { + t.Fatalf("parseRengokuBinary on built binary: %v", err) + } + + if info.MultiFloors != len(cfg.MultiRoad.Floors) { + t.Errorf("MultiFloors = %d, want %d", info.MultiFloors, len(cfg.MultiRoad.Floors)) + } + if info.MultiSpawnTables != len(cfg.MultiRoad.SpawnTables) { + t.Errorf("MultiSpawnTables = %d, want %d", info.MultiSpawnTables, len(cfg.MultiRoad.SpawnTables)) + } + if info.SoloFloors != len(cfg.SoloRoad.Floors) { + t.Errorf("SoloFloors = %d, want %d", info.SoloFloors, len(cfg.SoloRoad.Floors)) + } + if info.SoloSpawnTables != len(cfg.SoloRoad.SpawnTables) { + t.Errorf("SoloSpawnTables = %d, want %d", info.SoloSpawnTables, len(cfg.SoloRoad.SpawnTables)) + } + // Unique monsters: multi has 101,102,103,104; solo has 103,104 → 4 total + if info.UniqueMonsters != 4 { + t.Errorf("UniqueMonsters = %d, want 4", info.UniqueMonsters) + } +} + +// TestBuildRengokuBinary_FloatFields verifies that PointMulti1/2 values +// survive the binary encoding intact. +func TestBuildRengokuBinary_FloatFields(t *testing.T) { + cfg := RengokuConfig{ + MultiRoad: RoadConfig{ + Floors: []FloorConfig{ + {FloorNumber: 1, SpawnTableIndex: 0, PointMulti1: 1.25, PointMulti2: 3.75}, + }, + SpawnTables: []SpawnTableConfig{{Monster1ID: 1}}, + }, + SoloRoad: RoadConfig{ + Floors: []FloorConfig{{FloorNumber: 1, SpawnTableIndex: 0}}, + SpawnTables: []SpawnTableConfig{{Monster1ID: 2}}, + }, + } + + bin, err := BuildRengokuBinary(cfg) + if err != nil { + t.Fatalf("BuildRengokuBinary: %v", err) + } + + // Re-parse the binary and check that we can read back the float fields. + // The floor stats for multiDef start at rengokuMinSize (0x44). + // Layout: floorNumber(4) + spawnTableIndex(4) + unk0(4) + pointMulti1(4) + pointMulti2(4) + floorBase := rengokuMinSize // 0x44 + pm1Bits := uint32(bin[floorBase+12]) | uint32(bin[floorBase+13])<<8 | + uint32(bin[floorBase+14])<<16 | uint32(bin[floorBase+15])<<24 + pm2Bits := uint32(bin[floorBase+16]) | uint32(bin[floorBase+17])<<8 | + uint32(bin[floorBase+18])<<16 | uint32(bin[floorBase+19])<<24 + + if got := math.Float32frombits(pm1Bits); got != 1.25 { + t.Errorf("PointMulti1 = %f, want 1.25", got) + } + if got := math.Float32frombits(pm2Bits); got != 3.75 { + t.Errorf("PointMulti2 = %f, want 3.75", got) + } +} + +// TestBuildRengokuBinary_ValidationErrors verifies that out-of-range +// spawn_table_index values are caught before the binary is built. +func TestBuildRengokuBinary_ValidationErrors(t *testing.T) { + cases := []struct { + name string + cfg RengokuConfig + wantErr string + }{ + { + name: "multi_index_out_of_range", + cfg: RengokuConfig{ + MultiRoad: RoadConfig{ + Floors: []FloorConfig{{FloorNumber: 1, SpawnTableIndex: 5}}, + SpawnTables: []SpawnTableConfig{{Monster1ID: 1}}, + }, + SoloRoad: RoadConfig{ + Floors: []FloorConfig{{FloorNumber: 1, SpawnTableIndex: 0}}, + SpawnTables: []SpawnTableConfig{{Monster1ID: 2}}, + }, + }, + wantErr: "multi_road", + }, + { + name: "solo_index_out_of_range", + cfg: RengokuConfig{ + MultiRoad: RoadConfig{ + Floors: []FloorConfig{{FloorNumber: 1, SpawnTableIndex: 0}}, + SpawnTables: []SpawnTableConfig{{Monster1ID: 1}}, + }, + SoloRoad: RoadConfig{ + Floors: []FloorConfig{{FloorNumber: 1, SpawnTableIndex: 99}}, + SpawnTables: []SpawnTableConfig{{Monster1ID: 2}}, + }, + }, + wantErr: "solo_road", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := BuildRengokuBinary(tc.cfg) + 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) + } + }) + } +} + +// TestLoadRengokuBinary_JSONPreferredOverBin writes both a JSON file and a +// .bin file and verifies that the JSON source is used (different monster IDs). +func TestLoadRengokuBinary_JSONPreferredOverBin(t *testing.T) { + dir := t.TempDir() + logger, _ := zap.NewDevelopment() + + // Write a valid rengoku_data.json + cfg := sampleRengokuConfig() + jsonBytes, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "rengoku_data.json"), jsonBytes, 0644); err != nil { + t.Fatal(err) + } + + // Also write a minimal (but incompletely valid) rengoku_data.bin that + // would be returned if JSON loading was skipped. + binData := make([]byte, 16) // 16-byte ECD header, zero payload + binData[0], binData[1], binData[2], binData[3] = 0x65, 0x63, 0x64, 0x1A + if err := os.WriteFile(filepath.Join(dir, "rengoku_data.bin"), binData, 0644); err != nil { + t.Fatal(err) + } + + result := loadRengokuBinary(dir, logger) + if result == nil { + t.Fatal("expected non-nil result from JSON loading") + } + // The JSON-built binary is longer than the 16-byte stub .bin. + if len(result) <= 16 { + t.Errorf("result is %d bytes — looks like .bin was used instead of JSON", len(result)) + } +} + +// TestLoadRengokuBinary_JSONFallsThroughOnBadJSON verifies that a malformed +// JSON file causes loadRengokuBinary to fall back to the .bin file. +func TestLoadRengokuBinary_JSONFallsThroughOnBadJSON(t *testing.T) { + dir := t.TempDir() + logger, _ := zap.NewDevelopment() + + if err := os.WriteFile(filepath.Join(dir, "rengoku_data.json"), []byte("{invalid json"), 0644); err != nil { + t.Fatal(err) + } + + // Write a valid minimal .bin + binData := make([]byte, 16) + binData[0], binData[1], binData[2], binData[3] = 0x65, 0x63, 0x64, 0x1A + if err := os.WriteFile(filepath.Join(dir, "rengoku_data.bin"), binData, 0644); err != nil { + t.Fatal(err) + } + + result := loadRengokuBinary(dir, logger) + if result == nil { + t.Fatal("expected fallback to .bin, got nil") + } + if len(result) != 16 { + t.Errorf("expected 16-byte .bin result, got %d bytes", len(result)) + } +} diff --git a/server/channelserver/sys_channel_server.go b/server/channelserver/sys_channel_server.go index 4d4357b06..5edd1dab7 100644 --- a/server/channelserver/sys_channel_server.go +++ b/server/channelserver/sys_channel_server.go @@ -450,12 +450,15 @@ func (s *Server) Season() uint8 { return uint8(((TimeAdjusted().Unix() / secsPerDay) + sid) % 3) } -// 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. +// loadRengokuBinary loads and caches Hunting Road config. It prefers +// rengoku_data.json (human-readable, built on the fly) and falls back to the +// pre-encrypted rengoku_data.bin. Returns ECD-encrypted bytes ready to serve, +// or nil if no valid source is found. func loadRengokuBinary(binPath string, logger *zap.Logger) []byte { + if enc := loadRengokuFromJSON(binPath, logger); enc != nil { + return enc + } + path := filepath.Join(binPath, "rengoku_data.bin") data, err := os.ReadFile(path) if err != nil {