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) + } +}