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.
This commit is contained in:
Houmgaor
2026-03-19 18:20:00 +01:00
parent c64260275b
commit e827ecf7d4
3 changed files with 1488 additions and 200 deletions

View File

@@ -105,11 +105,107 @@ type QuestForcedEquipJSON struct {
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.
// 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"`
@@ -174,6 +270,32 @@ type QuestJSON struct {
// 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.
@@ -194,6 +316,11 @@ func writeUint16LE(buf *bytes.Buffer, v uint16) {
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{}
@@ -213,6 +340,29 @@ 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:
//
@@ -267,13 +417,30 @@ func objectiveBytes(obj QuestObjectiveJSON) ([]byte, error) {
// 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
// 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
@@ -335,88 +502,101 @@ func CompileQuestJSON(data []byte) ([]byte, error) {
// Large monster spawns: each is 60 bytes + 1-byte terminator.
largeMonsterPtr := afterRewards
monsterBuf := buildMonsterSpawns(q.LargeMonsters)
afterMonsters := align4(largeMonsterPtr + uint32(len(monsterBuf)))
// ── Assemble file ────────────────────────────────────────────────────
out := &bytes.Buffer{}
qb := &questBuilder{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)
// ── 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
if out.Len() != headerSize {
return nil, fmt.Errorf("header size mismatch: got %d want %d", out.Len(), headerSize)
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(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
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 out.Len() != headerSize+genPropSize {
return nil, fmt.Errorf("genProp size mismatch: got %d want %d", out.Len(), headerSize+genPropSize)
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) ───────────────────
// 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
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} {
@@ -424,18 +604,18 @@ func CompileQuestJSON(data []byte) ([]byte, error) {
if err != nil {
return nil, err
}
out.Write(b)
qb.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]
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
@@ -444,90 +624,88 @@ func CompileQuestJSON(data []byte) ([]byte, error) {
}
for _, slot := range [][4]uint16{eq.Legs, eq.Weapon, eq.Head, eq.Chest, eq.Arms, eq.Waist} {
for _, v := range slot {
writeUint16LE(out, v)
writeUint16LE(qb.out, v)
}
}
// +0x8C unknown u32
writeUint32LE(out, 0)
writeUint32LE(qb.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
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(out, 0)
out.WriteByte(0) // requiredItemCount
writeUint16LE(qb.out, 0)
qb.out.WriteByte(0) // requiredItemCount
// +0x97 questVariants
out.WriteByte(q.QuestVariant1)
out.WriteByte(q.QuestVariant2)
out.WriteByte(q.QuestVariant3)
out.WriteByte(q.QuestVariant4)
qb.out.WriteByte(q.QuestVariant1)
qb.out.WriteByte(q.QuestVariant2)
qb.out.WriteByte(q.QuestVariant3)
qb.out.WriteByte(q.QuestVariant4)
// +0x9B padding[5]
pad(out, 5)
pad(qb.out, 5)
// +0xA0 allowedEquipBitmask, points
writeUint32LE(out, 0) // allowedEquipBitmask
writeUint32LE(out, 0) // mainPoints
writeUint32LE(out, 0) // subAPoints
writeUint32LE(out, 0) // subBPoints
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(out, 6)
pad(qb.out, 6)
// +0xB6 interception section (non-SlayAll path: padding[3] + MonsterID[1] = 4 bytes)
pad(out, 4)
pad(qb.out, 4)
// +0xBA padding[0xA] = 10 bytes
pad(out, 10)
pad(qb.out, 10)
// +0xC4 questClearsAllowed
writeUint32LE(out, 0)
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 := out.Len() - mainStart
writtenInMain := qb.out.Len() - mainStart
if writtenInMain < mainPropSize {
pad(out, mainPropSize-writtenInMain)
pad(qb.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)
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(out, ptr)
writeUint32LE(qb.out, ptr)
}
// ── String data ──────────────────────────────────────────────────────
for _, s := range sjisStrings {
out.Write(s)
qb.out.Write(s)
}
// Pad to afterStrings alignment.
for uint32(out.Len()) < afterStrings {
out.WriteByte(0)
for uint32(qb.out.Len()) < afterStrings {
qb.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)
writeUint32LE(qb.out, st.StageID)
pad(qb.out, 12)
}
for uint32(out.Len()) < afterStages {
out.WriteByte(0)
for uint32(qb.out.Len()) < afterStages {
qb.out.WriteByte(0)
}
// ── Supply Box ───────────────────────────────────────────────────────
// Three sections: main (24 slots), subA (8 slots), subB (8 slots).
type slot struct {
items []QuestSupplyItemJSON
max int
@@ -542,29 +720,323 @@ func CompileQuestJSON(data []byte) ([]byte, error) {
if written >= section.max {
break
}
writeUint16LE(out, item.Item)
writeUint16LE(out, item.Quantity)
writeUint16LE(qb.out, item.Item)
writeUint16LE(qb.out, item.Quantity)
written++
}
// Pad remaining slots with zeros.
for written < section.max {
writeUint32LE(out, 0)
writeUint32LE(qb.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)
qb.out.Write(rewardBuf)
for uint32(qb.out.Len()) < largeMonsterPtr {
qb.out.WriteByte(0)
}
// ── Large Monster Spawns ─────────────────────────────────────────────
out.Write(monsterBuf)
qb.out.Write(monsterBuf)
for uint32(qb.out.Len()) < afterMonsters {
qb.out.WriteByte(0)
}
return out.Bytes(), nil
// ── 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.
@@ -587,7 +1059,6 @@ func buildRewardTables(tables []QuestRewardTableJSON) []byte {
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)