feat(quests): support JSON quest files alongside .bin

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.
This commit is contained in:
Houmgaor
2026-03-19 17:56:50 +01:00
parent 0911d15709
commit c64260275b
4 changed files with 1949 additions and 3 deletions

View File

@@ -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())

View File

@@ -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, 0x440x85)
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:
//
// 0x0000x043 QuestFileHeader (68 bytes, 17 pointers)
// 0x0440x085 generalQuestProperties (66 bytes)
// 0x0860x1C5 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, 0x440x85) ──────────────────
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) // 0x620x72 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, 0x860x1C5) ───────────────────
// 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()
}

View File

@@ -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 (0x000x43) ───────────────────────────────────────────────
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 (0x440x85) ────────────────────────────
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)
// 0x620x85 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
}

View File

@@ -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 (0x000x43) ───────────────────────────────────────────────
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 (0x440x85) ────────────────────────────
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")
// 0x620x72: 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 (0x860x1C5) ──────────────────────────────
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 (0x1C60x1E5) ───────────────────────────
// 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)
}
}