Files
Erupe/server/channelserver/quest_json.go
Houmgaor e827ecf7d4 feat(quests): implement all remaining binary sections in JSON format
Implement the 8 pointer-addressed sections that were previously written
as null pointers in the quest JSON compiler and parser:

- questAreaPtr → MapSections (ptMapSection pointer array + minion spawns)
- areaTransitionsPtr → AreaTransitions (per-zone floatSet arrays, 52B each)
- areaMappingPtr → AreaMappings (32-byte coordinate mapping entries)
- mapInfoPtr → MapInfo (qMapID + returnBC_ID, 8 bytes)
- gatheringPointsPtr → GatheringPoints (per-zone 24-byte gatheringPoint entries)
- areaFacilitiesPtr → AreaFacilities (per-zone facPoint blocks with sentinel)
- someStringsPtr → SomeString/QuestTypeString (two u32 ptrs + Shift-JIS data)
- gatheringTablesPtr → GatheringTables (pointer array → GatherItem[] lists)

Also set gatheringTablesQty and area1Zones in generalQuestProperties from
JSON data, and validate zone-length consistency between AreaTransitions,
GatheringPoints, and AreaFacilities arrays.

Round-trip tests cover all new sections to ensure compile(parse(bin)) == bin.
2026-03-19 18:20:00 +01:00

