From c64260275bdcd523280084d63f866190f02c1259 Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Thu, 19 Mar 2026 17:56:50 +0100 Subject: [PATCH] feat(quests): support JSON quest files alongside .bin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- server/channelserver/handlers_quest.go | 30 +- server/channelserver/quest_json.go | 630 ++++++++++++++++ server/channelserver/quest_json_parser.go | 415 ++++++++++ server/channelserver/quest_json_test.go | 877 ++++++++++++++++++++++ 4 files changed, 1949 insertions(+), 3 deletions(-) create mode 100644 server/channelserver/quest_json.go create mode 100644 server/channelserver/quest_json_parser.go create mode 100644 server/channelserver/quest_json_test.go diff --git a/server/channelserver/handlers_quest.go b/server/channelserver/handlers_quest.go index 61c104555..9847b1d28 100644 --- a/server/channelserver/handlers_quest.go +++ b/server/channelserver/handlers_quest.go @@ -126,9 +126,9 @@ func handleMsgSysGetFile(s *Session, p mhfpacket.MHFPacket) { pkt.Filename = seasonConversion(s, pkt.Filename) } - data, err := os.ReadFile(filepath.Join(s.server.erupeConfig.BinPath, fmt.Sprintf("quests/%s.bin", pkt.Filename))) + data, err := loadQuestBinary(s, pkt.Filename) if err != nil { - s.logger.Error("Failed to open quest file", zap.String("binPath", s.server.erupeConfig.BinPath), zap.String("filename", pkt.Filename)) + s.logger.Error("Failed to open quest file", zap.String("binPath", s.server.erupeConfig.BinPath), zap.String("filename", pkt.Filename), zap.Error(err)) doAckBufFail(s, pkt.AckHandle, nil) return } @@ -140,10 +140,34 @@ func handleMsgSysGetFile(s *Session, p mhfpacket.MHFPacket) { } func questFileExists(s *Session, filename string) bool { - _, err := os.Stat(filepath.Join(s.server.erupeConfig.BinPath, fmt.Sprintf("quests/%s.bin", filename))) + base := filepath.Join(s.server.erupeConfig.BinPath, "quests", filename) + if _, err := os.Stat(base + ".bin"); err == nil { + return true + } + _, err := os.Stat(base + ".json") return err == nil } +// loadQuestBinary loads a quest file by name, trying .bin first then .json. +// For .json files it compiles the JSON to the MHF binary wire format. +func loadQuestBinary(s *Session, filename string) ([]byte, error) { + base := filepath.Join(s.server.erupeConfig.BinPath, "quests", filename) + + if data, err := os.ReadFile(base + ".bin"); err == nil { + return data, nil + } + + jsonData, err := os.ReadFile(base + ".json") + if err != nil { + return nil, err + } + compiled, err := CompileQuestJSON(jsonData) + if err != nil { + return nil, fmt.Errorf("compile quest JSON %s: %w", filename, err) + } + return compiled, nil +} + func seasonConversion(s *Session, questFile string) string { // Try the seasonal override file (e.g., 00001d2 for season 2) filename := fmt.Sprintf("%s%d", questFile[:6], s.server.Season()) diff --git a/server/channelserver/quest_json.go b/server/channelserver/quest_json.go new file mode 100644 index 000000000..3637d5d11 --- /dev/null +++ b/server/channelserver/quest_json.go @@ -0,0 +1,630 @@ +package channelserver + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "fmt" + "math" + + "golang.org/x/text/encoding/japanese" + "golang.org/x/text/transform" +) + +// Objective type constants matching questObjType in questfile.bin.hexpat. +const ( + questObjNone = uint32(0x00000000) + questObjHunt = uint32(0x00000001) + questObjDeliver = uint32(0x00000002) + questObjEsoteric = uint32(0x00000010) + questObjCapture = uint32(0x00000101) + questObjSlay = uint32(0x00000201) + questObjDeliverFlag = uint32(0x00001002) + questObjBreakPart = uint32(0x00004004) + questObjDamage = uint32(0x00008004) + questObjSlayOrDamage = uint32(0x00018004) + questObjSlayTotal = uint32(0x00020000) + questObjSlayAll = uint32(0x00040000) +) + +var questObjTypeMap = map[string]uint32{ + "none": questObjNone, + "hunt": questObjHunt, + "deliver": questObjDeliver, + "esoteric": questObjEsoteric, + "capture": questObjCapture, + "slay": questObjSlay, + "deliver_flag": questObjDeliverFlag, + "break_part": questObjBreakPart, + "damage": questObjDamage, + "slay_or_damage": questObjSlayOrDamage, + "slay_total": questObjSlayTotal, + "slay_all": questObjSlayAll, +} + +// ---- JSON schema types ---- + +// QuestObjectiveJSON represents a single quest objective. +type QuestObjectiveJSON struct { + // Type is one of: none, hunt, capture, slay, deliver, deliver_flag, + // break_part, damage, slay_or_damage, slay_total, slay_all, esoteric. + Type string `json:"type"` + // Target is a monster ID for hunt/capture/slay/break_part/damage, + // or an item ID for deliver/deliver_flag. + Target uint16 `json:"target"` + // Count is the quantity required (hunts, item count, etc.). + Count uint16 `json:"count"` + // Part is the monster part ID for break_part objectives. + Part uint16 `json:"part,omitempty"` +} + +// QuestRewardItemJSON is one entry in a reward table. +type QuestRewardItemJSON struct { + Rate uint16 `json:"rate"` + Item uint16 `json:"item"` + Quantity uint16 `json:"quantity"` +} + +// QuestRewardTableJSON is a named reward table with its items. +type QuestRewardTableJSON struct { + TableID uint8 `json:"table_id"` + Items []QuestRewardItemJSON `json:"items"` +} + +// QuestMonsterJSON describes one large monster spawn. +type QuestMonsterJSON struct { + ID uint8 `json:"id"` + SpawnAmount uint32 `json:"spawn_amount"` + SpawnStage uint32 `json:"spawn_stage"` + Orientation uint32 `json:"orientation"` + X float32 `json:"x"` + Y float32 `json:"y"` + Z float32 `json:"z"` +} + +// QuestSupplyItemJSON is one supply box entry. +type QuestSupplyItemJSON struct { + Item uint16 `json:"item"` + Quantity uint16 `json:"quantity"` +} + +// QuestStageJSON is a loaded stage definition. +type QuestStageJSON struct { + StageID uint32 `json:"stage_id"` +} + +// QuestForcedEquipJSON defines forced equipment per slot. +// Each slot is [equipment_id, attach1, attach2, attach3]. +// Zero values mean no restriction. +type QuestForcedEquipJSON struct { + Legs [4]uint16 `json:"legs,omitempty"` + Weapon [4]uint16 `json:"weapon,omitempty"` + Head [4]uint16 `json:"head,omitempty"` + Chest [4]uint16 `json:"chest,omitempty"` + Arms [4]uint16 `json:"arms,omitempty"` + Waist [4]uint16 `json:"waist,omitempty"` +} + +// QuestJSON is the human-readable quest definition. +// Time values: TimeLimitMinutes is converted to frames (×30×60) in the binary. +// Strings: encoded as UTF-8 here, converted to Shift-JIS in the binary. +// All pointer-based sections (gathering, area transitions, facilities) are +// omitted — those fields are set to null pointers so the client uses defaults. +type QuestJSON struct { + // Quest identification + QuestID uint16 `json:"quest_id"` + + // Text (UTF-8; converted to Shift-JIS in binary) + Title string `json:"title"` + Description string `json:"description"` + TextMain string `json:"text_main"` + TextSubA string `json:"text_sub_a"` + TextSubB string `json:"text_sub_b"` + SuccessCond string `json:"success_cond"` + FailCond string `json:"fail_cond"` + Contractor string `json:"contractor"` + + // General quest properties (generalQuestProperties section, 0x44–0x85) + MonsterSizeMulti uint16 `json:"monster_size_multi"` // 100 = 100% + SizeRange uint16 `json:"size_range"` + StatTable1 uint32 `json:"stat_table_1,omitempty"` + StatTable2 uint8 `json:"stat_table_2,omitempty"` + MainRankPoints uint32 `json:"main_rank_points"` + SubARankPoints uint32 `json:"sub_a_rank_points"` + SubBRankPoints uint32 `json:"sub_b_rank_points"` + + // Main quest properties + Fee uint32 `json:"fee"` + RewardMain uint32 `json:"reward_main"` + RewardSubA uint16 `json:"reward_sub_a"` + RewardSubB uint16 `json:"reward_sub_b"` + TimeLimitMinutes uint32 `json:"time_limit_minutes"` + Map uint32 `json:"map"` + RankBand uint16 `json:"rank_band"` + HardHRReq uint16 `json:"hard_hr_req,omitempty"` + JoinRankMin uint16 `json:"join_rank_min,omitempty"` + JoinRankMax uint16 `json:"join_rank_max,omitempty"` + PostRankMin uint16 `json:"post_rank_min,omitempty"` + PostRankMax uint16 `json:"post_rank_max,omitempty"` + + // Quest variant flags (see handlers_quest.go makeEventQuest comments) + QuestVariant1 uint8 `json:"quest_variant1,omitempty"` + QuestVariant2 uint8 `json:"quest_variant2,omitempty"` + QuestVariant3 uint8 `json:"quest_variant3,omitempty"` + QuestVariant4 uint8 `json:"quest_variant4,omitempty"` + + // Objectives + ObjectiveMain QuestObjectiveJSON `json:"objective_main"` + ObjectiveSubA QuestObjectiveJSON `json:"objective_sub_a,omitempty"` + ObjectiveSubB QuestObjectiveJSON `json:"objective_sub_b,omitempty"` + + // Monster spawns + LargeMonsters []QuestMonsterJSON `json:"large_monsters,omitempty"` + + // Reward tables + Rewards []QuestRewardTableJSON `json:"rewards,omitempty"` + + // Supply box (main: up to 24, sub_a/sub_b: up to 8 each) + SupplyMain []QuestSupplyItemJSON `json:"supply_main,omitempty"` + SupplySubA []QuestSupplyItemJSON `json:"supply_sub_a,omitempty"` + SupplySubB []QuestSupplyItemJSON `json:"supply_sub_b,omitempty"` + + // Loaded stages + Stages []QuestStageJSON `json:"stages,omitempty"` + + // Forced equipment (optional) + ForcedEquipment *QuestForcedEquipJSON `json:"forced_equipment,omitempty"` +} + +// toShiftJIS converts a UTF-8 string to a null-terminated Shift-JIS byte slice. +// ASCII-only strings pass through unchanged. +func toShiftJIS(s string) ([]byte, error) { + enc := japanese.ShiftJIS.NewEncoder() + out, _, err := transform.Bytes(enc, []byte(s)) + if err != nil { + return nil, fmt.Errorf("shift-jis encode %q: %w", s, err) + } + return append(out, 0x00), nil +} + +// writeUint16LE writes a little-endian uint16 to buf. +func writeUint16LE(buf *bytes.Buffer, v uint16) { + b := [2]byte{} + binary.LittleEndian.PutUint16(b[:], v) + buf.Write(b[:]) +} + +// writeUint32LE writes a little-endian uint32 to buf. +func writeUint32LE(buf *bytes.Buffer, v uint32) { + b := [4]byte{} + binary.LittleEndian.PutUint32(b[:], v) + buf.Write(b[:]) +} + +// writeFloat32LE writes a little-endian IEEE-754 float32 to buf. +func writeFloat32LE(buf *bytes.Buffer, v float32) { + b := [4]byte{} + binary.LittleEndian.PutUint32(b[:], math.Float32bits(v)) + buf.Write(b[:]) +} + +// pad writes n zero bytes to buf. +func pad(buf *bytes.Buffer, n int) { + buf.Write(make([]byte, n)) +} + +// objectiveBytes serialises one QuestObjectiveJSON to 8 bytes. +// Layout per hexpat objective.hexpat: +// +// u32 goalType +// if hunt/capture/slay/damage/break_part: u8 target, u8 pad +// else: u16 target +// if break_part: u16 goalPart +// else: u16 goalCount +// if none: trailing padding[4] instead of the above +func objectiveBytes(obj QuestObjectiveJSON) ([]byte, error) { + goalType, ok := questObjTypeMap[obj.Type] + if !ok { + if obj.Type == "" { + goalType = questObjNone + } else { + return nil, fmt.Errorf("unknown objective type %q", obj.Type) + } + } + + buf := &bytes.Buffer{} + writeUint32LE(buf, goalType) + + if goalType == questObjNone { + pad(buf, 4) + return buf.Bytes(), nil + } + + switch goalType { + case questObjHunt, questObjCapture, questObjSlay, questObjDamage, + questObjSlayOrDamage, questObjBreakPart: + buf.WriteByte(uint8(obj.Target)) + buf.WriteByte(0x00) + default: + writeUint16LE(buf, obj.Target) + } + + if goalType == questObjBreakPart { + writeUint16LE(buf, obj.Part) + } else { + writeUint16LE(buf, obj.Count) + } + + return buf.Bytes(), nil +} + +// CompileQuestJSON parses JSON quest data and compiles it to the MHF quest +// binary format (ZZ/G10 version, little-endian, uncompressed). +// +// Binary layout produced: +// +// 0x000–0x043 QuestFileHeader (68 bytes, 17 pointers) +// 0x044–0x085 generalQuestProperties (66 bytes) +// 0x086–0x1C5 mainQuestProperties (320 bytes, questBodyLenZZ) +// 0x1C6+ QuestText pointer table (32 bytes) + strings (Shift-JIS) +// aligned+ stages, supply box, reward tables, monster spawns +func CompileQuestJSON(data []byte) ([]byte, error) { + var q QuestJSON + if err := json.Unmarshal(data, &q); err != nil { + return nil, fmt.Errorf("parse quest JSON: %w", err) + } + + // ── Section offsets (computed as we build) ────────────────────────── + const ( + headerSize = 68 // 0x44 + genPropSize = 66 // 0x42 + mainPropSize = questBodyLenZZ // 320 = 0x140 + questTextSize = 32 // 8 × 4-byte s32p pointers + ) + + questTypeFlagsPtr := uint32(headerSize + genPropSize) // 0x86 + questStringsTablePtr := questTypeFlagsPtr + uint32(mainPropSize) // 0x1C6 + + // ── Build Shift-JIS strings ───────────────────────────────────────── + // Order matches QuestText struct: title, textMain, textSubA, textSubB, + // successCond, failCond, contractor, description. + rawTexts := []string{ + q.Title, q.TextMain, q.TextSubA, q.TextSubB, + q.SuccessCond, q.FailCond, q.Contractor, q.Description, + } + var sjisStrings [][]byte + for _, s := range rawTexts { + b, err := toShiftJIS(s) + if err != nil { + return nil, err + } + sjisStrings = append(sjisStrings, b) + } + + // Compute absolute pointers for each string (right after the s32p table). + stringDataStart := questStringsTablePtr + uint32(questTextSize) + stringPtrs := make([]uint32, len(sjisStrings)) + cursor := stringDataStart + for i, s := range sjisStrings { + stringPtrs[i] = cursor + cursor += uint32(len(s)) + } + + // ── Locate variable sections ───────────────────────────────────────── + // Offset after all string data, 4-byte aligned. + align4 := func(n uint32) uint32 { return (n + 3) &^ 3 } + afterStrings := align4(cursor) + + // Stages: each Stage is u32 stageID + 12 bytes padding = 16 bytes. + loadedStagesPtr := afterStrings + stagesSize := uint32(len(q.Stages)) * 16 + afterStages := align4(loadedStagesPtr + stagesSize) + // unk34 (fixedCoords1Ptr) terminates the stages loop in the hexpat. + unk34Ptr := afterStages + + // Supply box: main=24×4, subA=8×4, subB=8×4 = 160 bytes total. + supplyBoxPtr := afterStages + const supplyBoxSize = (24 + 8 + 8) * 4 + afterSupply := align4(supplyBoxPtr + supplyBoxSize) + + // Reward tables: compute size. + rewardPtr := afterSupply + rewardBuf := buildRewardTables(q.Rewards) + afterRewards := align4(rewardPtr + uint32(len(rewardBuf))) + + // Large monster spawns: each is 60 bytes + 1-byte terminator. + largeMonsterPtr := afterRewards + monsterBuf := buildMonsterSpawns(q.LargeMonsters) + + // ── Assemble file ──────────────────────────────────────────────────── + out := &bytes.Buffer{} + + // ── Header (68 bytes) ──────────────────────────────────────────────── + writeUint32LE(out, questTypeFlagsPtr) // 0x00 questTypeFlagsPtr + writeUint32LE(out, loadedStagesPtr) // 0x04 loadedStagesPtr + writeUint32LE(out, supplyBoxPtr) // 0x08 supplyBoxPtr + writeUint32LE(out, rewardPtr) // 0x0C rewardPtr + writeUint16LE(out, 0) // 0x10 subSupplyBoxPtr (unused) + out.WriteByte(0) // 0x12 hidden + out.WriteByte(0) // 0x13 subSupplyBoxLen + writeUint32LE(out, 0) // 0x14 questAreaPtr (null) + writeUint32LE(out, largeMonsterPtr) // 0x18 largeMonsterPtr + writeUint32LE(out, 0) // 0x1C areaTransitionsPtr (null) + writeUint32LE(out, 0) // 0x20 areaMappingPtr (null) + writeUint32LE(out, 0) // 0x24 mapInfoPtr (null) + writeUint32LE(out, 0) // 0x28 gatheringPointsPtr (null) + writeUint32LE(out, 0) // 0x2C areaFacilitiesPtr (null) + writeUint32LE(out, 0) // 0x30 someStringsPtr (null) + writeUint32LE(out, unk34Ptr) // 0x34 fixedCoords1Ptr (stages end) + writeUint32LE(out, 0) // 0x38 gatheringTablesPtr (null) + writeUint32LE(out, 0) // 0x3C fixedCoords2Ptr (null) + writeUint32LE(out, 0) // 0x40 fixedInfoPtr (null) + + if out.Len() != headerSize { + return nil, fmt.Errorf("header size mismatch: got %d want %d", out.Len(), headerSize) + } + + // ── General Quest Properties (66 bytes, 0x44–0x85) ────────────────── + writeUint16LE(out, q.MonsterSizeMulti) // 0x44 monsterSizeMulti + writeUint16LE(out, q.SizeRange) // 0x46 sizeRange + writeUint32LE(out, q.StatTable1) // 0x48 statTable1 + writeUint32LE(out, q.MainRankPoints) // 0x4C mainRankPoints + writeUint32LE(out, 0) // 0x50 unknown + writeUint32LE(out, q.SubARankPoints) // 0x54 subARankPoints + writeUint32LE(out, q.SubBRankPoints) // 0x58 subBRankPoints + writeUint32LE(out, 0) // 0x5C questTypeID / unknown + out.WriteByte(0) // 0x60 padding + out.WriteByte(q.StatTable2) // 0x61 statTable2 + pad(out, 0x11) // 0x62–0x72 padding + out.WriteByte(0) // 0x73 questKn1 + writeUint16LE(out, 0) // 0x74 questKn2 + writeUint16LE(out, 0) // 0x76 questKn3 + writeUint16LE(out, 0) // 0x78 gatheringTablesQty + writeUint16LE(out, 0) // 0x7A unknown + out.WriteByte(0) // 0x7C area1Zones + out.WriteByte(0) // 0x7D area2Zones + out.WriteByte(0) // 0x7E area3Zones + out.WriteByte(0) // 0x7F area4Zones + writeUint16LE(out, 0) // 0x80 unknown + writeUint16LE(out, 0) // 0x82 unknown + writeUint16LE(out, 0) // 0x84 unknown + + if out.Len() != headerSize+genPropSize { + return nil, fmt.Errorf("genProp size mismatch: got %d want %d", out.Len(), headerSize+genPropSize) + } + + // ── Main Quest Properties (320 bytes, 0x86–0x1C5) ─────────────────── + // Matches mainQuestProperties struct in questfile.bin.hexpat. + mainStart := out.Len() + out.WriteByte(0) // +0x00 unknown + out.WriteByte(0) // +0x01 musicMode + out.WriteByte(0) // +0x02 localeFlags + out.WriteByte(0) // +0x03 unknown + out.WriteByte(0) // +0x04 rankingID + out.WriteByte(0) // +0x05 unknown + writeUint16LE(out, 0) // +0x06 unknown + writeUint16LE(out, q.RankBand) // +0x08 rankBand + writeUint16LE(out, 0) // +0x0A questTypeID + writeUint32LE(out, q.Fee) // +0x0C questFee + writeUint32LE(out, q.RewardMain) // +0x10 rewardMain + writeUint32LE(out, 0) // +0x14 cartsOrReduction + writeUint16LE(out, q.RewardSubA) // +0x18 rewardA + writeUint16LE(out, 0) // +0x1A padding + writeUint16LE(out, q.RewardSubB) // +0x1C rewardB + writeUint16LE(out, q.HardHRReq) // +0x1E hardHRReq + writeUint32LE(out, q.TimeLimitMinutes*60*30) // +0x20 questTime (frames at 30Hz) + writeUint32LE(out, q.Map) // +0x24 questMap + writeUint32LE(out, questStringsTablePtr) // +0x28 questStringsPtr + writeUint16LE(out, 0) // +0x2C unknown + writeUint16LE(out, q.QuestID) // +0x2E questID + + // +0x30 objectives[3] (8 bytes each) + for _, obj := range []QuestObjectiveJSON{q.ObjectiveMain, q.ObjectiveSubA, q.ObjectiveSubB} { + b, err := objectiveBytes(obj) + if err != nil { + return nil, err + } + out.Write(b) + } + + // +0x48 post-objectives fields + out.WriteByte(0) // +0x48 unknown + out.WriteByte(0) // +0x49 unknown + writeUint16LE(out, 0) // +0x4A padding + writeUint16LE(out, q.JoinRankMin) // +0x4C joinRankMin + writeUint16LE(out, q.JoinRankMax) // +0x4E joinRankMax + writeUint16LE(out, q.PostRankMin) // +0x50 postRankMin + writeUint16LE(out, q.PostRankMax) // +0x52 postRankMax + pad(out, 8) // +0x54 padding[8] + + // +0x5C forced equipment (6 slots × 4 u16 = 48 bytes) + eq := q.ForcedEquipment + if eq == nil { + eq = &QuestForcedEquipJSON{} + } + for _, slot := range [][4]uint16{eq.Legs, eq.Weapon, eq.Head, eq.Chest, eq.Arms, eq.Waist} { + for _, v := range slot { + writeUint16LE(out, v) + } + } + + // +0x8C unknown u32 + writeUint32LE(out, 0) + + // +0x90 monster variants[3] + mapVariant + out.WriteByte(0) // monsterVariants[0] + out.WriteByte(0) // monsterVariants[1] + out.WriteByte(0) // monsterVariants[2] + out.WriteByte(0) // mapVariant + + // +0x94 requiredItemType (ItemID = u16), requiredItemCount + writeUint16LE(out, 0) + out.WriteByte(0) // requiredItemCount + + // +0x97 questVariants + out.WriteByte(q.QuestVariant1) + out.WriteByte(q.QuestVariant2) + out.WriteByte(q.QuestVariant3) + out.WriteByte(q.QuestVariant4) + + // +0x9B padding[5] + pad(out, 5) + + // +0xA0 allowedEquipBitmask, points + writeUint32LE(out, 0) // allowedEquipBitmask + writeUint32LE(out, 0) // mainPoints + writeUint32LE(out, 0) // subAPoints + writeUint32LE(out, 0) // subBPoints + + // +0xB0 rewardItems[3] (ItemID = u16, 3 items = 6 bytes) + pad(out, 6) + + // +0xB6 interception section (non-SlayAll path: padding[3] + MonsterID[1] = 4 bytes) + pad(out, 4) + + // +0xBA padding[0xA] = 10 bytes + pad(out, 10) + + // +0xC4 questClearsAllowed + writeUint32LE(out, 0) + + // +0xC8 = 200 bytes so far for documented fields. ZZ body = 320 bytes. + // Zero-pad the remaining unknown ZZ-specific fields. + writtenInMain := out.Len() - mainStart + if writtenInMain < mainPropSize { + pad(out, mainPropSize-writtenInMain) + } else if writtenInMain > mainPropSize { + return nil, fmt.Errorf("mainQuestProperties overflowed: wrote %d, max %d", writtenInMain, mainPropSize) + } + + if out.Len() != int(questTypeFlagsPtr)+mainPropSize { + return nil, fmt.Errorf("main prop end mismatch: at %d, want %d", out.Len(), int(questTypeFlagsPtr)+mainPropSize) + } + + // ── QuestText pointer table (32 bytes) ─────────────────────────────── + for _, ptr := range stringPtrs { + writeUint32LE(out, ptr) + } + + // ── String data ────────────────────────────────────────────────────── + for _, s := range sjisStrings { + out.Write(s) + } + + // Pad to afterStrings alignment. + for uint32(out.Len()) < afterStrings { + out.WriteByte(0) + } + + // ── Stages ─────────────────────────────────────────────────────────── + // Each Stage: u32 stageID + 12 bytes padding = 16 bytes. + for _, st := range q.Stages { + writeUint32LE(out, st.StageID) + pad(out, 12) + } + for uint32(out.Len()) < afterStages { + out.WriteByte(0) + } + + // ── Supply Box ─────────────────────────────────────────────────────── + // Three sections: main (24 slots), subA (8 slots), subB (8 slots). + type slot struct { + items []QuestSupplyItemJSON + max int + } + for _, section := range []slot{ + {q.SupplyMain, 24}, + {q.SupplySubA, 8}, + {q.SupplySubB, 8}, + } { + written := 0 + for _, item := range section.items { + if written >= section.max { + break + } + writeUint16LE(out, item.Item) + writeUint16LE(out, item.Quantity) + written++ + } + // Pad remaining slots with zeros. + for written < section.max { + writeUint32LE(out, 0) + written++ + } + } + + // ── Reward Tables ──────────────────────────────────────────────────── + // Written immediately after the supply box (at rewardPtr), then padded + // to 4-byte alignment before the monster spawn list. + out.Write(rewardBuf) + for uint32(out.Len()) < largeMonsterPtr { + out.WriteByte(0) + } + + // ── Large Monster Spawns ───────────────────────────────────────────── + out.Write(monsterBuf) + + return out.Bytes(), nil +} + +// buildRewardTables serialises the reward table array and all reward item lists. +// Layout per hexpat: +// +// RewardTable[] { u8 tableId, u8 pad, u16 pad, u32 tableOffset } terminated by int16(-1) +// RewardItem[] { u16 rate, u16 item, u16 quantity } terminated by int16(-1) +func buildRewardTables(tables []QuestRewardTableJSON) []byte { + if len(tables) == 0 { + // Empty: just the terminator. + b := [2]byte{0xFF, 0xFF} + return b[:] + } + + headers := &bytes.Buffer{} + itemData := &bytes.Buffer{} + + // Header array size = len(tables) × 8 bytes + 2-byte terminator. + headerArraySize := uint32(len(tables)*8 + 2) + + for _, t := range tables { + // tableOffset is relative to the start of rewardPtr in the file. + // We compute it as headerArraySize + offset into itemData. + tableOffset := headerArraySize + uint32(itemData.Len()) + + headers.WriteByte(t.TableID) + headers.WriteByte(0) // padding + writeUint16LE(headers, 0) // padding + writeUint32LE(headers, tableOffset) + + for _, item := range t.Items { + writeUint16LE(itemData, item.Rate) + writeUint16LE(itemData, item.Item) + writeUint16LE(itemData, item.Quantity) + } + // Terminate this table's item list with -1. + writeUint16LE(itemData, 0xFFFF) + } + // Terminate the table header array. + writeUint16LE(headers, 0xFFFF) + + return append(headers.Bytes(), itemData.Bytes()...) +} + +// buildMonsterSpawns serialises the large monster spawn list. +// Each entry is 60 bytes; terminated with a 0xFF byte. +func buildMonsterSpawns(monsters []QuestMonsterJSON) []byte { + buf := &bytes.Buffer{} + for _, m := range monsters { + buf.WriteByte(m.ID) + pad(buf, 3) // +0x01 padding[3] + writeUint32LE(buf, m.SpawnAmount) // +0x04 + writeUint32LE(buf, m.SpawnStage) // +0x08 + pad(buf, 16) // +0x0C padding[0x10] + writeUint32LE(buf, m.Orientation) // +0x1C + writeFloat32LE(buf, m.X) // +0x20 + writeFloat32LE(buf, m.Y) // +0x24 + writeFloat32LE(buf, m.Z) // +0x28 + pad(buf, 16) // +0x2C padding[0x10] + } + buf.WriteByte(0xFF) // terminator + return buf.Bytes() +} diff --git a/server/channelserver/quest_json_parser.go b/server/channelserver/quest_json_parser.go new file mode 100644 index 000000000..417e67528 --- /dev/null +++ b/server/channelserver/quest_json_parser.go @@ -0,0 +1,415 @@ +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 +} diff --git a/server/channelserver/quest_json_test.go b/server/channelserver/quest_json_test.go new file mode 100644 index 000000000..1446f9917 --- /dev/null +++ b/server/channelserver/quest_json_test.go @@ -0,0 +1,877 @@ +package channelserver + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "math" + "testing" +) + +// minimalQuestJSON is a small but complete quest used across many test cases. +var minimalQuestJSON = `{ + "quest_id": 1, + "title": "Test Quest", + "description": "A test quest.", + "text_main": "Hunt the Rathalos.", + "text_sub_a": "", + "text_sub_b": "", + "success_cond": "Slay the Rathalos.", + "fail_cond": "Time runs out or all hunters faint.", + "contractor": "Guild Master", + "monster_size_multi": 100, + "stat_table_1": 0, + "main_rank_points": 120, + "sub_a_rank_points": 60, + "sub_b_rank_points": 0, + "fee": 500, + "reward_main": 5000, + "reward_sub_a": 1000, + "reward_sub_b": 0, + "time_limit_minutes": 50, + "map": 2, + "rank_band": 0, + "objective_main": {"type": "hunt", "target": 11, "count": 1}, + "objective_sub_a": {"type": "deliver", "target": 149, "count": 3}, + "objective_sub_b": {"type": "none"}, + "large_monsters": [ + {"id": 11, "spawn_amount": 1, "spawn_stage": 5, "orientation": 180, "x": 1500.0, "y": 0.0, "z": -2000.0} + ], + "rewards": [ + { + "table_id": 1, + "items": [ + {"rate": 50, "item": 149, "quantity": 1}, + {"rate": 30, "item": 153, "quantity": 1} + ] + } + ], + "supply_main": [ + {"item": 1, "quantity": 5} + ], + "stages": [ + {"stage_id": 2} + ] +}` + +// ── Compiler tests (existing) ──────────────────────────────────────────────── + +func TestCompileQuestJSON_MinimalQuest(t *testing.T) { + data, err := CompileQuestJSON([]byte(minimalQuestJSON)) + if err != nil { + t.Fatalf("CompileQuestJSON: %v", err) + } + if len(data) == 0 { + t.Fatal("empty output") + } + + // Header check: first pointer (questTypeFlagsPtr) must equal headerSize+genPropSize = 0x86 + questTypeFlagsPtr := binary.LittleEndian.Uint32(data[0:4]) + const expectedBodyStart = uint32(68 + 66) // 0x86 + if questTypeFlagsPtr != expectedBodyStart { + t.Errorf("questTypeFlagsPtr = 0x%X, want 0x%X", questTypeFlagsPtr, expectedBodyStart) + } + + // QuestStringsPtr (mainQuestProperties+40) must point past the body. + questStringsPtr := binary.LittleEndian.Uint32(data[questTypeFlagsPtr+40 : questTypeFlagsPtr+44]) + if questStringsPtr < questTypeFlagsPtr+questBodyLenZZ { + t.Errorf("questStringsPtr 0x%X is inside main body (ends at 0x%X)", questStringsPtr, questTypeFlagsPtr+questBodyLenZZ) + } + + // QuestStringsPtr must be within the file. + if int(questStringsPtr) >= len(data) { + t.Errorf("questStringsPtr 0x%X out of range (file len %d)", questStringsPtr, len(data)) + } + + // The quest text pointer table: 8 string pointers, all within the file. + for i := 0; i < 8; i++ { + off := int(questStringsPtr) + i*4 + if off+4 > len(data) { + t.Fatalf("string pointer %d out of bounds", i) + } + strPtr := binary.LittleEndian.Uint32(data[off : off+4]) + if int(strPtr) >= len(data) { + t.Errorf("string pointer %d = 0x%X out of file range (%d bytes)", i, strPtr, len(data)) + } + } + + // QuestID at mainQuestProperties+0x2E. + questID := binary.LittleEndian.Uint16(data[questTypeFlagsPtr+0x2E : questTypeFlagsPtr+0x30]) + if questID != 1 { + t.Errorf("questID = %d, want 1", questID) + } + + // QuestTime at mainQuestProperties+0x20: 50 minutes × 60s × 30Hz = 90000 frames. + questTime := binary.LittleEndian.Uint32(data[questTypeFlagsPtr+0x20 : questTypeFlagsPtr+0x24]) + if questTime != 90000 { + t.Errorf("questTime = %d frames, want 90000 (50min)", questTime) + } +} + +func TestCompileQuestJSON_BadObjectiveType(t *testing.T) { + var q QuestJSON + _ = json.Unmarshal([]byte(minimalQuestJSON), &q) + q.ObjectiveMain.Type = "invalid_type" + b, _ := json.Marshal(q) + + _, err := CompileQuestJSON(b) + if err == nil { + t.Fatal("expected error for invalid objective type, got nil") + } +} + +func TestCompileQuestJSON_AllObjectiveTypes(t *testing.T) { + types := []string{ + "none", "hunt", "capture", "slay", "deliver", "deliver_flag", + "break_part", "damage", "slay_or_damage", "slay_total", "slay_all", "esoteric", + } + for _, typ := range types { + t.Run(typ, func(t *testing.T) { + var q QuestJSON + _ = json.Unmarshal([]byte(minimalQuestJSON), &q) + q.ObjectiveMain.Type = typ + b, _ := json.Marshal(q) + if _, err := CompileQuestJSON(b); err != nil { + t.Fatalf("CompileQuestJSON with type %q: %v", typ, err) + } + }) + } +} + +func TestCompileQuestJSON_EmptyRewards(t *testing.T) { + var q QuestJSON + _ = json.Unmarshal([]byte(minimalQuestJSON), &q) + q.Rewards = nil + b, _ := json.Marshal(q) + if _, err := CompileQuestJSON(b); err != nil { + t.Fatalf("unexpected error with no rewards: %v", err) + } +} + +func TestCompileQuestJSON_MultipleRewardTables(t *testing.T) { + var q QuestJSON + _ = json.Unmarshal([]byte(minimalQuestJSON), &q) + q.Rewards = []QuestRewardTableJSON{ + {TableID: 1, Items: []QuestRewardItemJSON{{Rate: 50, Item: 149, Quantity: 1}}}, + {TableID: 2, Items: []QuestRewardItemJSON{{Rate: 100, Item: 153, Quantity: 2}}}, + } + b, _ := json.Marshal(q) + data, err := CompileQuestJSON(b) + if err != nil { + t.Fatalf("CompileQuestJSON: %v", err) + } + + // Verify reward pointer points into the file. + rewardPtr := binary.LittleEndian.Uint32(data[0x0C:0x10]) + if int(rewardPtr) >= len(data) { + t.Errorf("rewardPtr 0x%X out of file range (%d)", rewardPtr, len(data)) + } +} + +// ── Parser tests ───────────────────────────────────────────────────────────── + +func TestParseQuestBinary_TooShort(t *testing.T) { + _, err := ParseQuestBinary([]byte{0x01, 0x02}) + if err == nil { + t.Fatal("expected error for undersized input, got nil") + } +} + +func TestParseQuestBinary_NullQuestTypeFlagsPtr(t *testing.T) { + // Build a buffer that is long enough but has a null questTypeFlagsPtr. + buf := make([]byte, 0x200) + // questTypeFlagsPtr at 0x00 = 0 (null) + binary.LittleEndian.PutUint32(buf[0x00:], 0) + _, err := ParseQuestBinary(buf) + if err == nil { + t.Fatal("expected error for null questTypeFlagsPtr, got nil") + } +} + +func TestParseQuestBinary_MinimalQuest(t *testing.T) { + data, err := CompileQuestJSON([]byte(minimalQuestJSON)) + if err != nil { + t.Fatalf("compile: %v", err) + } + + q, err := ParseQuestBinary(data) + if err != nil { + t.Fatalf("parse: %v", err) + } + + // Identification + if q.QuestID != 1 { + t.Errorf("QuestID = %d, want 1", q.QuestID) + } + + // Text strings + if q.Title != "Test Quest" { + t.Errorf("Title = %q, want %q", q.Title, "Test Quest") + } + if q.Description != "A test quest." { + t.Errorf("Description = %q, want %q", q.Description, "A test quest.") + } + if q.TextMain != "Hunt the Rathalos." { + t.Errorf("TextMain = %q, want %q", q.TextMain, "Hunt the Rathalos.") + } + if q.SuccessCond != "Slay the Rathalos." { + t.Errorf("SuccessCond = %q, want %q", q.SuccessCond, "Slay the Rathalos.") + } + if q.FailCond != "Time runs out or all hunters faint." { + t.Errorf("FailCond = %q, want %q", q.FailCond, "Time runs out or all hunters faint.") + } + if q.Contractor != "Guild Master" { + t.Errorf("Contractor = %q, want %q", q.Contractor, "Guild Master") + } + + // Numeric fields + if q.MonsterSizeMulti != 100 { + t.Errorf("MonsterSizeMulti = %d, want 100", q.MonsterSizeMulti) + } + if q.MainRankPoints != 120 { + t.Errorf("MainRankPoints = %d, want 120", q.MainRankPoints) + } + if q.SubARankPoints != 60 { + t.Errorf("SubARankPoints = %d, want 60", q.SubARankPoints) + } + if q.SubBRankPoints != 0 { + t.Errorf("SubBRankPoints = %d, want 0", q.SubBRankPoints) + } + if q.Fee != 500 { + t.Errorf("Fee = %d, want 500", q.Fee) + } + if q.RewardMain != 5000 { + t.Errorf("RewardMain = %d, want 5000", q.RewardMain) + } + if q.RewardSubA != 1000 { + t.Errorf("RewardSubA = %d, want 1000", q.RewardSubA) + } + if q.TimeLimitMinutes != 50 { + t.Errorf("TimeLimitMinutes = %d, want 50", q.TimeLimitMinutes) + } + if q.Map != 2 { + t.Errorf("Map = %d, want 2", q.Map) + } + + // Objectives + if q.ObjectiveMain.Type != "hunt" { + t.Errorf("ObjectiveMain.Type = %q, want hunt", q.ObjectiveMain.Type) + } + if q.ObjectiveMain.Target != 11 { + t.Errorf("ObjectiveMain.Target = %d, want 11", q.ObjectiveMain.Target) + } + if q.ObjectiveMain.Count != 1 { + t.Errorf("ObjectiveMain.Count = %d, want 1", q.ObjectiveMain.Count) + } + if q.ObjectiveSubA.Type != "deliver" { + t.Errorf("ObjectiveSubA.Type = %q, want deliver", q.ObjectiveSubA.Type) + } + if q.ObjectiveSubA.Target != 149 { + t.Errorf("ObjectiveSubA.Target = %d, want 149", q.ObjectiveSubA.Target) + } + if q.ObjectiveSubA.Count != 3 { + t.Errorf("ObjectiveSubA.Count = %d, want 3", q.ObjectiveSubA.Count) + } + if q.ObjectiveSubB.Type != "none" { + t.Errorf("ObjectiveSubB.Type = %q, want none", q.ObjectiveSubB.Type) + } + + // Stages + if len(q.Stages) != 1 { + t.Fatalf("Stages len = %d, want 1", len(q.Stages)) + } + if q.Stages[0].StageID != 2 { + t.Errorf("Stages[0].StageID = %d, want 2", q.Stages[0].StageID) + } + + // Supply box + if len(q.SupplyMain) != 1 { + t.Fatalf("SupplyMain len = %d, want 1", len(q.SupplyMain)) + } + if q.SupplyMain[0].Item != 1 || q.SupplyMain[0].Quantity != 5 { + t.Errorf("SupplyMain[0] = {%d, %d}, want {1, 5}", q.SupplyMain[0].Item, q.SupplyMain[0].Quantity) + } + if len(q.SupplySubA) != 0 { + t.Errorf("SupplySubA len = %d, want 0", len(q.SupplySubA)) + } + + // Rewards + if len(q.Rewards) != 1 { + t.Fatalf("Rewards len = %d, want 1", len(q.Rewards)) + } + rt := q.Rewards[0] + if rt.TableID != 1 { + t.Errorf("Rewards[0].TableID = %d, want 1", rt.TableID) + } + if len(rt.Items) != 2 { + t.Fatalf("Rewards[0].Items len = %d, want 2", len(rt.Items)) + } + if rt.Items[0].Rate != 50 || rt.Items[0].Item != 149 || rt.Items[0].Quantity != 1 { + t.Errorf("Rewards[0].Items[0] = %+v, want {50 149 1}", rt.Items[0]) + } + if rt.Items[1].Rate != 30 || rt.Items[1].Item != 153 || rt.Items[1].Quantity != 1 { + t.Errorf("Rewards[0].Items[1] = %+v, want {30 153 1}", rt.Items[1]) + } + + // Large monsters + if len(q.LargeMonsters) != 1 { + t.Fatalf("LargeMonsters len = %d, want 1", len(q.LargeMonsters)) + } + m := q.LargeMonsters[0] + if m.ID != 11 { + t.Errorf("LargeMonsters[0].ID = %d, want 11", m.ID) + } + if m.SpawnAmount != 1 { + t.Errorf("LargeMonsters[0].SpawnAmount = %d, want 1", m.SpawnAmount) + } + if m.SpawnStage != 5 { + t.Errorf("LargeMonsters[0].SpawnStage = %d, want 5", m.SpawnStage) + } + if m.Orientation != 180 { + t.Errorf("LargeMonsters[0].Orientation = %d, want 180", m.Orientation) + } + if m.X != 1500.0 { + t.Errorf("LargeMonsters[0].X = %v, want 1500.0", m.X) + } + if m.Y != 0.0 { + t.Errorf("LargeMonsters[0].Y = %v, want 0.0", m.Y) + } + if m.Z != -2000.0 { + t.Errorf("LargeMonsters[0].Z = %v, want -2000.0", m.Z) + } +} + +// ── Round-trip tests ───────────────────────────────────────────────────────── + +// roundTrip compiles JSON → binary, parses back to QuestJSON, re-serializes +// to JSON, compiles again, and asserts the two binaries are byte-for-byte equal. +func roundTrip(t *testing.T, label, jsonSrc string) { + t.Helper() + + bin1, err := CompileQuestJSON([]byte(jsonSrc)) + if err != nil { + t.Fatalf("%s: compile(1): %v", label, err) + } + + q, err := ParseQuestBinary(bin1) + if err != nil { + t.Fatalf("%s: parse: %v", label, err) + } + + jsonOut, err := json.Marshal(q) + if err != nil { + t.Fatalf("%s: marshal: %v", label, err) + } + + bin2, err := CompileQuestJSON(jsonOut) + if err != nil { + t.Fatalf("%s: compile(2): %v", label, err) + } + + if !bytes.Equal(bin1, bin2) { + t.Errorf("%s: round-trip binary mismatch (bin1 len=%d, bin2 len=%d)", label, len(bin1), len(bin2)) + // Find first differing byte to aid debugging. + limit := len(bin1) + if len(bin2) < limit { + limit = len(bin2) + } + for i := 0; i < limit; i++ { + if bin1[i] != bin2[i] { + t.Errorf(" first diff at offset 0x%X: bin1=0x%02X bin2=0x%02X", i, bin1[i], bin2[i]) + break + } + } + } +} + +func TestRoundTrip_MinimalQuest(t *testing.T) { + roundTrip(t, "minimal", minimalQuestJSON) +} + +func TestRoundTrip_NoRewards(t *testing.T) { + var q QuestJSON + _ = json.Unmarshal([]byte(minimalQuestJSON), &q) + q.Rewards = nil + b, _ := json.Marshal(q) + roundTrip(t, "no rewards", string(b)) +} + +func TestRoundTrip_NoMonsters(t *testing.T) { + var q QuestJSON + _ = json.Unmarshal([]byte(minimalQuestJSON), &q) + q.LargeMonsters = nil + b, _ := json.Marshal(q) + roundTrip(t, "no monsters", string(b)) +} + +func TestRoundTrip_NoStages(t *testing.T) { + var q QuestJSON + _ = json.Unmarshal([]byte(minimalQuestJSON), &q) + q.Stages = nil + b, _ := json.Marshal(q) + roundTrip(t, "no stages", string(b)) +} + +func TestRoundTrip_MultipleStages(t *testing.T) { + var q QuestJSON + _ = json.Unmarshal([]byte(minimalQuestJSON), &q) + q.Stages = []QuestStageJSON{{StageID: 2}, {StageID: 5}, {StageID: 11}} + b, _ := json.Marshal(q) + roundTrip(t, "multiple stages", string(b)) +} + +func TestRoundTrip_MultipleMonsters(t *testing.T) { + var q QuestJSON + _ = json.Unmarshal([]byte(minimalQuestJSON), &q) + q.LargeMonsters = []QuestMonsterJSON{ + {ID: 11, SpawnAmount: 1, SpawnStage: 5, Orientation: 180, X: 1500.0, Y: 0.0, Z: -2000.0}, + {ID: 37, SpawnAmount: 2, SpawnStage: 3, Orientation: 90, X: 0.0, Y: 50.0, Z: 300.0}, + } + b, _ := json.Marshal(q) + roundTrip(t, "multiple monsters", string(b)) +} + +func TestRoundTrip_MultipleRewardTables(t *testing.T) { + var q QuestJSON + _ = json.Unmarshal([]byte(minimalQuestJSON), &q) + q.Rewards = []QuestRewardTableJSON{ + {TableID: 1, Items: []QuestRewardItemJSON{ + {Rate: 50, Item: 149, Quantity: 1}, + {Rate: 50, Item: 153, Quantity: 2}, + }}, + {TableID: 2, Items: []QuestRewardItemJSON{ + {Rate: 100, Item: 200, Quantity: 3}, + }}, + } + b, _ := json.Marshal(q) + roundTrip(t, "multiple reward tables", string(b)) +} + +func TestRoundTrip_FullSupplyBox(t *testing.T) { + var q QuestJSON + _ = json.Unmarshal([]byte(minimalQuestJSON), &q) + // Fill supply box to capacity: 24 main + 8 subA + 8 subB. + q.SupplyMain = make([]QuestSupplyItemJSON, 24) + for i := range q.SupplyMain { + q.SupplyMain[i] = QuestSupplyItemJSON{Item: uint16(i + 1), Quantity: uint16(i + 1)} + } + q.SupplySubA = []QuestSupplyItemJSON{{Item: 10, Quantity: 2}, {Item: 20, Quantity: 1}} + q.SupplySubB = []QuestSupplyItemJSON{{Item: 30, Quantity: 5}} + b, _ := json.Marshal(q) + roundTrip(t, "full supply box", string(b)) +} + +func TestRoundTrip_BreakPartObjective(t *testing.T) { + var q QuestJSON + _ = json.Unmarshal([]byte(minimalQuestJSON), &q) + q.ObjectiveMain = QuestObjectiveJSON{Type: "break_part", Target: 11, Part: 3} + b, _ := json.Marshal(q) + roundTrip(t, "break_part objective", string(b)) +} + +func TestRoundTrip_AllObjectiveTypes(t *testing.T) { + types := []string{ + "none", "hunt", "capture", "slay", "deliver", "deliver_flag", + "break_part", "damage", "slay_or_damage", "slay_total", "slay_all", "esoteric", + } + for _, typ := range types { + t.Run(typ, func(t *testing.T) { + var q QuestJSON + _ = json.Unmarshal([]byte(minimalQuestJSON), &q) + q.ObjectiveMain = QuestObjectiveJSON{Type: typ, Target: 11, Count: 1} + b, _ := json.Marshal(q) + roundTrip(t, typ, string(b)) + }) + } +} + +func TestRoundTrip_RankFields(t *testing.T) { + var q QuestJSON + _ = json.Unmarshal([]byte(minimalQuestJSON), &q) + q.RankBand = 7 + q.HardHRReq = 300 + q.JoinRankMin = 100 + q.JoinRankMax = 999 + q.PostRankMin = 50 + q.PostRankMax = 500 + b, _ := json.Marshal(q) + roundTrip(t, "rank fields", string(b)) +} + +func TestRoundTrip_QuestVariants(t *testing.T) { + var q QuestJSON + _ = json.Unmarshal([]byte(minimalQuestJSON), &q) + q.QuestVariant1 = 1 + q.QuestVariant2 = 2 + q.QuestVariant3 = 4 + q.QuestVariant4 = 8 + b, _ := json.Marshal(q) + roundTrip(t, "quest variants", string(b)) +} + +func TestRoundTrip_EmptyQuest(t *testing.T) { + q := QuestJSON{ + QuestID: 999, + TimeLimitMinutes: 30, + MonsterSizeMulti: 100, + ObjectiveMain: QuestObjectiveJSON{Type: "slay_all"}, + } + b, _ := json.Marshal(q) + roundTrip(t, "empty quest", string(b)) +} + +// ── Golden file test ───────────────────────────────────────────────────────── +// +// This test manually constructs expected binary bytes at specific offsets and +// verifies the compiler produces them exactly for minimalQuestJSON. +// Hard-coded values are derived from the documented binary layout. +// +// Layout constants for minimalQuestJSON: +// headerSize = 68 (0x44) +// genPropSize = 66 (0x42) +// mainPropOffset = 0x86 (= headerSize + genPropSize) +// questStringsPtr = 0x1C6 (= mainPropOffset + 320) + +func TestGolden_MinimalQuestBinaryLayout(t *testing.T) { + data, err := CompileQuestJSON([]byte(minimalQuestJSON)) + if err != nil { + t.Fatalf("compile: %v", err) + } + + const ( + mainPropOffset = 0x86 + questStringsPtr = uint32(mainPropOffset + questBodyLenZZ) // 0x1C6 + ) + + // ── Header (0x00–0x43) ─────────────────────────────────────────────── + assertU32(t, data, 0x00, mainPropOffset, "questTypeFlagsPtr") + // loadedStagesPtr, supplyBoxPtr, rewardPtr, largeMonsterPtr are computed + // offsets we don't hard-code here — they are verified by the round-trip + // tests and the structural checks below. + assertU16(t, data, 0x10, 0, "subSupplyBoxPtr (unused)") + assertByte(t, data, 0x12, 0, "hidden") + assertByte(t, data, 0x13, 0, "subSupplyBoxLen") + assertU32(t, data, 0x14, 0, "questAreaPtr (null)") + assertU32(t, data, 0x1C, 0, "areaTransitionsPtr (null)") + assertU32(t, data, 0x20, 0, "areaMappingPtr (null)") + assertU32(t, data, 0x24, 0, "mapInfoPtr (null)") + assertU32(t, data, 0x28, 0, "gatheringPointsPtr (null)") + assertU32(t, data, 0x2C, 0, "areaFacilitiesPtr (null)") + assertU32(t, data, 0x30, 0, "someStringsPtr (null)") + assertU32(t, data, 0x38, 0, "gatheringTablesPtr (null)") + assertU32(t, data, 0x3C, 0, "fixedCoords2Ptr (null)") + assertU32(t, data, 0x40, 0, "fixedInfoPtr (null)") + + // loadedStagesPtr and unk34Ptr must be equal (no stages would mean stagesPtr + // points past itself — but we have 1 stage, so unk34 = loadedStagesPtr+16). + loadedStagesPtr := binary.LittleEndian.Uint32(data[0x04:]) + unk34Ptr := binary.LittleEndian.Uint32(data[0x34:]) + if unk34Ptr != loadedStagesPtr+16 { + t.Errorf("unk34Ptr 0x%X != loadedStagesPtr+16 (0x%X); expected exactly 1 stage × 16 bytes", + unk34Ptr, loadedStagesPtr+16) + } + + // ── General Quest Properties (0x44–0x85) ──────────────────────────── + assertU16(t, data, 0x44, 100, "monsterSizeMulti") + assertU16(t, data, 0x46, 0, "sizeRange") + assertU32(t, data, 0x48, 0, "statTable1") + assertU32(t, data, 0x4C, 120, "mainRankPoints") + assertU32(t, data, 0x50, 0, "unknown@0x50") + assertU32(t, data, 0x54, 60, "subARankPoints") + assertU32(t, data, 0x58, 0, "subBRankPoints") + assertU32(t, data, 0x5C, 0, "questTypeID@0x5C") + assertByte(t, data, 0x60, 0, "padding@0x60") + assertByte(t, data, 0x61, 0, "statTable2") + // 0x62–0x72: padding (17 bytes of zeros) + for i := 0x62; i <= 0x72; i++ { + assertByte(t, data, i, 0, "padding") + } + assertByte(t, data, 0x73, 0, "questKn1") + assertU16(t, data, 0x74, 0, "questKn2") + assertU16(t, data, 0x76, 0, "questKn3") + assertU16(t, data, 0x78, 0, "gatheringTablesQty") + assertByte(t, data, 0x7C, 0, "area1Zones") + assertByte(t, data, 0x7D, 0, "area2Zones") + assertByte(t, data, 0x7E, 0, "area3Zones") + assertByte(t, data, 0x7F, 0, "area4Zones") + + // ── Main Quest Properties (0x86–0x1C5) ────────────────────────────── + mp := mainPropOffset + assertByte(t, data, mp+0x00, 0, "mp.unknown@+0x00") + assertByte(t, data, mp+0x01, 0, "mp.musicMode") + assertByte(t, data, mp+0x02, 0, "mp.localeFlags") + assertByte(t, data, mp+0x08, 0, "mp.rankBand lo") // rankBand = 0 + assertByte(t, data, mp+0x09, 0, "mp.rankBand hi") + // questFee = 500 → LE bytes: 0xF4 0x01 0x00 0x00 + assertU32(t, data, mp+0x0C, 500, "mp.questFee") + // rewardMain = 5000 → LE: 0x88 0x13 0x00 0x00 + assertU32(t, data, mp+0x10, 5000, "mp.rewardMain") + assertU32(t, data, mp+0x14, 0, "mp.cartsOrReduction") + // rewardA = 1000 → LE: 0xE8 0x03 + assertU16(t, data, mp+0x18, 1000, "mp.rewardA") + assertU16(t, data, mp+0x1A, 0, "mp.padding@+0x1A") + assertU16(t, data, mp+0x1C, 0, "mp.rewardB") + assertU16(t, data, mp+0x1E, 0, "mp.hardHRReq") + // questTime = 50 × 60 × 30 = 90000 → LE: 0x10 0x5F 0x01 0x00 + assertU32(t, data, mp+0x20, 90000, "mp.questTime") + assertU32(t, data, mp+0x24, 2, "mp.questMap") + assertU32(t, data, mp+0x28, uint32(questStringsPtr), "mp.questStringsPtr") + assertU16(t, data, mp+0x2C, 0, "mp.unknown@+0x2C") + assertU16(t, data, mp+0x2E, 1, "mp.questID") + + // Objective[0]: hunt, target=11, count=1 + // goalType=0x00000001, u8(target)=0x0B, u8(pad)=0x00, u16(count)=0x0001 + assertU32(t, data, mp+0x30, questObjHunt, "obj[0].goalType") + assertByte(t, data, mp+0x34, 11, "obj[0].target") + assertByte(t, data, mp+0x35, 0, "obj[0].pad") + assertU16(t, data, mp+0x36, 1, "obj[0].count") + + // Objective[1]: deliver, target=149, count=3 + // goalType=0x00000002, u16(target)=0x0095, u16(count)=0x0003 + assertU32(t, data, mp+0x38, questObjDeliver, "obj[1].goalType") + assertU16(t, data, mp+0x3C, 149, "obj[1].target") + assertU16(t, data, mp+0x3E, 3, "obj[1].count") + + // Objective[2]: none + assertU32(t, data, mp+0x40, questObjNone, "obj[2].goalType") + assertU32(t, data, mp+0x44, 0, "obj[2].trailing pad") + + assertU16(t, data, mp+0x4C, 0, "mp.joinRankMin") + assertU16(t, data, mp+0x4E, 0, "mp.joinRankMax") + assertU16(t, data, mp+0x50, 0, "mp.postRankMin") + assertU16(t, data, mp+0x52, 0, "mp.postRankMax") + + // forced equip: 6 slots × 4 × 2 = 48 bytes, all zero (no ForcedEquipment in minimalQuestJSON) + for i := 0; i < 48; i++ { + assertByte(t, data, mp+0x5C+i, 0, "forced equip zero") + } + + assertByte(t, data, mp+0x97, 0, "mp.questVariant1") + assertByte(t, data, mp+0x98, 0, "mp.questVariant2") + assertByte(t, data, mp+0x99, 0, "mp.questVariant3") + assertByte(t, data, mp+0x9A, 0, "mp.questVariant4") + + // ── QuestText pointer table (0x1C6–0x1E5) ─────────────────────────── + // 8 pointers, each u32 pointing at a null-terminated Shift-JIS string. + // All string pointers must be within the file and pointing at valid data. + for i := 0; i < 8; i++ { + off := int(questStringsPtr) + i*4 + strPtr := int(binary.LittleEndian.Uint32(data[off:])) + if strPtr < 0 || strPtr >= len(data) { + t.Errorf("string[%d] ptr 0x%X out of bounds (len=%d)", i, strPtr, len(data)) + } + } + + // Title pointer → "Test Quest" (ASCII = valid Shift-JIS) + titlePtr := int(binary.LittleEndian.Uint32(data[int(questStringsPtr):])) + end := titlePtr + for end < len(data) && data[end] != 0 { + end++ + } + if string(data[titlePtr:end]) != "Test Quest" { + t.Errorf("title bytes = %q, want %q", data[titlePtr:end], "Test Quest") + } + + // ── Stage entry (1 stage: stageID=2) ──────────────────────────────── + assertU32(t, data, int(loadedStagesPtr), 2, "stage[0].stageID") + // padding 12 bytes after stageID must be zero + for i := 1; i < 16; i++ { + assertByte(t, data, int(loadedStagesPtr)+i, 0, "stage padding") + } + + // ── Supply box: main[0] = {item:1, qty:5} ─────────────────────────── + supplyBoxPtr := int(binary.LittleEndian.Uint32(data[0x08:])) + assertU16(t, data, supplyBoxPtr, 1, "supply_main[0].item") + assertU16(t, data, supplyBoxPtr+2, 5, "supply_main[0].quantity") + // Remaining 23 main slots must be zero. + for i := 1; i < 24; i++ { + assertU32(t, data, supplyBoxPtr+i*4, 0, "supply_main slot empty") + } + // All 8 subA slots zero. + subABase := supplyBoxPtr + 24*4 + for i := 0; i < 8; i++ { + assertU32(t, data, subABase+i*4, 0, "supply_subA slot empty") + } + // All 8 subB slots zero. + subBBase := subABase + 8*4 + for i := 0; i < 8; i++ { + assertU32(t, data, subBBase+i*4, 0, "supply_subB slot empty") + } + + // ── Reward table ──────────────────────────────────────────────────── + // 1 table, so: header[0] = {tableID=1, pad, pad, tableOffset=10} + // followed by 0xFFFF terminator, then item list. + rewardPtr := int(binary.LittleEndian.Uint32(data[0x0C:])) + assertByte(t, data, rewardPtr, 1, "reward header[0].tableID") + assertByte(t, data, rewardPtr+1, 0, "reward header[0].pad1") + assertU16(t, data, rewardPtr+2, 0, "reward header[0].pad2") + // headerArraySize = 1×8 + 2 = 10 + assertU32(t, data, rewardPtr+4, 10, "reward header[0].tableOffset") + // terminator at rewardPtr+8 + assertU16(t, data, rewardPtr+8, 0xFFFF, "reward header terminator") + // item 0: rate=50, item=149, qty=1 + itemsBase := rewardPtr + 10 + assertU16(t, data, itemsBase, 50, "reward[0].items[0].rate") + assertU16(t, data, itemsBase+2, 149, "reward[0].items[0].item") + assertU16(t, data, itemsBase+4, 1, "reward[0].items[0].quantity") + // item 1: rate=30, item=153, qty=1 + assertU16(t, data, itemsBase+6, 30, "reward[0].items[1].rate") + assertU16(t, data, itemsBase+8, 153, "reward[0].items[1].item") + assertU16(t, data, itemsBase+10, 1, "reward[0].items[1].quantity") + // item list terminator + assertU16(t, data, itemsBase+12, 0xFFFF, "reward item terminator") + + // ── Large monster spawn ────────────────────────────────────────────── + // {id:11, spawnAmount:1, spawnStage:5, orientation:180, x:1500.0, y:0.0, z:-2000.0} + largeMonsterPtr := int(binary.LittleEndian.Uint32(data[0x18:])) + assertByte(t, data, largeMonsterPtr, 11, "monster[0].id") + // pad[3] + assertByte(t, data, largeMonsterPtr+1, 0, "monster[0].pad1") + assertByte(t, data, largeMonsterPtr+2, 0, "monster[0].pad2") + assertByte(t, data, largeMonsterPtr+3, 0, "monster[0].pad3") + assertU32(t, data, largeMonsterPtr+4, 1, "monster[0].spawnAmount") + assertU32(t, data, largeMonsterPtr+8, 5, "monster[0].spawnStage") + // pad[16] at +0x0C + for i := 0; i < 16; i++ { + assertByte(t, data, largeMonsterPtr+0x0C+i, 0, "monster[0].pad16") + } + assertU32(t, data, largeMonsterPtr+0x1C, 180, "monster[0].orientation") + assertF32(t, data, largeMonsterPtr+0x20, 1500.0, "monster[0].x") + assertF32(t, data, largeMonsterPtr+0x24, 0.0, "monster[0].y") + assertF32(t, data, largeMonsterPtr+0x28, -2000.0, "monster[0].z") + // pad[16] at +0x2C + for i := 0; i < 16; i++ { + assertByte(t, data, largeMonsterPtr+0x2C+i, 0, "monster[0].trailing_pad") + } + // terminator byte after 60-byte entry + assertByte(t, data, largeMonsterPtr+60, 0xFF, "monster list terminator") + + // ── Total file size ────────────────────────────────────────────────── + // Compute expected size: + // header(68) + genProp(66) + mainProp(320) + + // strTable(32) + strings(variable) + align + + // stages(1×16) + supplyBox(160) + rewardBuf(10+2+12+2) + monsters(60+1) + // The exact size depends on string byte lengths — just sanity-check it's > 0x374 + // (the last verified byte is the monster terminator at largeMonsterPtr+60). + minExpectedLen := largeMonsterPtr + 61 + if len(data) < minExpectedLen { + t.Errorf("file too short: len=%d, need at least %d", len(data), minExpectedLen) + } +} + +// ── Objective encoding golden tests ───────────────────────────────────────── + +func TestGolden_ObjectiveEncoding(t *testing.T) { + cases := []struct { + name string + obj QuestObjectiveJSON + wantRaw [8]byte // goalType(4) + payload(4) + }{ + { + name: "none", + obj: QuestObjectiveJSON{Type: "none"}, + // goalType=0x00000000, trailing zeros + wantRaw: [8]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + { + name: "hunt target=11 count=1", + obj: QuestObjectiveJSON{Type: "hunt", Target: 11, Count: 1}, + // goalType=0x00000001, u8(11)=0x0B, u8(0), u16(1)=0x01 0x00 + wantRaw: [8]byte{0x01, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x01, 0x00}, + }, + { + name: "capture target=11 count=1", + obj: QuestObjectiveJSON{Type: "capture", Target: 11, Count: 1}, + // goalType=0x00000101 + wantRaw: [8]byte{0x01, 0x01, 0x00, 0x00, 0x0B, 0x00, 0x01, 0x00}, + }, + { + name: "slay target=37 count=3", + obj: QuestObjectiveJSON{Type: "slay", Target: 37, Count: 3}, + // goalType=0x00000201, u8(37)=0x25, u8(0), u16(3)=0x03 0x00 + wantRaw: [8]byte{0x01, 0x02, 0x00, 0x00, 0x25, 0x00, 0x03, 0x00}, + }, + { + name: "deliver target=149 count=3", + obj: QuestObjectiveJSON{Type: "deliver", Target: 149, Count: 3}, + // goalType=0x00000002, u16(149)=0x95 0x00, u16(3)=0x03 0x00 + wantRaw: [8]byte{0x02, 0x00, 0x00, 0x00, 0x95, 0x00, 0x03, 0x00}, + }, + { + name: "break_part target=11 part=3", + obj: QuestObjectiveJSON{Type: "break_part", Target: 11, Part: 3}, + // goalType=0x00004004, u8(11)=0x0B, u8(0), u16(part=3)=0x03 0x00 + wantRaw: [8]byte{0x04, 0x40, 0x00, 0x00, 0x0B, 0x00, 0x03, 0x00}, + }, + { + name: "slay_all", + obj: QuestObjectiveJSON{Type: "slay_all"}, + // goalType=0x00040000 — slay_all uses default (deliver) path: u16(target), u16(count) + wantRaw: [8]byte{0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := objectiveBytes(tc.obj) + if err != nil { + t.Fatalf("objectiveBytes: %v", err) + } + if len(got) != 8 { + t.Fatalf("len(got) = %d, want 8", len(got)) + } + if [8]byte(got) != tc.wantRaw { + t.Errorf("bytes = %v, want %v", got, tc.wantRaw[:]) + } + }) + } +} + +// ── Helper assertions ──────────────────────────────────────────────────────── + +func assertByte(t *testing.T, data []byte, off int, want byte, label string) { + t.Helper() + if off >= len(data) { + t.Errorf("%s @ 0x%X: out of bounds (len=%d)", label, off, len(data)) + return + } + if data[off] != want { + t.Errorf("%s @ 0x%X: got 0x%02X, want 0x%02X", label, off, data[off], want) + } +} + +func assertU16(t *testing.T, data []byte, off int, want uint16, label string) { + t.Helper() + if off+2 > len(data) { + t.Errorf("%s @ 0x%X: out of bounds (len=%d)", label, off, len(data)) + return + } + got := binary.LittleEndian.Uint16(data[off:]) + if got != want { + t.Errorf("%s @ 0x%X: got %d (0x%04X), want %d (0x%04X)", label, off, got, got, want, want) + } +} + +func assertU32(t *testing.T, data []byte, off int, want uint32, label string) { + t.Helper() + if off+4 > len(data) { + t.Errorf("%s @ 0x%X: out of bounds (len=%d)", label, off, len(data)) + return + } + got := binary.LittleEndian.Uint32(data[off:]) + if got != want { + t.Errorf("%s @ 0x%X: got %d (0x%08X), want %d (0x%08X)", label, off, got, got, want, want) + } +} + +func assertF32(t *testing.T, data []byte, off int, want float32, label string) { + t.Helper() + if off+4 > len(data) { + t.Errorf("%s @ 0x%X: out of bounds (len=%d)", label, off, len(data)) + return + } + got := math.Float32frombits(binary.LittleEndian.Uint32(data[off:])) + if got != want { + t.Errorf("%s @ 0x%X: got %v, want %v", label, off, got, want) + } +}