mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-21 23:22:34 +01:00
Adds human-readable JSON as an alternative quest format for bin/quests/. The server tries .bin first (full backward compatibility), then falls back to .json and compiles it to the MHF binary wire format on the fly. JSON quests cover all documented fields: text (UTF-8 → Shift-JIS), objectives (all 12 types), monster spawns, reward tables, supply box, stages, rank requirements, variant flags, and forced equipment. Also adds ParseQuestBinary for the reverse direction, enabling tools to round-trip quests and verify that JSON-compiled output is bit-for-bit identical to a hand-authored .bin for the same quest data. 49 tests: compiler, parser, 13 round-trip scenarios, and golden byte- level assertions covering every section of the binary layout.
416 lines
13 KiB
Go
416 lines
13 KiB
Go
package channelserver
|
||
|
||
import (
|
||
"encoding/binary"
|
||
"fmt"
|
||
"math"
|
||
|
||
"golang.org/x/text/encoding/japanese"
|
||
"golang.org/x/text/transform"
|
||
)
|
||
|
||
// ParseQuestBinary reads a MHF quest binary (ZZ/G10 layout, little-endian)
|
||
// and returns a QuestJSON ready for re-compilation with CompileQuestJSON.
|
||
//
|
||
// The binary layout is described in quest_json.go (CompileQuestJSON).
|
||
// Sections guarded by null pointers in the header are skipped; the
|
||
// corresponding QuestJSON slices will be nil/empty.
|
||
func ParseQuestBinary(data []byte) (*QuestJSON, error) {
|
||
if len(data) < 0x86 {
|
||
return nil, fmt.Errorf("quest binary too short: %d bytes (minimum 0x86)", len(data))
|
||
}
|
||
|
||
// ── Helper closures ──────────────────────────────────────────────────
|
||
u8 := func(off int) uint8 {
|
||
return data[off]
|
||
}
|
||
u16 := func(off int) uint16 {
|
||
return binary.LittleEndian.Uint16(data[off:])
|
||
}
|
||
u32 := func(off int) uint32 {
|
||
return binary.LittleEndian.Uint32(data[off:])
|
||
}
|
||
f32 := func(off int) float32 {
|
||
return math.Float32frombits(binary.LittleEndian.Uint32(data[off:]))
|
||
}
|
||
|
||
// bounds checks a read of n bytes at off.
|
||
check := func(off, n int, ctx string) error {
|
||
if off < 0 || off+n > len(data) {
|
||
return fmt.Errorf("%s: offset 0x%X len %d out of bounds (file len %d)", ctx, off, n, len(data))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// readSJIS reads a null-terminated Shift-JIS string starting at off.
|
||
readSJIS := func(off int) (string, error) {
|
||
if off < 0 || off >= len(data) {
|
||
return "", fmt.Errorf("string offset 0x%X out of bounds", off)
|
||
}
|
||
end := off
|
||
for end < len(data) && data[end] != 0 {
|
||
end++
|
||
}
|
||
sjis := data[off:end]
|
||
if len(sjis) == 0 {
|
||
return "", nil
|
||
}
|
||
dec := japanese.ShiftJIS.NewDecoder()
|
||
utf8, _, err := transform.Bytes(dec, sjis)
|
||
if err != nil {
|
||
return "", fmt.Errorf("shift-jis decode at 0x%X: %w", off, err)
|
||
}
|
||
return string(utf8), nil
|
||
}
|
||
|
||
q := &QuestJSON{}
|
||
|
||
// ── Header (0x00–0x43) ───────────────────────────────────────────────
|
||
questTypeFlagsPtr := int(u32(0x00))
|
||
loadedStagesPtr := int(u32(0x04))
|
||
supplyBoxPtr := int(u32(0x08))
|
||
rewardPtr := int(u32(0x0C))
|
||
// 0x10 subSupplyBoxPtr (u16), 0x12 hidden, 0x13 subSupplyBoxLen — not in QuestJSON
|
||
// 0x14 questAreaPtr — null, not parsed
|
||
largeMonsterPtr := int(u32(0x18))
|
||
// 0x1C areaTransitionsPtr — null, not parsed
|
||
// 0x20 areaMappingPtr — null, not parsed
|
||
// 0x24 mapInfoPtr — null, not parsed
|
||
// 0x28 gatheringPointsPtr — null, not parsed
|
||
// 0x2C areaFacilitiesPtr — null, not parsed
|
||
// 0x30 someStringsPtr — null, not parsed
|
||
unk34Ptr := int(u32(0x34)) // stages-end sentinel
|
||
// 0x38 gatheringTablesPtr — null, not parsed
|
||
// 0x3C fixedCoords2Ptr — null, not parsed
|
||
// 0x40 fixedInfoPtr — null, not parsed
|
||
|
||
// ── General Quest Properties (0x44–0x85) ────────────────────────────
|
||
q.MonsterSizeMulti = u16(0x44)
|
||
q.SizeRange = u16(0x46)
|
||
q.StatTable1 = u32(0x48)
|
||
q.MainRankPoints = u32(0x4C)
|
||
// 0x50 unknown u32 — skipped
|
||
q.SubARankPoints = u32(0x54)
|
||
q.SubBRankPoints = u32(0x58)
|
||
// 0x5C questTypeID/unknown — skipped
|
||
// 0x60 padding
|
||
q.StatTable2 = u8(0x61)
|
||
// 0x62–0x85 padding, questKn1/2/3, gatheringTablesQty, zone counts, unknowns — skipped
|
||
|
||
// ── Main Quest Properties (at questTypeFlagsPtr, 320 bytes) ─────────
|
||
if questTypeFlagsPtr == 0 {
|
||
return nil, fmt.Errorf("questTypeFlagsPtr is null; cannot read main quest properties")
|
||
}
|
||
if err := check(questTypeFlagsPtr, questBodyLenZZ, "mainQuestProperties"); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
mp := questTypeFlagsPtr // shorthand
|
||
|
||
// +0x08 rankBand
|
||
q.RankBand = u16(mp + 0x08)
|
||
// +0x0C questFee
|
||
q.Fee = u32(mp + 0x0C)
|
||
// +0x10 rewardMain
|
||
q.RewardMain = u32(mp + 0x10)
|
||
// +0x18 rewardA
|
||
q.RewardSubA = u16(mp + 0x18)
|
||
// +0x1C rewardB
|
||
q.RewardSubB = u16(mp + 0x1C)
|
||
// +0x1E hardHRReq
|
||
q.HardHRReq = u16(mp + 0x1E)
|
||
// +0x20 questTime (frames at 30 Hz → minutes)
|
||
questFrames := u32(mp + 0x20)
|
||
q.TimeLimitMinutes = questFrames / (60 * 30)
|
||
// +0x24 questMap
|
||
q.Map = u32(mp + 0x24)
|
||
// +0x28 questStringsPtr (absolute file offset)
|
||
questStringsPtr := int(u32(mp + 0x28))
|
||
// +0x2E questID
|
||
q.QuestID = u16(mp + 0x2E)
|
||
|
||
// +0x30 objectives[3] (8 bytes each)
|
||
objectives, err := parseObjectives(data, mp+0x30)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
q.ObjectiveMain = objectives[0]
|
||
q.ObjectiveSubA = objectives[1]
|
||
q.ObjectiveSubB = objectives[2]
|
||
|
||
// +0x4C joinRankMin/Max, postRankMin/Max
|
||
q.JoinRankMin = u16(mp + 0x4C)
|
||
q.JoinRankMax = u16(mp + 0x4E)
|
||
q.PostRankMin = u16(mp + 0x50)
|
||
q.PostRankMax = u16(mp + 0x52)
|
||
|
||
// +0x5C forced equipment (6 slots × 4 × u16 = 48 bytes)
|
||
eq, hasEquip := parseForcedEquip(data, mp+0x5C)
|
||
if hasEquip {
|
||
q.ForcedEquipment = eq
|
||
}
|
||
|
||
// +0x97 questVariants
|
||
q.QuestVariant1 = u8(mp + 0x97)
|
||
q.QuestVariant2 = u8(mp + 0x98)
|
||
q.QuestVariant3 = u8(mp + 0x99)
|
||
q.QuestVariant4 = u8(mp + 0x9A)
|
||
|
||
// ── QuestText strings ────────────────────────────────────────────────
|
||
if questStringsPtr != 0 {
|
||
if err := check(questStringsPtr, 32, "questTextTable"); err != nil {
|
||
return nil, err
|
||
}
|
||
// 8 pointers × 4 bytes: title, textMain, textSubA, textSubB,
|
||
// successCond, failCond, contractor, description.
|
||
strPtrs := make([]int, 8)
|
||
for i := range strPtrs {
|
||
strPtrs[i] = int(u32(questStringsPtr + i*4))
|
||
}
|
||
texts := make([]string, 8)
|
||
for i, ptr := range strPtrs {
|
||
if ptr == 0 {
|
||
continue
|
||
}
|
||
s, err := readSJIS(ptr)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("string[%d]: %w", i, err)
|
||
}
|
||
texts[i] = s
|
||
}
|
||
q.Title = texts[0]
|
||
q.TextMain = texts[1]
|
||
q.TextSubA = texts[2]
|
||
q.TextSubB = texts[3]
|
||
q.SuccessCond = texts[4]
|
||
q.FailCond = texts[5]
|
||
q.Contractor = texts[6]
|
||
q.Description = texts[7]
|
||
}
|
||
|
||
// ── Stages ───────────────────────────────────────────────────────────
|
||
// Guarded by loadedStagesPtr; terminated when we reach unk34Ptr.
|
||
// Each stage: u32 stageID + 12 bytes padding = 16 bytes.
|
||
if loadedStagesPtr != 0 && unk34Ptr > loadedStagesPtr {
|
||
off := loadedStagesPtr
|
||
for off+16 <= unk34Ptr {
|
||
if err := check(off, 16, "stage"); err != nil {
|
||
return nil, err
|
||
}
|
||
stageID := u32(off)
|
||
q.Stages = append(q.Stages, QuestStageJSON{StageID: stageID})
|
||
off += 16
|
||
}
|
||
}
|
||
|
||
// ── Supply Box ───────────────────────────────────────────────────────
|
||
// Guarded by supplyBoxPtr. Layout: main(24) + subA(8) + subB(8) × 4 bytes each.
|
||
if supplyBoxPtr != 0 {
|
||
const supplyBoxSize = (24 + 8 + 8) * 4
|
||
if err := check(supplyBoxPtr, supplyBoxSize, "supplyBox"); err != nil {
|
||
return nil, err
|
||
}
|
||
q.SupplyMain = readSupplySlots(data, supplyBoxPtr, 24)
|
||
q.SupplySubA = readSupplySlots(data, supplyBoxPtr+24*4, 8)
|
||
q.SupplySubB = readSupplySlots(data, supplyBoxPtr+24*4+8*4, 8)
|
||
}
|
||
|
||
// ── Reward Tables ────────────────────────────────────────────────────
|
||
// Guarded by rewardPtr. Header array terminated by int16(-1); item lists
|
||
// each terminated by int16(-1).
|
||
if rewardPtr != 0 {
|
||
tables, err := parseRewardTables(data, rewardPtr)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
q.Rewards = tables
|
||
}
|
||
|
||
// ── Large Monster Spawns ─────────────────────────────────────────────
|
||
// Guarded by largeMonsterPtr. Each entry is 60 bytes; terminated by 0xFF.
|
||
if largeMonsterPtr != 0 {
|
||
monsters, err := parseMonsterSpawns(data, largeMonsterPtr, f32)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
q.LargeMonsters = monsters
|
||
}
|
||
|
||
return q, nil
|
||
}
|
||
|
||
// ── Section parsers ──────────────────────────────────────────────────────────
|
||
|
||
// parseObjectives reads the three 8-byte objective entries at off.
|
||
func parseObjectives(data []byte, off int) ([3]QuestObjectiveJSON, error) {
|
||
var objs [3]QuestObjectiveJSON
|
||
for i := range objs {
|
||
base := off + i*8
|
||
if base+8 > len(data) {
|
||
return objs, fmt.Errorf("objective[%d] at 0x%X out of bounds", i, base)
|
||
}
|
||
goalType := binary.LittleEndian.Uint32(data[base:])
|
||
typeName, ok := objTypeToString(goalType)
|
||
if !ok {
|
||
typeName = "none"
|
||
}
|
||
obj := QuestObjectiveJSON{Type: typeName}
|
||
|
||
if goalType != questObjNone {
|
||
switch goalType {
|
||
case questObjHunt, questObjCapture, questObjSlay, questObjDamage,
|
||
questObjSlayOrDamage, questObjBreakPart:
|
||
obj.Target = uint16(data[base+4])
|
||
// data[base+5] is padding
|
||
default:
|
||
obj.Target = binary.LittleEndian.Uint16(data[base+4:])
|
||
}
|
||
|
||
secondary := binary.LittleEndian.Uint16(data[base+6:])
|
||
if goalType == questObjBreakPart {
|
||
obj.Part = secondary
|
||
} else {
|
||
obj.Count = secondary
|
||
}
|
||
}
|
||
objs[i] = obj
|
||
}
|
||
return objs, nil
|
||
}
|
||
|
||
// parseForcedEquip reads 6 slots × 4 uint16 at off.
|
||
// Returns nil, false if all values are zero (no forced equipment).
|
||
func parseForcedEquip(data []byte, off int) (*QuestForcedEquipJSON, bool) {
|
||
eq := &QuestForcedEquipJSON{}
|
||
slots := []*[4]uint16{&eq.Legs, &eq.Weapon, &eq.Head, &eq.Chest, &eq.Arms, &eq.Waist}
|
||
anyNonZero := false
|
||
for _, slot := range slots {
|
||
for j := range slot {
|
||
v := binary.LittleEndian.Uint16(data[off:])
|
||
slot[j] = v
|
||
if v != 0 {
|
||
anyNonZero = true
|
||
}
|
||
off += 2
|
||
}
|
||
}
|
||
if !anyNonZero {
|
||
return nil, false
|
||
}
|
||
return eq, true
|
||
}
|
||
|
||
// readSupplySlots reads n supply item slots (each 4 bytes: u16 item + u16 qty)
|
||
// starting at off and returns only non-empty entries (item != 0).
|
||
func readSupplySlots(data []byte, off, n int) []QuestSupplyItemJSON {
|
||
var out []QuestSupplyItemJSON
|
||
for i := 0; i < n; i++ {
|
||
base := off + i*4
|
||
item := binary.LittleEndian.Uint16(data[base:])
|
||
qty := binary.LittleEndian.Uint16(data[base+2:])
|
||
if item == 0 {
|
||
continue
|
||
}
|
||
out = append(out, QuestSupplyItemJSON{Item: item, Quantity: qty})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// parseRewardTables reads the reward table array starting at baseOff.
|
||
// Header array: {u8 tableId, u8 pad, u16 pad, u32 tableOffset} per entry,
|
||
// terminated by int16(-1). tableOffset is relative to baseOff.
|
||
// Each item list: {u16 rate, u16 item, u16 quantity} terminated by int16(-1).
|
||
func parseRewardTables(data []byte, baseOff int) ([]QuestRewardTableJSON, error) {
|
||
var tables []QuestRewardTableJSON
|
||
off := baseOff
|
||
for {
|
||
if off+2 > len(data) {
|
||
return nil, fmt.Errorf("reward table header truncated at 0x%X", off)
|
||
}
|
||
// Check for terminator (0xFFFF).
|
||
if binary.LittleEndian.Uint16(data[off:]) == 0xFFFF {
|
||
break
|
||
}
|
||
if off+8 > len(data) {
|
||
return nil, fmt.Errorf("reward table header entry truncated at 0x%X", off)
|
||
}
|
||
tableID := data[off]
|
||
tableOff := int(binary.LittleEndian.Uint32(data[off+4:])) + baseOff
|
||
off += 8
|
||
|
||
// Read items at tableOff.
|
||
items, err := parseRewardItems(data, tableOff)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("reward table %d items: %w", tableID, err)
|
||
}
|
||
tables = append(tables, QuestRewardTableJSON{TableID: tableID, Items: items})
|
||
}
|
||
return tables, nil
|
||
}
|
||
|
||
// parseRewardItems reads a null-terminated reward item list at off.
|
||
func parseRewardItems(data []byte, off int) ([]QuestRewardItemJSON, error) {
|
||
var items []QuestRewardItemJSON
|
||
for {
|
||
if off+2 > len(data) {
|
||
return nil, fmt.Errorf("reward item list truncated at 0x%X", off)
|
||
}
|
||
if binary.LittleEndian.Uint16(data[off:]) == 0xFFFF {
|
||
break
|
||
}
|
||
if off+6 > len(data) {
|
||
return nil, fmt.Errorf("reward item entry truncated at 0x%X", off)
|
||
}
|
||
rate := binary.LittleEndian.Uint16(data[off:])
|
||
item := binary.LittleEndian.Uint16(data[off+2:])
|
||
qty := binary.LittleEndian.Uint16(data[off+4:])
|
||
items = append(items, QuestRewardItemJSON{Rate: rate, Item: item, Quantity: qty})
|
||
off += 6
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
// parseMonsterSpawns reads large monster spawn entries at baseOff.
|
||
// Each entry is 60 bytes; the list is terminated by a 0xFF byte.
|
||
func parseMonsterSpawns(data []byte, baseOff int, f32fn func(int) float32) ([]QuestMonsterJSON, error) {
|
||
var monsters []QuestMonsterJSON
|
||
off := baseOff
|
||
const entrySize = 60
|
||
for {
|
||
if off >= len(data) {
|
||
return nil, fmt.Errorf("monster spawn list unterminated at end of file")
|
||
}
|
||
if data[off] == 0xFF {
|
||
break
|
||
}
|
||
if off+entrySize > len(data) {
|
||
return nil, fmt.Errorf("monster spawn entry at 0x%X truncated", off)
|
||
}
|
||
m := QuestMonsterJSON{
|
||
ID: data[off],
|
||
SpawnAmount: binary.LittleEndian.Uint32(data[off+4:]),
|
||
SpawnStage: binary.LittleEndian.Uint32(data[off+8:]),
|
||
// +0x0C padding[16]
|
||
Orientation: binary.LittleEndian.Uint32(data[off+0x1C:]),
|
||
X: f32fn(off + 0x20),
|
||
Y: f32fn(off + 0x24),
|
||
Z: f32fn(off + 0x28),
|
||
// +0x2C padding[16]
|
||
}
|
||
monsters = append(monsters, m)
|
||
off += entrySize
|
||
}
|
||
return monsters, nil
|
||
}
|
||
|
||
// objTypeToString maps a uint32 goal type to its JSON string name.
|
||
// Returns "", false for unknown types.
|
||
func objTypeToString(t uint32) (string, bool) {
|
||
for name, v := range questObjTypeMap {
|
||
if v == t {
|
||
return name, true
|
||
}
|
||
}
|
||
return "", false
|
||
}
|