1102 lines
40 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"`
}
// QuestMinionSpawnJSON is one minion spawn entry within a map section.
type QuestMinionSpawnJSON struct {
Monster uint8 `json:"monster"`
SpawnToggle uint16 `json:"spawn_toggle"`
SpawnAmount uint32 `json:"spawn_amount"`
X float32 `json:"x"`
Y float32 `json:"y"`
Z float32 `json:"z"`
}
// QuestMapSectionJSON defines one map section with its minion spawns.
// Each section corresponds to a loaded stage area.
type QuestMapSectionJSON struct {
LoadedStage uint32 `json:"loaded_stage"`
SpawnMonsters []uint8 `json:"spawn_monsters,omitempty"` // monster IDs for spawn type list
MinionSpawns []QuestMinionSpawnJSON `json:"minion_spawns,omitempty"`
}
// QuestAreaTransitionJSON is one zone transition (floatSet).
type QuestAreaTransitionJSON struct {
TargetStageID1 int16 `json:"target_stage_id"`
StageVariant int16 `json:"stage_variant"`
CurrentX float32 `json:"current_x"`
CurrentY float32 `json:"current_y"`
CurrentZ float32 `json:"current_z"`
TransitionBox [5]float32 `json:"transition_box"`
TargetX float32 `json:"target_x"`
TargetY float32 `json:"target_y"`
TargetZ float32 `json:"target_z"`
TargetRotation [2]int16 `json:"target_rotation"`
}
// QuestAreaTransitionsJSON holds the transitions for one area zone entry.
// The pointer may be null (empty transitions list) for zones without transitions.
type QuestAreaTransitionsJSON struct {
Transitions []QuestAreaTransitionJSON `json:"transitions,omitempty"`
}
// QuestAreaMappingJSON defines coordinate mappings between area and base map.
// Layout: 32 bytes per entry (Area_xPos, Area_zPos, pad8, Base_xPos, Base_zPos, kn_Pos, pad4).
type QuestAreaMappingJSON struct {
AreaX float32 `json:"area_x"`
AreaZ float32 `json:"area_z"`
BaseX float32 `json:"base_x"`
BaseZ float32 `json:"base_z"`
KnPos float32 `json:"kn_pos"`
}
// QuestMapInfoJSON contains the map ID and return base camp ID.
type QuestMapInfoJSON struct {
MapID uint32 `json:"map_id"`
ReturnBCID uint32 `json:"return_bc_id"`
}
// QuestGatheringPointJSON is one gathering point (24 bytes).
type QuestGatheringPointJSON struct {
X float32 `json:"x"`
Y float32 `json:"y"`
Z float32 `json:"z"`
Range float32 `json:"range"`
GatheringID uint16 `json:"gathering_id"`
MaxCount uint16 `json:"max_count"`
MinCount uint16 `json:"min_count"`
}
// QuestAreaGatheringJSON holds up to 4 gathering points for one area zone entry.
// A nil/empty list means the pointer is null for this zone.
type QuestAreaGatheringJSON struct {
Points []QuestGatheringPointJSON `json:"points,omitempty"`
}
// QuestFacilityPointJSON is one facility point (24 bytes, facPoint in hexpat).
type QuestFacilityPointJSON struct {
Type uint16 `json:"type"` // SpecAc: 1=cooking, 2=fishing, 3=bluebox, etc.
X float32 `json:"x"`
Y float32 `json:"y"`
Z float32 `json:"z"`
Range float32 `json:"range"`
ID uint16 `json:"id"`
}
// QuestAreaFacilitiesJSON holds the facilities block for one area zone entry.
// A nil/empty list means the pointer is null for this zone.
type QuestAreaFacilitiesJSON struct {
Points []QuestFacilityPointJSON `json:"points,omitempty"`
}
// QuestGatherItemJSON is one entry in a gathering table.
type QuestGatherItemJSON struct {
Rate uint16 `json:"rate"`
Item uint16 `json:"item"`
}
// QuestGatheringTableJSON is one gathering loot table.
type QuestGatheringTableJSON struct {
Items []QuestGatherItemJSON `json:"items,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.
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"`
// Map sections with minion spawns (questAreaPtr)
MapSections []QuestMapSectionJSON `json:"map_sections,omitempty"`
// Area transitions per zone (areaTransitionsPtr); one entry per zone.
// Length determines area1Zones in generalQuestProperties.
AreaTransitions []QuestAreaTransitionsJSON `json:"area_transitions,omitempty"`
// Area coordinate mappings (areaMappingPtr)
AreaMappings []QuestAreaMappingJSON `json:"area_mappings,omitempty"`
// Map info: map ID + return base camp ID (mapInfoPtr)
MapInfo *QuestMapInfoJSON `json:"map_info,omitempty"`
// Per-zone gathering points (gatheringPointsPtr); one entry per zone.
GatheringPoints []QuestAreaGatheringJSON `json:"gathering_points,omitempty"`
// Per-zone area facilities (areaFacilitiesPtr); one entry per zone.
AreaFacilities []QuestAreaFacilitiesJSON `json:"area_facilities,omitempty"`
// Additional metadata strings (someStringsPtr / unk30). Optional.
SomeString string `json:"some_string,omitempty"`
QuestType string `json:"quest_type_string,omitempty"`
// Gathering loot tables (gatheringTablesPtr)
GatheringTables []QuestGatheringTableJSON `json:"gathering_tables,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[:])
}
// writeInt16LE writes a little-endian int16 to buf.
func writeInt16LE(buf *bytes.Buffer, v int16) {
writeUint16LE(buf, uint16(v))
}
// 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))
}
// questBuilder is a small helper for building a quest binary with pointer patching.
// All pointers are absolute offsets from the start of the buffer (file start).
type questBuilder struct {
out *bytes.Buffer
}
// reserve writes a u32(0) placeholder and returns its offset in the buffer.
func (b *questBuilder) reserve() int {
off := b.out.Len()
writeUint32LE(b.out, 0)
return off
}
// patch writes the current buffer length as a u32 at the previously reserved offset.
func (b *questBuilder) patch(reservedOff int) {
binary.LittleEndian.PutUint32(b.out.Bytes()[reservedOff:], uint32(b.out.Len()))
}
// patchValue writes a specific uint32 value at a previously reserved offset.
func (b *questBuilder) patchValue(reservedOff int, v uint32) {
binary.LittleEndian.PutUint32(b.out.Bytes()[reservedOff:], v)
}
// 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,
// map sections, area mappings, area transitions,
// map info, gathering points, area facilities,
// some strings, gathering tables
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)
}
// ── Compute counts before writing generalQuestProperties ─────────────
numZones := len(q.AreaTransitions)
numGatheringTables := len(q.GatheringTables)
// Validate zone-length consistency.
if len(q.GatheringPoints) != 0 && len(q.GatheringPoints) != numZones {
return nil, fmt.Errorf("GatheringPoints len (%d) must equal AreaTransitions len (%d) or be 0",
len(q.GatheringPoints), numZones)
}
if len(q.AreaFacilities) != 0 && len(q.AreaFacilities) != numZones {
return nil, fmt.Errorf("AreaFacilities len (%d) must equal AreaTransitions len (%d) or be 0",
len(q.AreaFacilities), numZones)
}
// ── 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)
afterMonsters := align4(largeMonsterPtr + uint32(len(monsterBuf)))
// ── Assemble file ────────────────────────────────────────────────────
qb := &questBuilder{out: &bytes.Buffer{}}
// ── Header placeholders (68 bytes) ────────────────────────────────────
// We'll write the header now with known values; variable section pointers
// that depend on the preceding variable sections are also known at this
// point because we computed them above. The new sections (area, gathering,
// etc.) will be appended after the monster spawns and patched in.
hdrQuestAreaOff := 0x14 // questAreaPtr placeholder
hdrAreaTransOff := 0x1C // areaTransitionsPtr placeholder
hdrAreaMappingOff := 0x20 // areaMappingPtr placeholder
hdrMapInfoOff := 0x24 // mapInfoPtr placeholder
hdrGatherPtsOff := 0x28 // gatheringPointsPtr placeholder
hdrFacilitiesOff := 0x2C // areaFacilitiesPtr placeholder
hdrSomeStringsOff := 0x30 // someStringsPtr placeholder
hdrGatherTablesOff := 0x38 // gatheringTablesPtr placeholder
writeUint32LE(qb.out, questTypeFlagsPtr) // 0x00 questTypeFlagsPtr
writeUint32LE(qb.out, loadedStagesPtr) // 0x04 loadedStagesPtr
writeUint32LE(qb.out, supplyBoxPtr) // 0x08 supplyBoxPtr
writeUint32LE(qb.out, rewardPtr) // 0x0C rewardPtr
writeUint16LE(qb.out, 0) // 0x10 subSupplyBoxPtr (unused)
qb.out.WriteByte(0) // 0x12 hidden
qb.out.WriteByte(0) // 0x13 subSupplyBoxLen
writeUint32LE(qb.out, 0) // 0x14 questAreaPtr (patched later)
writeUint32LE(qb.out, largeMonsterPtr) // 0x18 largeMonsterPtr
writeUint32LE(qb.out, 0) // 0x1C areaTransitionsPtr (patched later)
writeUint32LE(qb.out, 0) // 0x20 areaMappingPtr (patched later)
writeUint32LE(qb.out, 0) // 0x24 mapInfoPtr (patched later)
writeUint32LE(qb.out, 0) // 0x28 gatheringPointsPtr (patched later)
writeUint32LE(qb.out, 0) // 0x2C areaFacilitiesPtr (patched later)
writeUint32LE(qb.out, 0) // 0x30 someStringsPtr (patched later)
writeUint32LE(qb.out, unk34Ptr) // 0x34 fixedCoords1Ptr (stages end)
writeUint32LE(qb.out, 0) // 0x38 gatheringTablesPtr (patched later)
writeUint32LE(qb.out, 0) // 0x3C fixedCoords2Ptr (null)
writeUint32LE(qb.out, 0) // 0x40 fixedInfoPtr (null)
if qb.out.Len() != headerSize {
return nil, fmt.Errorf("header size mismatch: got %d want %d", qb.out.Len(), headerSize)
}
// ── General Quest Properties (66 bytes, 0x440x85) ──────────────────
writeUint16LE(qb.out, q.MonsterSizeMulti) // 0x44 monsterSizeMulti
writeUint16LE(qb.out, q.SizeRange) // 0x46 sizeRange
writeUint32LE(qb.out, q.StatTable1) // 0x48 statTable1
writeUint32LE(qb.out, q.MainRankPoints) // 0x4C mainRankPoints
writeUint32LE(qb.out, 0) // 0x50 unknown
writeUint32LE(qb.out, q.SubARankPoints) // 0x54 subARankPoints
writeUint32LE(qb.out, q.SubBRankPoints) // 0x58 subBRankPoints
writeUint32LE(qb.out, 0) // 0x5C questTypeID / unknown
qb.out.WriteByte(0) // 0x60 padding
qb.out.WriteByte(q.StatTable2) // 0x61 statTable2
pad(qb.out, 0x11) // 0x620x72 padding
qb.out.WriteByte(0) // 0x73 questKn1
writeUint16LE(qb.out, 0) // 0x74 questKn2
writeUint16LE(qb.out, 0) // 0x76 questKn3
writeUint16LE(qb.out, uint16(numGatheringTables)) // 0x78 gatheringTablesQty
writeUint16LE(qb.out, 0) // 0x7A unknown
qb.out.WriteByte(uint8(numZones)) // 0x7C area1Zones
qb.out.WriteByte(0) // 0x7D area2Zones
qb.out.WriteByte(0) // 0x7E area3Zones
qb.out.WriteByte(0) // 0x7F area4Zones
writeUint16LE(qb.out, 0) // 0x80 unknown
writeUint16LE(qb.out, 0) // 0x82 unknown
writeUint16LE(qb.out, 0) // 0x84 unknown
if qb.out.Len() != headerSize+genPropSize {
return nil, fmt.Errorf("genProp size mismatch: got %d want %d", qb.out.Len(), headerSize+genPropSize)
}
// ── Main Quest Properties (320 bytes, 0x860x1C5) ───────────────────
mainStart := qb.out.Len()
qb.out.WriteByte(0) // +0x00 unknown
qb.out.WriteByte(0) // +0x01 musicMode
qb.out.WriteByte(0) // +0x02 localeFlags
qb.out.WriteByte(0) // +0x03 unknown
qb.out.WriteByte(0) // +0x04 rankingID
qb.out.WriteByte(0) // +0x05 unknown
writeUint16LE(qb.out, 0) // +0x06 unknown
writeUint16LE(qb.out, q.RankBand) // +0x08 rankBand
writeUint16LE(qb.out, 0) // +0x0A questTypeID
writeUint32LE(qb.out, q.Fee) // +0x0C questFee
writeUint32LE(qb.out, q.RewardMain) // +0x10 rewardMain
writeUint32LE(qb.out, 0) // +0x14 cartsOrReduction
writeUint16LE(qb.out, q.RewardSubA) // +0x18 rewardA
writeUint16LE(qb.out, 0) // +0x1A padding
writeUint16LE(qb.out, q.RewardSubB) // +0x1C rewardB
writeUint16LE(qb.out, q.HardHRReq) // +0x1E hardHRReq
writeUint32LE(qb.out, q.TimeLimitMinutes*60*30) // +0x20 questTime (frames at 30Hz)
writeUint32LE(qb.out, q.Map) // +0x24 questMap
writeUint32LE(qb.out, questStringsTablePtr) // +0x28 questStringsPtr
writeUint16LE(qb.out, 0) // +0x2C unknown
writeUint16LE(qb.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
}
qb.out.Write(b)
}
// +0x48 post-objectives fields
qb.out.WriteByte(0) // +0x48 unknown
qb.out.WriteByte(0) // +0x49 unknown
writeUint16LE(qb.out, 0) // +0x4A padding
writeUint16LE(qb.out, q.JoinRankMin) // +0x4C joinRankMin
writeUint16LE(qb.out, q.JoinRankMax) // +0x4E joinRankMax
writeUint16LE(qb.out, q.PostRankMin) // +0x50 postRankMin
writeUint16LE(qb.out, q.PostRankMax) // +0x52 postRankMax
pad(qb.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(qb.out, v)
}
}
// +0x8C unknown u32
writeUint32LE(qb.out, 0)
// +0x90 monster variants[3] + mapVariant
qb.out.WriteByte(0) // monsterVariants[0]
qb.out.WriteByte(0) // monsterVariants[1]
qb.out.WriteByte(0) // monsterVariants[2]
qb.out.WriteByte(0) // mapVariant
// +0x94 requiredItemType (ItemID = u16), requiredItemCount
writeUint16LE(qb.out, 0)
qb.out.WriteByte(0) // requiredItemCount
// +0x97 questVariants
qb.out.WriteByte(q.QuestVariant1)
qb.out.WriteByte(q.QuestVariant2)
qb.out.WriteByte(q.QuestVariant3)
qb.out.WriteByte(q.QuestVariant4)
// +0x9B padding[5]
pad(qb.out, 5)
// +0xA0 allowedEquipBitmask, points
writeUint32LE(qb.out, 0) // allowedEquipBitmask
writeUint32LE(qb.out, 0) // mainPoints
writeUint32LE(qb.out, 0) // subAPoints
writeUint32LE(qb.out, 0) // subBPoints
// +0xB0 rewardItems[3] (ItemID = u16, 3 items = 6 bytes)
pad(qb.out, 6)
// +0xB6 interception section (non-SlayAll path: padding[3] + MonsterID[1] = 4 bytes)
pad(qb.out, 4)
// +0xBA padding[0xA] = 10 bytes
pad(qb.out, 10)
// +0xC4 questClearsAllowed
writeUint32LE(qb.out, 0)
// +0xC8 = 200 bytes so far for documented fields. ZZ body = 320 bytes.
// Zero-pad the remaining unknown ZZ-specific fields.
writtenInMain := qb.out.Len() - mainStart
if writtenInMain < mainPropSize {
pad(qb.out, mainPropSize-writtenInMain)
} else if writtenInMain > mainPropSize {
return nil, fmt.Errorf("mainQuestProperties overflowed: wrote %d, max %d", writtenInMain, mainPropSize)
}
if qb.out.Len() != int(questTypeFlagsPtr)+mainPropSize {
return nil, fmt.Errorf("main prop end mismatch: at %d, want %d", qb.out.Len(), int(questTypeFlagsPtr)+mainPropSize)
}
// ── QuestText pointer table (32 bytes) ───────────────────────────────
for _, ptr := range stringPtrs {
writeUint32LE(qb.out, ptr)
}
// ── String data ──────────────────────────────────────────────────────
for _, s := range sjisStrings {
qb.out.Write(s)
}
// Pad to afterStrings alignment.
for uint32(qb.out.Len()) < afterStrings {
qb.out.WriteByte(0)
}
// ── Stages ───────────────────────────────────────────────────────────
for _, st := range q.Stages {
writeUint32LE(qb.out, st.StageID)
pad(qb.out, 12)
}
for uint32(qb.out.Len()) < afterStages {
qb.out.WriteByte(0)
}
// ── Supply Box ───────────────────────────────────────────────────────
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(qb.out, item.Item)
writeUint16LE(qb.out, item.Quantity)
written++
}
for written < section.max {
writeUint32LE(qb.out, 0)
written++
}
}
// ── Reward Tables ────────────────────────────────────────────────────
qb.out.Write(rewardBuf)
for uint32(qb.out.Len()) < largeMonsterPtr {
qb.out.WriteByte(0)
}
// ── Large Monster Spawns ─────────────────────────────────────────────
qb.out.Write(monsterBuf)
for uint32(qb.out.Len()) < afterMonsters {
qb.out.WriteByte(0)
}
// ── Variable sections: map sections, area mappings, transitions, etc. ──
// All written at afterMonsters and beyond, pointers patched into header.
// ── Map Sections (questAreaPtr) ──────────────────────────────────────
// Layout:
// u32 ptr[0], u32 ptr[1], ..., u32(0) terminator
// For each section:
// mapSection: u32 loadedStage, u32 unk, u32 spawnTypesPtr, u32 spawnStatsPtr
// u32(0) gap, u16 unk (= 6 bytes after mapSection)
// spawnTypes data: (MonsterID u8 + pad[3]) per entry, terminated by 0xFFFF
// spawnStats data: MinionSpawn (60 bytes) per entry, terminated by 0xFFFF
if len(q.MapSections) > 0 {
questAreaOff := qb.out.Len()
qb.patchValue(hdrQuestAreaOff, uint32(questAreaOff))
// Write pointer array (one u32 per section + terminator).
sectionPtrOffs := make([]int, len(q.MapSections))
for i := range q.MapSections {
sectionPtrOffs[i] = qb.reserve()
}
writeUint32LE(qb.out, 0) // terminator
// Write each mapSection block.
type sectionPtrs struct {
spawnTypesOff int
spawnStatsOff int
}
internalPtrs := make([]sectionPtrs, len(q.MapSections))
for i, ms := range q.MapSections {
// Patch the pointer-array entry to point here.
qb.patch(sectionPtrOffs[i])
// mapSection: loadedStage, unk, spawnTypesPtr, spawnStatsPtr
writeUint32LE(qb.out, ms.LoadedStage)
writeUint32LE(qb.out, 0) // unk
internalPtrs[i].spawnTypesOff = qb.reserve()
internalPtrs[i].spawnStatsOff = qb.reserve()
// u32(0) gap + u16 unk immediately after the 16-byte mapSection.
writeUint32LE(qb.out, 0)
writeUint16LE(qb.out, 0)
}
// Write spawn data for each section.
for i, ms := range q.MapSections {
// spawnTypes: varPaddT<MonsterID,3> = u8 monster + pad[3] per entry.
// Terminated by first 2 bytes == 0xFFFF.
qb.patch(internalPtrs[i].spawnTypesOff)
for _, monID := range ms.SpawnMonsters {
qb.out.WriteByte(monID)
pad(qb.out, 3)
}
writeUint16LE(qb.out, 0xFFFF) // terminator
// Align to 4 bytes before spawnStats.
for qb.out.Len()%4 != 0 {
qb.out.WriteByte(0)
}
// spawnStats: MinionSpawn per entry (60 bytes), terminated by 0xFFFF.
qb.patch(internalPtrs[i].spawnStatsOff)
for _, ms2 := range ms.MinionSpawns {
qb.out.WriteByte(ms2.Monster)
qb.out.WriteByte(0) // padding[1]
writeUint16LE(qb.out, ms2.SpawnToggle) // spawnToggle
writeUint32LE(qb.out, ms2.SpawnAmount) // spawnAmount
writeUint32LE(qb.out, 0) // unk u32
pad(qb.out, 0x10) // padding[16]
writeUint32LE(qb.out, 0) // unk u32
writeFloat32LE(qb.out, ms2.X)
writeFloat32LE(qb.out, ms2.Y)
writeFloat32LE(qb.out, ms2.Z)
pad(qb.out, 0x10) // padding[16]
}
writeUint16LE(qb.out, 0xFFFF) // terminator
// Align for next section.
for qb.out.Len()%4 != 0 {
qb.out.WriteByte(0)
}
}
}
// ── Area Mappings (areaMappingPtr) ────────────────────────────────────
// Written BEFORE area transitions so the parser can use
// "read until areaTransitionsPtr" to know the count.
// Layout: AreaMappings[n] × 32 bytes each, back-to-back.
// float area_xPos, float area_zPos, pad[8],
// float base_xPos, float base_zPos, float kn_Pos, pad[4]
if len(q.AreaMappings) > 0 {
areaMappingOff := qb.out.Len()
qb.patchValue(hdrAreaMappingOff, uint32(areaMappingOff))
for _, am := range q.AreaMappings {
writeFloat32LE(qb.out, am.AreaX)
writeFloat32LE(qb.out, am.AreaZ)
pad(qb.out, 8)
writeFloat32LE(qb.out, am.BaseX)
writeFloat32LE(qb.out, am.BaseZ)
writeFloat32LE(qb.out, am.KnPos)
pad(qb.out, 4)
}
}
// ── Area Transitions (areaTransitionsPtr) ─────────────────────────────
// Layout: playerAreaChange[area1Zones] = u32 ptr per zone.
// Then floatSet arrays for each zone with transitions.
if numZones > 0 {
areaTransOff := qb.out.Len()
qb.patchValue(hdrAreaTransOff, uint32(areaTransOff))
// Write pointer array.
zonePtrOffs := make([]int, numZones)
for i := range q.AreaTransitions {
zonePtrOffs[i] = qb.reserve()
}
// Write floatSet arrays for non-empty zones.
for i, zone := range q.AreaTransitions {
if len(zone.Transitions) == 0 {
// Null pointer — leave as 0.
continue
}
qb.patch(zonePtrOffs[i])
for _, tr := range zone.Transitions {
writeInt16LE(qb.out, tr.TargetStageID1)
writeInt16LE(qb.out, tr.StageVariant)
writeFloat32LE(qb.out, tr.CurrentX)
writeFloat32LE(qb.out, tr.CurrentY)
writeFloat32LE(qb.out, tr.CurrentZ)
for _, f := range tr.TransitionBox {
writeFloat32LE(qb.out, f)
}
writeFloat32LE(qb.out, tr.TargetX)
writeFloat32LE(qb.out, tr.TargetY)
writeFloat32LE(qb.out, tr.TargetZ)
for _, r := range tr.TargetRotation {
writeInt16LE(qb.out, r)
}
}
// Terminate with s16(-1).
writeInt16LE(qb.out, -1)
// Align.
for qb.out.Len()%4 != 0 {
qb.out.WriteByte(0)
}
}
}
// ── Map Info (mapInfoPtr) ─────────────────────────────────────────────
if q.MapInfo != nil {
mapInfoOff := qb.out.Len()
qb.patchValue(hdrMapInfoOff, uint32(mapInfoOff))
writeUint32LE(qb.out, q.MapInfo.MapID)
writeUint32LE(qb.out, q.MapInfo.ReturnBCID)
}
// ── Gathering Points (gatheringPointsPtr) ─────────────────────────────
// Layout: ptGatheringPoint[area1Zones] = u32 ptr per zone.
// Each non-null ptr points to gatheringPoint[4] terminated by xPos=-1.0.
if numZones > 0 && len(q.GatheringPoints) > 0 {
gatherPtsOff := qb.out.Len()
qb.patchValue(hdrGatherPtsOff, uint32(gatherPtsOff))
// Write pointer array.
gpPtrOffs := make([]int, numZones)
for i := range q.GatheringPoints {
gpPtrOffs[i] = qb.reserve()
}
// Write gathering point arrays for non-empty zones.
for i, zone := range q.GatheringPoints {
if len(zone.Points) == 0 {
continue
}
qb.patch(gpPtrOffs[i])
for _, gp := range zone.Points {
writeFloat32LE(qb.out, gp.X)
writeFloat32LE(qb.out, gp.Y)
writeFloat32LE(qb.out, gp.Z)
writeFloat32LE(qb.out, gp.Range)
writeUint16LE(qb.out, gp.GatheringID)
writeUint16LE(qb.out, gp.MaxCount)
pad(qb.out, 2)
writeUint16LE(qb.out, gp.MinCount)
}
// Terminator: xPos == -1.0 (0xBF800000).
writeFloat32LE(qb.out, float32(math.Float32frombits(0xBF800000)))
// Pad terminator entry to 24 bytes total (only wrote 4).
pad(qb.out, 20)
}
}
// ── Area Facilities (areaFacilitiesPtr) ───────────────────────────────
// Layout: ptVar<facPointBlock>[area1Zones] = u32 ptr per zone.
// Each non-null ptr points to a facPointBlock.
// facPoint: pad[2] + SpecAc(u16) + xPos + yPos + zPos + range + id(u16) + pad[2] = 24 bytes
// facPointBlock: facPoints[] terminated by (xPos-at-$+4 == 0xBF800000) + pad[0xC] + float + float
// Terminator layout: write pad[2]+type[2] then float32(-1.0) to trigger termination,
// then block footer: pad[0xC] + float(0) + float(0).
if numZones > 0 && len(q.AreaFacilities) > 0 {
facOff := qb.out.Len()
qb.patchValue(hdrFacilitiesOff, uint32(facOff))
facPtrOffs := make([]int, numZones)
for i := range q.AreaFacilities {
facPtrOffs[i] = qb.reserve()
}
for i, zone := range q.AreaFacilities {
if len(zone.Points) == 0 {
continue
}
qb.patch(facPtrOffs[i])
for _, fp := range zone.Points {
pad(qb.out, 2) // pad[2]
writeUint16LE(qb.out, fp.Type) // SpecAc type
writeFloat32LE(qb.out, fp.X)
writeFloat32LE(qb.out, fp.Y)
writeFloat32LE(qb.out, fp.Z)
writeFloat32LE(qb.out, fp.Range)
writeUint16LE(qb.out, fp.ID)
pad(qb.out, 2) // pad[2]
}
// Terminator: the while condition checks read_unsigned($+4,4).
// Write 4 bytes header (pad[2]+type[2]) then float32(-1.0).
pad(qb.out, 2)
writeUint16LE(qb.out, 0)
writeFloat32LE(qb.out, float32(math.Float32frombits(0xBF800000)))
// Block footer: padding[0xC] + float(0) + float(0) = 20 bytes.
pad(qb.out, 0xC)
writeFloat32LE(qb.out, 0)
writeFloat32LE(qb.out, 0)
}
}
// ── Some Strings (someStringsPtr / unk30) ─────────────────────────────
// Layout at unk30: ptr someStringPtr, ptr questTypePtr (8 bytes),
// then the string data.
hasSomeStrings := q.SomeString != "" || q.QuestType != ""
if hasSomeStrings {
someStringsOff := qb.out.Len()
qb.patchValue(hdrSomeStringsOff, uint32(someStringsOff))
// Two pointer slots.
someStrPtrOff := qb.reserve()
questTypePtrOff := qb.reserve()
if q.SomeString != "" {
qb.patch(someStrPtrOff)
b, err := toShiftJIS(q.SomeString)
if err != nil {
return nil, err
}
qb.out.Write(b)
}
if q.QuestType != "" {
qb.patch(questTypePtrOff)
b, err := toShiftJIS(q.QuestType)
if err != nil {
return nil, err
}
qb.out.Write(b)
}
}
// ── Gathering Tables (gatheringTablesPtr) ─────────────────────────────
// Layout: ptVar<gatheringTable>[gatheringTablesQty] = u32 ptr per table.
// Each ptr points to GatherItem[] terminated by u16(0xFFFF).
// GatherItem: u16 rate + u16 item = 4 bytes.
if numGatheringTables > 0 {
gatherTablesOff := qb.out.Len()
qb.patchValue(hdrGatherTablesOff, uint32(gatherTablesOff))
tblPtrOffs := make([]int, numGatheringTables)
for i := range q.GatheringTables {
tblPtrOffs[i] = qb.reserve()
}
for i, tbl := range q.GatheringTables {
qb.patch(tblPtrOffs[i])
for _, item := range tbl.Items {
writeUint16LE(qb.out, item.Rate)
writeUint16LE(qb.out, item.Item)
}
writeUint16LE(qb.out, 0xFFFF) // terminator
}
}
return qb.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.
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()
}