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"` 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. // QuestJSON is the human-readable quest definition.
// Time values: TimeLimitMinutes is converted to frames (×30×60) in the binary. // 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. // 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 { type QuestJSON struct {
// Quest identification // Quest identification
QuestID uint16 `json:"quest_id"` QuestID uint16 `json:"quest_id"`
@@ -174,6 +270,32 @@ type QuestJSON struct {
// Forced equipment (optional) // Forced equipment (optional)
ForcedEquipment *QuestForcedEquipJSON `json:"forced_equipment,omitempty"` 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. // 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[:]) 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. // writeUint32LE writes a little-endian uint32 to buf.
func writeUint32LE(buf *bytes.Buffer, v uint32) { func writeUint32LE(buf *bytes.Buffer, v uint32) {
b := [4]byte{} b := [4]byte{}
@@ -213,6 +340,29 @@ func pad(buf *bytes.Buffer, n int) {
buf.Write(make([]byte, n)) 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. // objectiveBytes serialises one QuestObjectiveJSON to 8 bytes.
// Layout per hexpat objective.hexpat: // Layout per hexpat objective.hexpat:
// //
@@ -267,13 +417,30 @@ func objectiveBytes(obj QuestObjectiveJSON) ([]byte, error) {
// 0x0440x085 generalQuestProperties (66 bytes) // 0x0440x085 generalQuestProperties (66 bytes)
// 0x0860x1C5 mainQuestProperties (320 bytes, questBodyLenZZ) // 0x0860x1C5 mainQuestProperties (320 bytes, questBodyLenZZ)
// 0x1C6+ QuestText pointer table (32 bytes) + strings (Shift-JIS) // 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) { func CompileQuestJSON(data []byte) ([]byte, error) {
var q QuestJSON var q QuestJSON
if err := json.Unmarshal(data, &q); err != nil { if err := json.Unmarshal(data, &q); err != nil {
return nil, fmt.Errorf("parse quest JSON: %w", err) 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) ────────────────────────── // ── Section offsets (computed as we build) ──────────────────────────
const ( const (
headerSize = 68 // 0x44 headerSize = 68 // 0x44
@@ -335,88 +502,101 @@ func CompileQuestJSON(data []byte) ([]byte, error) {
// Large monster spawns: each is 60 bytes + 1-byte terminator. // Large monster spawns: each is 60 bytes + 1-byte terminator.
largeMonsterPtr := afterRewards largeMonsterPtr := afterRewards
monsterBuf := buildMonsterSpawns(q.LargeMonsters) monsterBuf := buildMonsterSpawns(q.LargeMonsters)
afterMonsters := align4(largeMonsterPtr + uint32(len(monsterBuf)))
// ── Assemble file ──────────────────────────────────────────────────── // ── Assemble file ────────────────────────────────────────────────────
out := &bytes.Buffer{} qb := &questBuilder{out: &bytes.Buffer{}}
// ── Header (68 bytes) ──────────────────────────────────────────────── // ── Header placeholders (68 bytes) ────────────────────────────────────
writeUint32LE(out, questTypeFlagsPtr) // 0x00 questTypeFlagsPtr // We'll write the header now with known values; variable section pointers
writeUint32LE(out, loadedStagesPtr) // 0x04 loadedStagesPtr // that depend on the preceding variable sections are also known at this
writeUint32LE(out, supplyBoxPtr) // 0x08 supplyBoxPtr // point because we computed them above. The new sections (area, gathering,
writeUint32LE(out, rewardPtr) // 0x0C rewardPtr // etc.) will be appended after the monster spawns and patched in.
writeUint16LE(out, 0) // 0x10 subSupplyBoxPtr (unused) hdrQuestAreaOff := 0x14 // questAreaPtr placeholder
out.WriteByte(0) // 0x12 hidden hdrAreaTransOff := 0x1C // areaTransitionsPtr placeholder
out.WriteByte(0) // 0x13 subSupplyBoxLen hdrAreaMappingOff := 0x20 // areaMappingPtr placeholder
writeUint32LE(out, 0) // 0x14 questAreaPtr (null) hdrMapInfoOff := 0x24 // mapInfoPtr placeholder
writeUint32LE(out, largeMonsterPtr) // 0x18 largeMonsterPtr hdrGatherPtsOff := 0x28 // gatheringPointsPtr placeholder
writeUint32LE(out, 0) // 0x1C areaTransitionsPtr (null) hdrFacilitiesOff := 0x2C // areaFacilitiesPtr placeholder
writeUint32LE(out, 0) // 0x20 areaMappingPtr (null) hdrSomeStringsOff := 0x30 // someStringsPtr placeholder
writeUint32LE(out, 0) // 0x24 mapInfoPtr (null) hdrGatherTablesOff := 0x38 // gatheringTablesPtr placeholder
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 { writeUint32LE(qb.out, questTypeFlagsPtr) // 0x00 questTypeFlagsPtr
return nil, fmt.Errorf("header size mismatch: got %d want %d", out.Len(), headerSize) 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) ────────────────── // ── General Quest Properties (66 bytes, 0x440x85) ──────────────────
writeUint16LE(out, q.MonsterSizeMulti) // 0x44 monsterSizeMulti writeUint16LE(qb.out, q.MonsterSizeMulti) // 0x44 monsterSizeMulti
writeUint16LE(out, q.SizeRange) // 0x46 sizeRange writeUint16LE(qb.out, q.SizeRange) // 0x46 sizeRange
writeUint32LE(out, q.StatTable1) // 0x48 statTable1 writeUint32LE(qb.out, q.StatTable1) // 0x48 statTable1
writeUint32LE(out, q.MainRankPoints) // 0x4C mainRankPoints writeUint32LE(qb.out, q.MainRankPoints) // 0x4C mainRankPoints
writeUint32LE(out, 0) // 0x50 unknown writeUint32LE(qb.out, 0) // 0x50 unknown
writeUint32LE(out, q.SubARankPoints) // 0x54 subARankPoints writeUint32LE(qb.out, q.SubARankPoints) // 0x54 subARankPoints
writeUint32LE(out, q.SubBRankPoints) // 0x58 subBRankPoints writeUint32LE(qb.out, q.SubBRankPoints) // 0x58 subBRankPoints
writeUint32LE(out, 0) // 0x5C questTypeID / unknown writeUint32LE(qb.out, 0) // 0x5C questTypeID / unknown
out.WriteByte(0) // 0x60 padding qb.out.WriteByte(0) // 0x60 padding
out.WriteByte(q.StatTable2) // 0x61 statTable2 qb.out.WriteByte(q.StatTable2) // 0x61 statTable2
pad(out, 0x11) // 0x620x72 padding pad(qb.out, 0x11) // 0x620x72 padding
out.WriteByte(0) // 0x73 questKn1 qb.out.WriteByte(0) // 0x73 questKn1
writeUint16LE(out, 0) // 0x74 questKn2 writeUint16LE(qb.out, 0) // 0x74 questKn2
writeUint16LE(out, 0) // 0x76 questKn3 writeUint16LE(qb.out, 0) // 0x76 questKn3
writeUint16LE(out, 0) // 0x78 gatheringTablesQty writeUint16LE(qb.out, uint16(numGatheringTables)) // 0x78 gatheringTablesQty
writeUint16LE(out, 0) // 0x7A unknown writeUint16LE(qb.out, 0) // 0x7A unknown
out.WriteByte(0) // 0x7C area1Zones qb.out.WriteByte(uint8(numZones)) // 0x7C area1Zones
out.WriteByte(0) // 0x7D area2Zones qb.out.WriteByte(0) // 0x7D area2Zones
out.WriteByte(0) // 0x7E area3Zones qb.out.WriteByte(0) // 0x7E area3Zones
out.WriteByte(0) // 0x7F area4Zones qb.out.WriteByte(0) // 0x7F area4Zones
writeUint16LE(out, 0) // 0x80 unknown writeUint16LE(qb.out, 0) // 0x80 unknown
writeUint16LE(out, 0) // 0x82 unknown writeUint16LE(qb.out, 0) // 0x82 unknown
writeUint16LE(out, 0) // 0x84 unknown writeUint16LE(qb.out, 0) // 0x84 unknown
if out.Len() != headerSize+genPropSize { if qb.out.Len() != headerSize+genPropSize {
return nil, fmt.Errorf("genProp size mismatch: got %d want %d", 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) ─────────────────── // ── Main Quest Properties (320 bytes, 0x860x1C5) ───────────────────
// Matches mainQuestProperties struct in questfile.bin.hexpat. mainStart := qb.out.Len()
mainStart := out.Len() qb.out.WriteByte(0) // +0x00 unknown
out.WriteByte(0) // +0x00 unknown qb.out.WriteByte(0) // +0x01 musicMode
out.WriteByte(0) // +0x01 musicMode qb.out.WriteByte(0) // +0x02 localeFlags
out.WriteByte(0) // +0x02 localeFlags qb.out.WriteByte(0) // +0x03 unknown
out.WriteByte(0) // +0x03 unknown qb.out.WriteByte(0) // +0x04 rankingID
out.WriteByte(0) // +0x04 rankingID qb.out.WriteByte(0) // +0x05 unknown
out.WriteByte(0) // +0x05 unknown writeUint16LE(qb.out, 0) // +0x06 unknown
writeUint16LE(out, 0) // +0x06 unknown writeUint16LE(qb.out, q.RankBand) // +0x08 rankBand
writeUint16LE(out, q.RankBand) // +0x08 rankBand writeUint16LE(qb.out, 0) // +0x0A questTypeID
writeUint16LE(out, 0) // +0x0A questTypeID writeUint32LE(qb.out, q.Fee) // +0x0C questFee
writeUint32LE(out, q.Fee) // +0x0C questFee writeUint32LE(qb.out, q.RewardMain) // +0x10 rewardMain
writeUint32LE(out, q.RewardMain) // +0x10 rewardMain writeUint32LE(qb.out, 0) // +0x14 cartsOrReduction
writeUint32LE(out, 0) // +0x14 cartsOrReduction writeUint16LE(qb.out, q.RewardSubA) // +0x18 rewardA
writeUint16LE(out, q.RewardSubA) // +0x18 rewardA writeUint16LE(qb.out, 0) // +0x1A padding
writeUint16LE(out, 0) // +0x1A padding writeUint16LE(qb.out, q.RewardSubB) // +0x1C rewardB
writeUint16LE(out, q.RewardSubB) // +0x1C rewardB writeUint16LE(qb.out, q.HardHRReq) // +0x1E hardHRReq
writeUint16LE(out, q.HardHRReq) // +0x1E hardHRReq writeUint32LE(qb.out, q.TimeLimitMinutes*60*30) // +0x20 questTime (frames at 30Hz)
writeUint32LE(out, q.TimeLimitMinutes*60*30) // +0x20 questTime (frames at 30Hz) writeUint32LE(qb.out, q.Map) // +0x24 questMap
writeUint32LE(out, q.Map) // +0x24 questMap writeUint32LE(qb.out, questStringsTablePtr) // +0x28 questStringsPtr
writeUint32LE(out, questStringsTablePtr) // +0x28 questStringsPtr writeUint16LE(qb.out, 0) // +0x2C unknown
writeUint16LE(out, 0) // +0x2C unknown writeUint16LE(qb.out, q.QuestID) // +0x2E questID
writeUint16LE(out, q.QuestID) // +0x2E questID
// +0x30 objectives[3] (8 bytes each) // +0x30 objectives[3] (8 bytes each)
for _, obj := range []QuestObjectiveJSON{q.ObjectiveMain, q.ObjectiveSubA, q.ObjectiveSubB} { for _, obj := range []QuestObjectiveJSON{q.ObjectiveMain, q.ObjectiveSubA, q.ObjectiveSubB} {
@@ -424,18 +604,18 @@ func CompileQuestJSON(data []byte) ([]byte, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
out.Write(b) qb.out.Write(b)
} }
// +0x48 post-objectives fields // +0x48 post-objectives fields
out.WriteByte(0) // +0x48 unknown qb.out.WriteByte(0) // +0x48 unknown
out.WriteByte(0) // +0x49 unknown qb.out.WriteByte(0) // +0x49 unknown
writeUint16LE(out, 0) // +0x4A padding writeUint16LE(qb.out, 0) // +0x4A padding
writeUint16LE(out, q.JoinRankMin) // +0x4C joinRankMin writeUint16LE(qb.out, q.JoinRankMin) // +0x4C joinRankMin
writeUint16LE(out, q.JoinRankMax) // +0x4E joinRankMax writeUint16LE(qb.out, q.JoinRankMax) // +0x4E joinRankMax
writeUint16LE(out, q.PostRankMin) // +0x50 postRankMin writeUint16LE(qb.out, q.PostRankMin) // +0x50 postRankMin
writeUint16LE(out, q.PostRankMax) // +0x52 postRankMax writeUint16LE(qb.out, q.PostRankMax) // +0x52 postRankMax
pad(out, 8) // +0x54 padding[8] pad(qb.out, 8) // +0x54 padding[8]
// +0x5C forced equipment (6 slots × 4 u16 = 48 bytes) // +0x5C forced equipment (6 slots × 4 u16 = 48 bytes)
eq := q.ForcedEquipment 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 _, slot := range [][4]uint16{eq.Legs, eq.Weapon, eq.Head, eq.Chest, eq.Arms, eq.Waist} {
for _, v := range slot { for _, v := range slot {
writeUint16LE(out, v) writeUint16LE(qb.out, v)
} }
} }
// +0x8C unknown u32 // +0x8C unknown u32
writeUint32LE(out, 0) writeUint32LE(qb.out, 0)
// +0x90 monster variants[3] + mapVariant // +0x90 monster variants[3] + mapVariant
out.WriteByte(0) // monsterVariants[0] qb.out.WriteByte(0) // monsterVariants[0]
out.WriteByte(0) // monsterVariants[1] qb.out.WriteByte(0) // monsterVariants[1]
out.WriteByte(0) // monsterVariants[2] qb.out.WriteByte(0) // monsterVariants[2]
out.WriteByte(0) // mapVariant qb.out.WriteByte(0) // mapVariant
// +0x94 requiredItemType (ItemID = u16), requiredItemCount // +0x94 requiredItemType (ItemID = u16), requiredItemCount
writeUint16LE(out, 0) writeUint16LE(qb.out, 0)
out.WriteByte(0) // requiredItemCount qb.out.WriteByte(0) // requiredItemCount
// +0x97 questVariants // +0x97 questVariants
out.WriteByte(q.QuestVariant1) qb.out.WriteByte(q.QuestVariant1)
out.WriteByte(q.QuestVariant2) qb.out.WriteByte(q.QuestVariant2)
out.WriteByte(q.QuestVariant3) qb.out.WriteByte(q.QuestVariant3)
out.WriteByte(q.QuestVariant4) qb.out.WriteByte(q.QuestVariant4)
// +0x9B padding[5] // +0x9B padding[5]
pad(out, 5) pad(qb.out, 5)
// +0xA0 allowedEquipBitmask, points // +0xA0 allowedEquipBitmask, points
writeUint32LE(out, 0) // allowedEquipBitmask writeUint32LE(qb.out, 0) // allowedEquipBitmask
writeUint32LE(out, 0) // mainPoints writeUint32LE(qb.out, 0) // mainPoints
writeUint32LE(out, 0) // subAPoints writeUint32LE(qb.out, 0) // subAPoints
writeUint32LE(out, 0) // subBPoints writeUint32LE(qb.out, 0) // subBPoints
// +0xB0 rewardItems[3] (ItemID = u16, 3 items = 6 bytes) // +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) // +0xB6 interception section (non-SlayAll path: padding[3] + MonsterID[1] = 4 bytes)
pad(out, 4) pad(qb.out, 4)
// +0xBA padding[0xA] = 10 bytes // +0xBA padding[0xA] = 10 bytes
pad(out, 10) pad(qb.out, 10)
// +0xC4 questClearsAllowed // +0xC4 questClearsAllowed
writeUint32LE(out, 0) writeUint32LE(qb.out, 0)
// +0xC8 = 200 bytes so far for documented fields. ZZ body = 320 bytes. // +0xC8 = 200 bytes so far for documented fields. ZZ body = 320 bytes.
// Zero-pad the remaining unknown ZZ-specific fields. // Zero-pad the remaining unknown ZZ-specific fields.
writtenInMain := out.Len() - mainStart writtenInMain := qb.out.Len() - mainStart
if writtenInMain < mainPropSize { if writtenInMain < mainPropSize {
pad(out, mainPropSize-writtenInMain) pad(qb.out, mainPropSize-writtenInMain)
} else if writtenInMain > mainPropSize { } else if writtenInMain > mainPropSize {
return nil, fmt.Errorf("mainQuestProperties overflowed: wrote %d, max %d", writtenInMain, mainPropSize) return nil, fmt.Errorf("mainQuestProperties overflowed: wrote %d, max %d", writtenInMain, mainPropSize)
} }
if out.Len() != int(questTypeFlagsPtr)+mainPropSize { if qb.out.Len() != int(questTypeFlagsPtr)+mainPropSize {
return nil, fmt.Errorf("main prop end mismatch: at %d, want %d", 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) ─────────────────────────────── // ── QuestText pointer table (32 bytes) ───────────────────────────────
for _, ptr := range stringPtrs { for _, ptr := range stringPtrs {
writeUint32LE(out, ptr) writeUint32LE(qb.out, ptr)
} }
// ── String data ────────────────────────────────────────────────────── // ── String data ──────────────────────────────────────────────────────
for _, s := range sjisStrings { for _, s := range sjisStrings {
out.Write(s) qb.out.Write(s)
} }
// Pad to afterStrings alignment. // Pad to afterStrings alignment.
for uint32(out.Len()) < afterStrings { for uint32(qb.out.Len()) < afterStrings {
out.WriteByte(0) qb.out.WriteByte(0)
} }
// ── Stages ─────────────────────────────────────────────────────────── // ── Stages ───────────────────────────────────────────────────────────
// Each Stage: u32 stageID + 12 bytes padding = 16 bytes.
for _, st := range q.Stages { for _, st := range q.Stages {
writeUint32LE(out, st.StageID) writeUint32LE(qb.out, st.StageID)
pad(out, 12) pad(qb.out, 12)
} }
for uint32(out.Len()) < afterStages { for uint32(qb.out.Len()) < afterStages {
out.WriteByte(0) qb.out.WriteByte(0)
} }
// ── Supply Box ─────────────────────────────────────────────────────── // ── Supply Box ───────────────────────────────────────────────────────
// Three sections: main (24 slots), subA (8 slots), subB (8 slots).
type slot struct { type slot struct {
items []QuestSupplyItemJSON items []QuestSupplyItemJSON
max int max int
@@ -542,29 +720,323 @@ func CompileQuestJSON(data []byte) ([]byte, error) {
if written >= section.max { if written >= section.max {
break break
} }
writeUint16LE(out, item.Item) writeUint16LE(qb.out, item.Item)
writeUint16LE(out, item.Quantity) writeUint16LE(qb.out, item.Quantity)
written++ written++
} }
// Pad remaining slots with zeros.
for written < section.max { for written < section.max {
writeUint32LE(out, 0) writeUint32LE(qb.out, 0)
written++ written++
} }
} }
// ── Reward Tables ──────────────────────────────────────────────────── // ── Reward Tables ────────────────────────────────────────────────────
// Written immediately after the supply box (at rewardPtr), then padded qb.out.Write(rewardBuf)
// to 4-byte alignment before the monster spawn list. for uint32(qb.out.Len()) < largeMonsterPtr {
out.Write(rewardBuf) qb.out.WriteByte(0)
for uint32(out.Len()) < largeMonsterPtr {
out.WriteByte(0)
} }
// ── Large Monster Spawns ───────────────────────────────────────────── // ── 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. // buildRewardTables serialises the reward table array and all reward item lists.
@@ -587,7 +1059,6 @@ func buildRewardTables(tables []QuestRewardTableJSON) []byte {
for _, t := range tables { for _, t := range tables {
// tableOffset is relative to the start of rewardPtr in the file. // tableOffset is relative to the start of rewardPtr in the file.
// We compute it as headerArraySize + offset into itemData.
tableOffset := headerArraySize + uint32(itemData.Len()) tableOffset := headerArraySize + uint32(itemData.Len())
headers.WriteByte(t.TableID) headers.WriteByte(t.TableID)

View File

@@ -27,6 +27,9 @@ func ParseQuestBinary(data []byte) (*QuestJSON, error) {
u16 := func(off int) uint16 { u16 := func(off int) uint16 {
return binary.LittleEndian.Uint16(data[off:]) return binary.LittleEndian.Uint16(data[off:])
} }
i16 := func(off int) int16 {
return int16(binary.LittleEndian.Uint16(data[off:]))
}
u32 := func(off int) uint32 { u32 := func(off int) uint32 {
return binary.LittleEndian.Uint32(data[off:]) return binary.LittleEndian.Uint32(data[off:])
} }
@@ -34,7 +37,7 @@ func ParseQuestBinary(data []byte) (*QuestJSON, error) {
return math.Float32frombits(binary.LittleEndian.Uint32(data[off:])) return math.Float32frombits(binary.LittleEndian.Uint32(data[off:]))
} }
// bounds checks a read of n bytes at off. // check bounds-checks a read of n bytes at off.
check := func(off, n int, ctx string) error { check := func(off, n int, ctx string) error {
if off < 0 || off+n > len(data) { 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 fmt.Errorf("%s: offset 0x%X len %d out of bounds (file len %d)", ctx, off, n, len(data))
@@ -70,19 +73,16 @@ func ParseQuestBinary(data []byte) (*QuestJSON, error) {
loadedStagesPtr := int(u32(0x04)) loadedStagesPtr := int(u32(0x04))
supplyBoxPtr := int(u32(0x08)) supplyBoxPtr := int(u32(0x08))
rewardPtr := int(u32(0x0C)) rewardPtr := int(u32(0x0C))
// 0x10 subSupplyBoxPtr (u16), 0x12 hidden, 0x13 subSupplyBoxLen — not in QuestJSON questAreaPtr := int(u32(0x14))
// 0x14 questAreaPtr — null, not parsed
largeMonsterPtr := int(u32(0x18)) largeMonsterPtr := int(u32(0x18))
// 0x1C areaTransitionsPtr — null, not parsed areaTransitionsPtr := int(u32(0x1C))
// 0x20 areaMappingPtr — null, not parsed areaMappingPtr := int(u32(0x20))
// 0x24 mapInfoPtr — null, not parsed mapInfoPtr := int(u32(0x24))
// 0x28 gatheringPointsPtr — null, not parsed gatheringPointsPtr := int(u32(0x28))
// 0x2C areaFacilitiesPtr — null, not parsed areaFacilitiesPtr := int(u32(0x2C))
// 0x30 someStringsPtr — null, not parsed someStringsPtr := int(u32(0x30))
unk34Ptr := int(u32(0x34)) // stages-end sentinel unk34Ptr := int(u32(0x34)) // stages-end sentinel
// 0x38 gatheringTablesPtr — null, not parsed gatheringTablesPtr := int(u32(0x38))
// 0x3C fixedCoords2Ptr — null, not parsed
// 0x40 fixedInfoPtr — null, not parsed
// ── General Quest Properties (0x440x85) ──────────────────────────── // ── General Quest Properties (0x440x85) ────────────────────────────
q.MonsterSizeMulti = u16(0x44) q.MonsterSizeMulti = u16(0x44)
@@ -95,7 +95,12 @@ func ParseQuestBinary(data []byte) (*QuestJSON, error) {
// 0x5C questTypeID/unknown — skipped // 0x5C questTypeID/unknown — skipped
// 0x60 padding // 0x60 padding
q.StatTable2 = u8(0x61) q.StatTable2 = u8(0x61)
// 0x620x85 padding, questKn1/2/3, gatheringTablesQty, zone counts, unknowns — skipped // 0x620x72 padding
// 0x73 questKn1, 0x74 questKn2, 0x76 questKn3 — skipped
gatheringTablesQty := int(u16(0x78))
// 0x7A unknown
area1Zones := int(u8(0x7C))
// 0x7D0x7F area24Zones (not needed for parsing)
// ── Main Quest Properties (at questTypeFlagsPtr, 320 bytes) ───────── // ── Main Quest Properties (at questTypeFlagsPtr, 320 bytes) ─────────
if questTypeFlagsPtr == 0 { if questTypeFlagsPtr == 0 {
@@ -107,26 +112,16 @@ func ParseQuestBinary(data []byte) (*QuestJSON, error) {
mp := questTypeFlagsPtr // shorthand mp := questTypeFlagsPtr // shorthand
// +0x08 rankBand
q.RankBand = u16(mp + 0x08) q.RankBand = u16(mp + 0x08)
// +0x0C questFee
q.Fee = u32(mp + 0x0C) q.Fee = u32(mp + 0x0C)
// +0x10 rewardMain
q.RewardMain = u32(mp + 0x10) q.RewardMain = u32(mp + 0x10)
// +0x18 rewardA
q.RewardSubA = u16(mp + 0x18) q.RewardSubA = u16(mp + 0x18)
// +0x1C rewardB
q.RewardSubB = u16(mp + 0x1C) q.RewardSubB = u16(mp + 0x1C)
// +0x1E hardHRReq
q.HardHRReq = u16(mp + 0x1E) q.HardHRReq = u16(mp + 0x1E)
// +0x20 questTime (frames at 30 Hz → minutes)
questFrames := u32(mp + 0x20) questFrames := u32(mp + 0x20)
q.TimeLimitMinutes = questFrames / (60 * 30) q.TimeLimitMinutes = questFrames / (60 * 30)
// +0x24 questMap
q.Map = u32(mp + 0x24) q.Map = u32(mp + 0x24)
// +0x28 questStringsPtr (absolute file offset)
questStringsPtr := int(u32(mp + 0x28)) questStringsPtr := int(u32(mp + 0x28))
// +0x2E questID
q.QuestID = u16(mp + 0x2E) q.QuestID = u16(mp + 0x2E)
// +0x30 objectives[3] (8 bytes each) // +0x30 objectives[3] (8 bytes each)
@@ -161,8 +156,6 @@ func ParseQuestBinary(data []byte) (*QuestJSON, error) {
if err := check(questStringsPtr, 32, "questTextTable"); err != nil { if err := check(questStringsPtr, 32, "questTextTable"); err != nil {
return nil, err return nil, err
} }
// 8 pointers × 4 bytes: title, textMain, textSubA, textSubB,
// successCond, failCond, contractor, description.
strPtrs := make([]int, 8) strPtrs := make([]int, 8)
for i := range strPtrs { for i := range strPtrs {
strPtrs[i] = int(u32(questStringsPtr + i*4)) strPtrs[i] = int(u32(questStringsPtr + i*4))
@@ -189,8 +182,6 @@ func ParseQuestBinary(data []byte) (*QuestJSON, error) {
} }
// ── Stages ─────────────────────────────────────────────────────────── // ── Stages ───────────────────────────────────────────────────────────
// Guarded by loadedStagesPtr; terminated when we reach unk34Ptr.
// Each stage: u32 stageID + 12 bytes padding = 16 bytes.
if loadedStagesPtr != 0 && unk34Ptr > loadedStagesPtr { if loadedStagesPtr != 0 && unk34Ptr > loadedStagesPtr {
off := loadedStagesPtr off := loadedStagesPtr
for off+16 <= unk34Ptr { for off+16 <= unk34Ptr {
@@ -204,7 +195,6 @@ func ParseQuestBinary(data []byte) (*QuestJSON, error) {
} }
// ── Supply Box ─────────────────────────────────────────────────────── // ── Supply Box ───────────────────────────────────────────────────────
// Guarded by supplyBoxPtr. Layout: main(24) + subA(8) + subB(8) × 4 bytes each.
if supplyBoxPtr != 0 { if supplyBoxPtr != 0 {
const supplyBoxSize = (24 + 8 + 8) * 4 const supplyBoxSize = (24 + 8 + 8) * 4
if err := check(supplyBoxPtr, supplyBoxSize, "supplyBox"); err != nil { if err := check(supplyBoxPtr, supplyBoxSize, "supplyBox"); err != nil {
@@ -216,8 +206,6 @@ func ParseQuestBinary(data []byte) (*QuestJSON, error) {
} }
// ── Reward Tables ──────────────────────────────────────────────────── // ── Reward Tables ────────────────────────────────────────────────────
// Guarded by rewardPtr. Header array terminated by int16(-1); item lists
// each terminated by int16(-1).
if rewardPtr != 0 { if rewardPtr != 0 {
tables, err := parseRewardTables(data, rewardPtr) tables, err := parseRewardTables(data, rewardPtr)
if err != nil { if err != nil {
@@ -227,7 +215,6 @@ func ParseQuestBinary(data []byte) (*QuestJSON, error) {
} }
// ── Large Monster Spawns ───────────────────────────────────────────── // ── Large Monster Spawns ─────────────────────────────────────────────
// Guarded by largeMonsterPtr. Each entry is 60 bytes; terminated by 0xFF.
if largeMonsterPtr != 0 { if largeMonsterPtr != 0 {
monsters, err := parseMonsterSpawns(data, largeMonsterPtr, f32) monsters, err := parseMonsterSpawns(data, largeMonsterPtr, f32)
if err != nil { if err != nil {
@@ -236,6 +223,109 @@ func ParseQuestBinary(data []byte) (*QuestJSON, error) {
q.LargeMonsters = monsters q.LargeMonsters = monsters
} }
// ── Map Sections (questAreaPtr) ──────────────────────────────────────
// Layout: u32 ptr[] terminated by u32(0), then each mapSection:
// u32 loadedStage, u32 unk, u32 spawnTypesPtr, u32 spawnStatsPtr,
// u32(0) gap, u16 unk — then spawnTypes and spawnStats data.
if questAreaPtr != 0 {
sections, err := parseMapSections(data, questAreaPtr, u32, u16, f32)
if err != nil {
return nil, err
}
q.MapSections = sections
}
// ── Area Mappings (areaMappingPtr) ────────────────────────────────────
// Read AreaMappings until reaching areaTransitionsPtr (or end of file
// if areaTransitionsPtr is null). Each entry is 32 bytes.
if areaMappingPtr != 0 {
endOff := len(data)
if areaTransitionsPtr != 0 {
endOff = areaTransitionsPtr
}
mappings, err := parseAreaMappings(data, areaMappingPtr, endOff, f32)
if err != nil {
return nil, err
}
q.AreaMappings = mappings
}
// ── Area Transitions (areaTransitionsPtr) ─────────────────────────────
// playerAreaChange[area1Zones]: one u32 ptr per zone.
if areaTransitionsPtr != 0 && area1Zones > 0 {
transitions, err := parseAreaTransitions(data, areaTransitionsPtr, area1Zones, u32, i16, f32)
if err != nil {
return nil, err
}
q.AreaTransitions = transitions
}
// ── Map Info (mapInfoPtr) ─────────────────────────────────────────────
if mapInfoPtr != 0 {
if err := check(mapInfoPtr, 8, "mapInfo"); err != nil {
return nil, err
}
q.MapInfo = &QuestMapInfoJSON{
MapID: u32(mapInfoPtr),
ReturnBCID: u32(mapInfoPtr + 4),
}
}
// ── Gathering Points (gatheringPointsPtr) ─────────────────────────────
// ptGatheringPoint[area1Zones]: one u32 ptr per zone.
if gatheringPointsPtr != 0 && area1Zones > 0 {
gatherPts, err := parseGatheringPoints(data, gatheringPointsPtr, area1Zones, u32, u16, f32)
if err != nil {
return nil, err
}
q.GatheringPoints = gatherPts
}
// ── Area Facilities (areaFacilitiesPtr) ───────────────────────────────
// ptVar<facPointBlock>[area1Zones]: one u32 ptr per zone.
if areaFacilitiesPtr != 0 && area1Zones > 0 {
facilities, err := parseAreaFacilities(data, areaFacilitiesPtr, area1Zones, u32, u16, f32)
if err != nil {
return nil, err
}
q.AreaFacilities = facilities
}
// ── Some Strings (someStringsPtr / unk30) ─────────────────────────────
// Layout: ptr someStringPtr, ptr questTypePtr (8 bytes at someStringsPtr).
if someStringsPtr != 0 {
if err := check(someStringsPtr, 8, "someStrings"); err != nil {
return nil, err
}
someStrP := int(u32(someStringsPtr))
questTypeP := int(u32(someStringsPtr + 4))
if someStrP != 0 {
s, err := readSJIS(someStrP)
if err != nil {
return nil, fmt.Errorf("someString: %w", err)
}
q.SomeString = s
}
if questTypeP != 0 {
s, err := readSJIS(questTypeP)
if err != nil {
return nil, fmt.Errorf("questTypeString: %w", err)
}
q.QuestType = s
}
}
// ── Gathering Tables (gatheringTablesPtr) ─────────────────────────────
// ptVar<gatheringTable>[gatheringTablesQty]: one u32 ptr per table.
// GatherItem: u16 rate + u16 item, terminated by u16(0xFFFF).
if gatheringTablesPtr != 0 && gatheringTablesQty > 0 {
tables, err := parseGatheringTables(data, gatheringTablesPtr, gatheringTablesQty, u32, u16)
if err != nil {
return nil, err
}
q.GatheringTables = tables
}
return q, nil return q, nil
} }
@@ -327,7 +417,6 @@ func parseRewardTables(data []byte, baseOff int) ([]QuestRewardTableJSON, error)
if off+2 > len(data) { if off+2 > len(data) {
return nil, fmt.Errorf("reward table header truncated at 0x%X", off) return nil, fmt.Errorf("reward table header truncated at 0x%X", off)
} }
// Check for terminator (0xFFFF).
if binary.LittleEndian.Uint16(data[off:]) == 0xFFFF { if binary.LittleEndian.Uint16(data[off:]) == 0xFFFF {
break break
} }
@@ -338,7 +427,6 @@ func parseRewardTables(data []byte, baseOff int) ([]QuestRewardTableJSON, error)
tableOff := int(binary.LittleEndian.Uint32(data[off+4:])) + baseOff tableOff := int(binary.LittleEndian.Uint32(data[off+4:])) + baseOff
off += 8 off += 8
// Read items at tableOff.
items, err := parseRewardItems(data, tableOff) items, err := parseRewardItems(data, tableOff)
if err != nil { if err != nil {
return nil, fmt.Errorf("reward table %d items: %w", tableID, err) return nil, fmt.Errorf("reward table %d items: %w", tableID, err)
@@ -403,6 +491,347 @@ func parseMonsterSpawns(data []byte, baseOff int, f32fn func(int) float32) ([]Qu
return monsters, nil return monsters, nil
} }
// parseMapSections reads the MapZones structure at baseOff.
// Layout: u32 ptr[] terminated by u32(0); each ptr points to a mapSection:
//
// u32 loadedStage, u32 unk, u32 spawnTypesPtr, u32 spawnStatsPtr.
//
// After the 16-byte mapSection: u32(0) gap + u16 unk (2 bytes).
// spawnTypes: varPaddT<MonsterID,3> = u8+pad[3] per entry, terminated by 0xFFFF.
// spawnStats: MinionSpawn (60 bytes) per entry, terminated by 0xFFFF in first 2 bytes.
func parseMapSections(data []byte, baseOff int,
u32fn func(int) uint32,
u16fn func(int) uint16,
f32fn func(int) float32,
) ([]QuestMapSectionJSON, error) {
var sections []QuestMapSectionJSON
// Read pointer array (terminated by u32(0)).
off := baseOff
for {
if off+4 > len(data) {
return nil, fmt.Errorf("mapSection pointer array truncated at 0x%X", off)
}
ptr := int(u32fn(off))
off += 4
if ptr == 0 {
break
}
// Read mapSection at ptr.
if ptr+16 > len(data) {
return nil, fmt.Errorf("mapSection at 0x%X truncated", ptr)
}
loadedStage := u32fn(ptr)
// ptr+4 is unk u32 — skip
spawnTypesPtr := int(u32fn(ptr + 8))
spawnStatsPtr := int(u32fn(ptr + 12))
ms := QuestMapSectionJSON{LoadedStage: loadedStage}
// Read spawnTypes: varPaddT<MonsterID,3> terminated by 0xFFFF.
if spawnTypesPtr != 0 {
stOff := spawnTypesPtr
for {
if stOff+2 > len(data) {
return nil, fmt.Errorf("spawnTypes at 0x%X truncated", stOff)
}
if u16fn(stOff) == 0xFFFF {
break
}
if stOff+4 > len(data) {
return nil, fmt.Errorf("spawnType entry at 0x%X truncated", stOff)
}
monID := data[stOff]
ms.SpawnMonsters = append(ms.SpawnMonsters, monID)
stOff += 4 // u8 + pad[3]
}
}
// Read spawnStats: MinionSpawn terminated by 0xFFFF in first 2 bytes.
if spawnStatsPtr != 0 {
const minionSize = 60
ssOff := spawnStatsPtr
for {
if ssOff+2 > len(data) {
return nil, fmt.Errorf("spawnStats at 0x%X truncated", ssOff)
}
// Terminator: first 2 bytes == 0xFFFF.
if u16fn(ssOff) == 0xFFFF {
break
}
if ssOff+minionSize > len(data) {
return nil, fmt.Errorf("minionSpawn at 0x%X truncated", ssOff)
}
spawn := QuestMinionSpawnJSON{
Monster: data[ssOff],
// ssOff+1 padding
SpawnToggle: u16fn(ssOff + 2),
SpawnAmount: u32fn(ssOff + 4),
// +8 unk u32, +0xC pad[16], +0x1C unk u32
X: f32fn(ssOff + 0x20),
Y: f32fn(ssOff + 0x24),
Z: f32fn(ssOff + 0x28),
}
ms.MinionSpawns = append(ms.MinionSpawns, spawn)
ssOff += minionSize
}
}
sections = append(sections, ms)
}
return sections, nil
}
// parseAreaMappings reads AreaMappings entries at baseOff until endOff.
// Each entry is 32 bytes: float areaX, float areaZ, pad[8],
// float baseX, float baseZ, float knPos, pad[4].
func parseAreaMappings(data []byte, baseOff, endOff int, f32fn func(int) float32) ([]QuestAreaMappingJSON, error) {
var mappings []QuestAreaMappingJSON
const entrySize = 32
off := baseOff
for off+entrySize <= endOff {
if off+entrySize > len(data) {
return nil, fmt.Errorf("areaMapping at 0x%X truncated", off)
}
am := QuestAreaMappingJSON{
AreaX: f32fn(off),
AreaZ: f32fn(off + 4),
// off+8: pad[8]
BaseX: f32fn(off + 16),
BaseZ: f32fn(off + 20),
KnPos: f32fn(off + 24),
// off+28: pad[4]
}
mappings = append(mappings, am)
off += entrySize
}
return mappings, nil
}
// parseAreaTransitions reads playerAreaChange[numZones] at baseOff.
// Each entry is a u32 pointer to a floatSet array terminated by s16(-1).
// floatSet: s16 targetStageId + s16 stageVariant + float[3] current + float[5] box +
// float[3] target + s16[2] rotation = 52 bytes.
func parseAreaTransitions(data []byte, baseOff, numZones int,
u32fn func(int) uint32,
i16fn func(int) int16,
f32fn func(int) float32,
) ([]QuestAreaTransitionsJSON, error) {
result := make([]QuestAreaTransitionsJSON, numZones)
if baseOff+numZones*4 > len(data) {
return nil, fmt.Errorf("areaTransitions pointer array at 0x%X truncated", baseOff)
}
for i := 0; i < numZones; i++ {
ptr := int(u32fn(baseOff + i*4))
if ptr == 0 {
// Null pointer — no transitions for this zone.
continue
}
// Read floatSet entries until targetStageId1 == -1.
var transitions []QuestAreaTransitionJSON
off := ptr
for {
if off+2 > len(data) {
return nil, fmt.Errorf("floatSet at 0x%X truncated", off)
}
targetStageID := i16fn(off)
if targetStageID == -1 {
break
}
// Each floatSet is 52 bytes:
// s16 targetStageId1 + s16 stageVariant = 4
// float[3] current = 12
// float[5] transitionBox = 20
// float[3] target = 12
// s16[2] rotation = 4
// Total = 52
const floatSetSize = 52
if off+floatSetSize > len(data) {
return nil, fmt.Errorf("floatSet at 0x%X truncated (need %d bytes)", off, floatSetSize)
}
tr := QuestAreaTransitionJSON{
TargetStageID1: targetStageID,
StageVariant: i16fn(off + 2),
CurrentX: f32fn(off + 4),
CurrentY: f32fn(off + 8),
CurrentZ: f32fn(off + 12),
TargetX: f32fn(off + 36),
TargetY: f32fn(off + 40),
TargetZ: f32fn(off + 44),
}
for j := 0; j < 5; j++ {
tr.TransitionBox[j] = f32fn(off + 16 + j*4)
}
tr.TargetRotation[0] = i16fn(off + 48)
tr.TargetRotation[1] = i16fn(off + 50)
transitions = append(transitions, tr)
off += floatSetSize
}
result[i] = QuestAreaTransitionsJSON{Transitions: transitions}
}
return result, nil
}
// parseGatheringPoints reads ptGatheringPoint[numZones] at baseOff.
// Each entry is a u32 pointer to gatheringPoint[4] terminated by xPos==-1.0.
// gatheringPoint: float xPos, yPos, zPos, range, u16 gatheringID, u16 maxCount, pad[2], u16 minCount = 24 bytes.
func parseGatheringPoints(data []byte, baseOff, numZones int,
u32fn func(int) uint32,
u16fn func(int) uint16,
f32fn func(int) float32,
) ([]QuestAreaGatheringJSON, error) {
result := make([]QuestAreaGatheringJSON, numZones)
if baseOff+numZones*4 > len(data) {
return nil, fmt.Errorf("gatheringPoints pointer array at 0x%X truncated", baseOff)
}
const sentinel = uint32(0xBF800000) // float32(-1.0)
const pointSize = 24
for i := 0; i < numZones; i++ {
ptr := int(u32fn(baseOff + i*4))
if ptr == 0 {
continue
}
var points []QuestGatheringPointJSON
off := ptr
for {
if off+4 > len(data) {
return nil, fmt.Errorf("gatheringPoint at 0x%X truncated", off)
}
// Terminator: xPos bit pattern == 0xBF800000 (-1.0f).
if binary.LittleEndian.Uint32(data[off:]) == sentinel {
break
}
if off+pointSize > len(data) {
return nil, fmt.Errorf("gatheringPoint entry at 0x%X truncated", off)
}
gp := QuestGatheringPointJSON{
X: f32fn(off),
Y: f32fn(off + 4),
Z: f32fn(off + 8),
Range: f32fn(off + 12),
GatheringID: u16fn(off + 16),
MaxCount: u16fn(off + 18),
// off+20 pad[2]
MinCount: u16fn(off + 22),
}
points = append(points, gp)
off += pointSize
}
result[i] = QuestAreaGatheringJSON{Points: points}
}
return result, nil
}
// parseAreaFacilities reads ptVar<facPointBlock>[numZones] at baseOff.
// Each entry is a u32 pointer to a facPointBlock.
// facPoint: pad[2] + SpecAc(u16) + xPos + yPos + zPos + range + id(u16) + pad[2] = 24 bytes.
// Termination: the loop condition checks read_unsigned($+4,4) != 0xBF800000.
// So a facPoint whose xPos (at offset +4 from start of that potential entry) == -1.0 terminates.
// After all facPoints: padding[0xC] + float + float = 20 bytes (block footer, not parsed into JSON).
func parseAreaFacilities(data []byte, baseOff, numZones int,
u32fn func(int) uint32,
u16fn func(int) uint16,
f32fn func(int) float32,
) ([]QuestAreaFacilitiesJSON, error) {
result := make([]QuestAreaFacilitiesJSON, numZones)
if baseOff+numZones*4 > len(data) {
return nil, fmt.Errorf("areaFacilities pointer array at 0x%X truncated", baseOff)
}
const sentinel = uint32(0xBF800000)
const pointSize = 24
for i := 0; i < numZones; i++ {
ptr := int(u32fn(baseOff + i*4))
if ptr == 0 {
continue
}
var points []QuestFacilityPointJSON
off := ptr
for off+8 <= len(data) {
// Check: read_unsigned($+4, 4) == sentinel means terminate.
// $+4 is the xPos field of the potential next facPoint.
if binary.LittleEndian.Uint32(data[off+4:]) == sentinel {
break
}
if off+pointSize > len(data) {
return nil, fmt.Errorf("facPoint at 0x%X truncated", off)
}
fp := QuestFacilityPointJSON{
// off+0: pad[2]
Type: u16fn(off + 2),
X: f32fn(off + 4),
Y: f32fn(off + 8),
Z: f32fn(off + 12),
Range: f32fn(off + 16),
ID: u16fn(off + 20),
// off+22: pad[2]
}
points = append(points, fp)
off += pointSize
}
result[i] = QuestAreaFacilitiesJSON{Points: points}
}
return result, nil
}
// parseGatheringTables reads ptVar<gatheringTable>[count] at baseOff.
// Each entry is a u32 pointer to GatherItem[] terminated by u16(0xFFFF).
// GatherItem: u16 rate + u16 item = 4 bytes.
func parseGatheringTables(data []byte, baseOff, count int,
u32fn func(int) uint32,
u16fn func(int) uint16,
) ([]QuestGatheringTableJSON, error) {
result := make([]QuestGatheringTableJSON, count)
if baseOff+count*4 > len(data) {
return nil, fmt.Errorf("gatheringTables pointer array at 0x%X truncated", baseOff)
}
for i := 0; i < count; i++ {
ptr := int(u32fn(baseOff + i*4))
if ptr == 0 {
continue
}
var items []QuestGatherItemJSON
off := ptr
for {
if off+2 > len(data) {
return nil, fmt.Errorf("gatheringTable at 0x%X truncated", off)
}
if u16fn(off) == 0xFFFF {
break
}
if off+4 > len(data) {
return nil, fmt.Errorf("gatherItem at 0x%X truncated", off)
}
items = append(items, QuestGatherItemJSON{
Rate: u16fn(off),
Item: u16fn(off + 2),
})
off += 4
}
result[i] = QuestGatheringTableJSON{Items: items}
}
return result, nil
}
// objTypeToString maps a uint32 goal type to its JSON string name. // objTypeToString maps a uint32 goal type to its JSON string name.
// Returns "", false for unknown types. // Returns "", false for unknown types.
func objTypeToString(t uint32) (string, bool) { func objTypeToString(t uint32) (string, bool) {

View File

@@ -520,6 +520,246 @@ func TestRoundTrip_EmptyQuest(t *testing.T) {
roundTrip(t, "empty quest", string(b)) roundTrip(t, "empty quest", string(b))
} }
// ── New section round-trip tests ─────────────────────────────────────────────
func TestRoundTrip_MapSections(t *testing.T) {
var q QuestJSON
_ = json.Unmarshal([]byte(minimalQuestJSON), &q)
q.MapSections = []QuestMapSectionJSON{
{
LoadedStage: 5,
SpawnMonsters: []uint8{0x0F, 0x33}, // Khezu, Blangonga
MinionSpawns: []QuestMinionSpawnJSON{
{Monster: 0x0F, SpawnToggle: 1, SpawnAmount: 3, X: 100.0, Y: 0.0, Z: -200.0},
{Monster: 0x33, SpawnToggle: 1, SpawnAmount: 2, X: 250.0, Y: 5.0, Z: 300.0},
},
},
}
b, _ := json.Marshal(q)
roundTrip(t, "map sections", string(b))
}
func TestRoundTrip_MapSectionsMultiple(t *testing.T) {
var q QuestJSON
_ = json.Unmarshal([]byte(minimalQuestJSON), &q)
q.MapSections = []QuestMapSectionJSON{
{
LoadedStage: 2,
SpawnMonsters: []uint8{0x06},
MinionSpawns: []QuestMinionSpawnJSON{
{Monster: 0x06, SpawnToggle: 1, SpawnAmount: 4, X: 50.0, Y: 0.0, Z: 50.0},
},
},
{
LoadedStage: 3,
SpawnMonsters: nil,
MinionSpawns: nil,
},
}
b, _ := json.Marshal(q)
roundTrip(t, "map sections multiple", string(b))
}
func TestRoundTrip_AreaTransitions(t *testing.T) {
var q QuestJSON
_ = json.Unmarshal([]byte(minimalQuestJSON), &q)
q.AreaTransitions = []QuestAreaTransitionsJSON{
{
Transitions: []QuestAreaTransitionJSON{
{
TargetStageID1: 3,
StageVariant: 0,
CurrentX: 100.0,
CurrentY: 0.0,
CurrentZ: 50.0,
TransitionBox: [5]float32{10.0, 5.0, 10.0, 0.0, 0.0},
TargetX: -100.0,
TargetY: 0.0,
TargetZ: -50.0,
TargetRotation: [2]int16{90, 0},
},
},
},
{
// Zone 2: no transitions (null pointer).
Transitions: nil,
},
}
b, _ := json.Marshal(q)
roundTrip(t, "area transitions", string(b))
}
func TestRoundTrip_AreaMappings(t *testing.T) {
var q QuestJSON
_ = json.Unmarshal([]byte(minimalQuestJSON), &q)
// AreaMappings without AreaTransitions: the parser reads until areaTransitionsPtr,
// which will be null, so it reads until end of file's mapping section. To make
// this round-trip cleanly, add both together.
q.AreaTransitions = []QuestAreaTransitionsJSON{{}, {}}
q.AreaMappings = []QuestAreaMappingJSON{
{AreaX: 100.0, AreaZ: 200.0, BaseX: 10.0, BaseZ: 20.0, KnPos: 5.0},
{AreaX: 300.0, AreaZ: 400.0, BaseX: 30.0, BaseZ: 40.0, KnPos: 7.5},
}
b, _ := json.Marshal(q)
roundTrip(t, "area mappings", string(b))
}
func TestRoundTrip_MapInfo(t *testing.T) {
var q QuestJSON
_ = json.Unmarshal([]byte(minimalQuestJSON), &q)
q.MapInfo = &QuestMapInfoJSON{
MapID: 2,
ReturnBCID: 1,
}
b, _ := json.Marshal(q)
roundTrip(t, "map info", string(b))
}
func TestRoundTrip_GatheringPoints(t *testing.T) {
var q QuestJSON
_ = json.Unmarshal([]byte(minimalQuestJSON), &q)
q.AreaTransitions = []QuestAreaTransitionsJSON{{}, {}}
q.GatheringPoints = []QuestAreaGatheringJSON{
{
Points: []QuestGatheringPointJSON{
{X: 50.0, Y: 0.0, Z: 100.0, Range: 3.0, GatheringID: 5, MaxCount: 3, MinCount: 1},
{X: 150.0, Y: 0.0, Z: 200.0, Range: 3.0, GatheringID: 6, MaxCount: 2, MinCount: 1},
},
},
{
// Zone 2: no gathering points (null pointer).
Points: nil,
},
}
b, _ := json.Marshal(q)
roundTrip(t, "gathering points", string(b))
}
func TestRoundTrip_AreaFacilities(t *testing.T) {
var q QuestJSON
_ = json.Unmarshal([]byte(minimalQuestJSON), &q)
q.AreaTransitions = []QuestAreaTransitionsJSON{{}, {}}
q.AreaFacilities = []QuestAreaFacilitiesJSON{
{
Points: []QuestFacilityPointJSON{
{Type: 1, X: 10.0, Y: 0.0, Z: -5.0, Range: 2.0, ID: 1}, // cooking
{Type: 7, X: 20.0, Y: 0.0, Z: -10.0, Range: 3.0, ID: 2}, // red box
},
},
{
// Zone 2: no facilities (null pointer).
Points: nil,
},
}
b, _ := json.Marshal(q)
roundTrip(t, "area facilities", string(b))
}
func TestRoundTrip_SomeStrings(t *testing.T) {
var q QuestJSON
_ = json.Unmarshal([]byte(minimalQuestJSON), &q)
q.SomeString = "extra info"
q.QuestType = "standard"
b, _ := json.Marshal(q)
roundTrip(t, "some strings", string(b))
}
func TestRoundTrip_SomeStringOnly(t *testing.T) {
var q QuestJSON
_ = json.Unmarshal([]byte(minimalQuestJSON), &q)
q.SomeString = "only this string"
b, _ := json.Marshal(q)
roundTrip(t, "some string only", string(b))
}
func TestRoundTrip_GatheringTables(t *testing.T) {
var q QuestJSON
_ = json.Unmarshal([]byte(minimalQuestJSON), &q)
q.GatheringTables = []QuestGatheringTableJSON{
{
Items: []QuestGatherItemJSON{
{Rate: 50, Item: 100},
{Rate: 30, Item: 101},
{Rate: 20, Item: 102},
},
},
{
Items: []QuestGatherItemJSON{
{Rate: 100, Item: 200},
},
},
}
b, _ := json.Marshal(q)
roundTrip(t, "gathering tables", string(b))
}
func TestRoundTrip_AllSections(t *testing.T) {
var q QuestJSON
_ = json.Unmarshal([]byte(minimalQuestJSON), &q)
q.MapSections = []QuestMapSectionJSON{
{
LoadedStage: 5,
SpawnMonsters: []uint8{0x0F},
MinionSpawns: []QuestMinionSpawnJSON{
{Monster: 0x0F, SpawnToggle: 1, SpawnAmount: 2, X: 100.0, Y: 0.0, Z: -100.0},
},
},
}
q.AreaTransitions = []QuestAreaTransitionsJSON{
{
Transitions: []QuestAreaTransitionJSON{
{
TargetStageID1: 2,
StageVariant: 0,
CurrentX: 50.0,
CurrentY: 0.0,
CurrentZ: 25.0,
TransitionBox: [5]float32{5.0, 5.0, 5.0, 0.0, 0.0},
TargetX: -50.0,
TargetY: 0.0,
TargetZ: -25.0,
TargetRotation: [2]int16{180, 0},
},
},
},
{Transitions: nil},
}
q.AreaMappings = []QuestAreaMappingJSON{
{AreaX: 100.0, AreaZ: 200.0, BaseX: 10.0, BaseZ: 20.0, KnPos: 1.0},
}
q.MapInfo = &QuestMapInfoJSON{MapID: 2, ReturnBCID: 0}
q.GatheringPoints = []QuestAreaGatheringJSON{
{
Points: []QuestGatheringPointJSON{
{X: 75.0, Y: 0.0, Z: 150.0, Range: 2.5, GatheringID: 3, MaxCount: 3, MinCount: 1},
},
},
{Points: nil},
}
q.AreaFacilities = []QuestAreaFacilitiesJSON{
{
Points: []QuestFacilityPointJSON{
{Type: 3, X: 5.0, Y: 0.0, Z: -5.0, Range: 2.0, ID: 10},
},
},
{Points: nil},
}
q.SomeString = "test string"
q.QuestType = "hunt"
q.GatheringTables = []QuestGatheringTableJSON{
{
Items: []QuestGatherItemJSON{
{Rate: 60, Item: 300},
{Rate: 40, Item: 301},
},
},
}
b, _ := json.Marshal(q)
roundTrip(t, "all sections", string(b))
}
// ── Golden file test ───────────────────────────────────────────────────────── // ── Golden file test ─────────────────────────────────────────────────────────
// //
// This test manually constructs expected binary bytes at specific offsets and // This test manually constructs expected binary bytes at specific offsets and
@@ -527,11 +767,11 @@ func TestRoundTrip_EmptyQuest(t *testing.T) {
// Hard-coded values are derived from the documented binary layout. // Hard-coded values are derived from the documented binary layout.
// //
// Layout constants for minimalQuestJSON: // Layout constants for minimalQuestJSON:
// headerSize = 68 (0x44) //
// genPropSize = 66 (0x42) // headerSize = 68 (0x44)
// mainPropOffset = 0x86 (= headerSize + genPropSize) // genPropSize = 66 (0x42)
// questStringsPtr = 0x1C6 (= mainPropOffset + 320) // mainPropOffset = 0x86 (= headerSize + genPropSize)
// questStringsPtr = 0x1C6 (= mainPropOffset + 320)
func TestGolden_MinimalQuestBinaryLayout(t *testing.T) { func TestGolden_MinimalQuestBinaryLayout(t *testing.T) {
data, err := CompileQuestJSON([]byte(minimalQuestJSON)) data, err := CompileQuestJSON([]byte(minimalQuestJSON))
if err != nil { if err != nil {
@@ -545,9 +785,6 @@ func TestGolden_MinimalQuestBinaryLayout(t *testing.T) {
// ── Header (0x000x43) ─────────────────────────────────────────────── // ── Header (0x000x43) ───────────────────────────────────────────────
assertU32(t, data, 0x00, mainPropOffset, "questTypeFlagsPtr") 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)") assertU16(t, data, 0x10, 0, "subSupplyBoxPtr (unused)")
assertByte(t, data, 0x12, 0, "hidden") assertByte(t, data, 0x12, 0, "hidden")
assertByte(t, data, 0x13, 0, "subSupplyBoxLen") assertByte(t, data, 0x13, 0, "subSupplyBoxLen")
@@ -562,8 +799,6 @@ func TestGolden_MinimalQuestBinaryLayout(t *testing.T) {
assertU32(t, data, 0x3C, 0, "fixedCoords2Ptr (null)") assertU32(t, data, 0x3C, 0, "fixedCoords2Ptr (null)")
assertU32(t, data, 0x40, 0, "fixedInfoPtr (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:]) loadedStagesPtr := binary.LittleEndian.Uint32(data[0x04:])
unk34Ptr := binary.LittleEndian.Uint32(data[0x34:]) unk34Ptr := binary.LittleEndian.Uint32(data[0x34:])
if unk34Ptr != loadedStagesPtr+16 { if unk34Ptr != loadedStagesPtr+16 {
@@ -620,14 +855,12 @@ func TestGolden_MinimalQuestBinaryLayout(t *testing.T) {
assertU16(t, data, mp+0x2E, 1, "mp.questID") assertU16(t, data, mp+0x2E, 1, "mp.questID")
// Objective[0]: hunt, target=11, count=1 // 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") assertU32(t, data, mp+0x30, questObjHunt, "obj[0].goalType")
assertByte(t, data, mp+0x34, 11, "obj[0].target") assertByte(t, data, mp+0x34, 11, "obj[0].target")
assertByte(t, data, mp+0x35, 0, "obj[0].pad") assertByte(t, data, mp+0x35, 0, "obj[0].pad")
assertU16(t, data, mp+0x36, 1, "obj[0].count") assertU16(t, data, mp+0x36, 1, "obj[0].count")
// Objective[1]: deliver, target=149, count=3 // Objective[1]: deliver, target=149, count=3
// goalType=0x00000002, u16(target)=0x0095, u16(count)=0x0003
assertU32(t, data, mp+0x38, questObjDeliver, "obj[1].goalType") assertU32(t, data, mp+0x38, questObjDeliver, "obj[1].goalType")
assertU16(t, data, mp+0x3C, 149, "obj[1].target") assertU16(t, data, mp+0x3C, 149, "obj[1].target")
assertU16(t, data, mp+0x3E, 3, "obj[1].count") assertU16(t, data, mp+0x3E, 3, "obj[1].count")
@@ -641,7 +874,7 @@ func TestGolden_MinimalQuestBinaryLayout(t *testing.T) {
assertU16(t, data, mp+0x50, 0, "mp.postRankMin") assertU16(t, data, mp+0x50, 0, "mp.postRankMin")
assertU16(t, data, mp+0x52, 0, "mp.postRankMax") assertU16(t, data, mp+0x52, 0, "mp.postRankMax")
// forced equip: 6 slots × 4 × 2 = 48 bytes, all zero (no ForcedEquipment in minimalQuestJSON) // forced equip: 6 slots × 4 × 2 = 48 bytes, all zero
for i := 0; i < 48; i++ { for i := 0; i < 48; i++ {
assertByte(t, data, mp+0x5C+i, 0, "forced equip zero") assertByte(t, data, mp+0x5C+i, 0, "forced equip zero")
} }
@@ -652,8 +885,6 @@ func TestGolden_MinimalQuestBinaryLayout(t *testing.T) {
assertByte(t, data, mp+0x9A, 0, "mp.questVariant4") assertByte(t, data, mp+0x9A, 0, "mp.questVariant4")
// ── QuestText pointer table (0x1C60x1E5) ─────────────────────────── // ── 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++ { for i := 0; i < 8; i++ {
off := int(questStringsPtr) + i*4 off := int(questStringsPtr) + i*4
strPtr := int(binary.LittleEndian.Uint32(data[off:])) strPtr := int(binary.LittleEndian.Uint32(data[off:]))
@@ -662,7 +893,7 @@ func TestGolden_MinimalQuestBinaryLayout(t *testing.T) {
} }
} }
// Title pointer → "Test Quest" (ASCII = valid Shift-JIS) // Title pointer → "Test Quest"
titlePtr := int(binary.LittleEndian.Uint32(data[int(questStringsPtr):])) titlePtr := int(binary.LittleEndian.Uint32(data[int(questStringsPtr):]))
end := titlePtr end := titlePtr
for end < len(data) && data[end] != 0 { for end < len(data) && data[end] != 0 {
@@ -674,7 +905,6 @@ func TestGolden_MinimalQuestBinaryLayout(t *testing.T) {
// ── Stage entry (1 stage: stageID=2) ──────────────────────────────── // ── Stage entry (1 stage: stageID=2) ────────────────────────────────
assertU32(t, data, int(loadedStagesPtr), 2, "stage[0].stageID") assertU32(t, data, int(loadedStagesPtr), 2, "stage[0].stageID")
// padding 12 bytes after stageID must be zero
for i := 1; i < 16; i++ { for i := 1; i < 16; i++ {
assertByte(t, data, int(loadedStagesPtr)+i, 0, "stage padding") assertByte(t, data, int(loadedStagesPtr)+i, 0, "stage padding")
} }
@@ -683,55 +913,43 @@ func TestGolden_MinimalQuestBinaryLayout(t *testing.T) {
supplyBoxPtr := int(binary.LittleEndian.Uint32(data[0x08:])) supplyBoxPtr := int(binary.LittleEndian.Uint32(data[0x08:]))
assertU16(t, data, supplyBoxPtr, 1, "supply_main[0].item") assertU16(t, data, supplyBoxPtr, 1, "supply_main[0].item")
assertU16(t, data, supplyBoxPtr+2, 5, "supply_main[0].quantity") assertU16(t, data, supplyBoxPtr+2, 5, "supply_main[0].quantity")
// Remaining 23 main slots must be zero.
for i := 1; i < 24; i++ { for i := 1; i < 24; i++ {
assertU32(t, data, supplyBoxPtr+i*4, 0, "supply_main slot empty") assertU32(t, data, supplyBoxPtr+i*4, 0, "supply_main slot empty")
} }
// All 8 subA slots zero.
subABase := supplyBoxPtr + 24*4 subABase := supplyBoxPtr + 24*4
for i := 0; i < 8; i++ { for i := 0; i < 8; i++ {
assertU32(t, data, subABase+i*4, 0, "supply_subA slot empty") assertU32(t, data, subABase+i*4, 0, "supply_subA slot empty")
} }
// All 8 subB slots zero.
subBBase := subABase + 8*4 subBBase := subABase + 8*4
for i := 0; i < 8; i++ { for i := 0; i < 8; i++ {
assertU32(t, data, subBBase+i*4, 0, "supply_subB slot empty") assertU32(t, data, subBBase+i*4, 0, "supply_subB slot empty")
} }
// ── Reward table ──────────────────────────────────────────────────── // ── 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:])) rewardPtr := int(binary.LittleEndian.Uint32(data[0x0C:]))
assertByte(t, data, rewardPtr, 1, "reward header[0].tableID") assertByte(t, data, rewardPtr, 1, "reward header[0].tableID")
assertByte(t, data, rewardPtr+1, 0, "reward header[0].pad1") assertByte(t, data, rewardPtr+1, 0, "reward header[0].pad1")
assertU16(t, data, rewardPtr+2, 0, "reward header[0].pad2") assertU16(t, data, rewardPtr+2, 0, "reward header[0].pad2")
// headerArraySize = 1×8 + 2 = 10 // headerArraySize = 1×8 + 2 = 10
assertU32(t, data, rewardPtr+4, 10, "reward header[0].tableOffset") assertU32(t, data, rewardPtr+4, 10, "reward header[0].tableOffset")
// terminator at rewardPtr+8
assertU16(t, data, rewardPtr+8, 0xFFFF, "reward header terminator") assertU16(t, data, rewardPtr+8, 0xFFFF, "reward header terminator")
// item 0: rate=50, item=149, qty=1
itemsBase := rewardPtr + 10 itemsBase := rewardPtr + 10
assertU16(t, data, itemsBase, 50, "reward[0].items[0].rate") 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+2, 149, "reward[0].items[0].item")
assertU16(t, data, itemsBase+4, 1, "reward[0].items[0].quantity") 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+6, 30, "reward[0].items[1].rate")
assertU16(t, data, itemsBase+8, 153, "reward[0].items[1].item") assertU16(t, data, itemsBase+8, 153, "reward[0].items[1].item")
assertU16(t, data, itemsBase+10, 1, "reward[0].items[1].quantity") assertU16(t, data, itemsBase+10, 1, "reward[0].items[1].quantity")
// item list terminator
assertU16(t, data, itemsBase+12, 0xFFFF, "reward item terminator") assertU16(t, data, itemsBase+12, 0xFFFF, "reward item terminator")
// ── Large monster spawn ────────────────────────────────────────────── // ── 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:])) largeMonsterPtr := int(binary.LittleEndian.Uint32(data[0x18:]))
assertByte(t, data, largeMonsterPtr, 11, "monster[0].id") assertByte(t, data, largeMonsterPtr, 11, "monster[0].id")
// pad[3]
assertByte(t, data, largeMonsterPtr+1, 0, "monster[0].pad1") assertByte(t, data, largeMonsterPtr+1, 0, "monster[0].pad1")
assertByte(t, data, largeMonsterPtr+2, 0, "monster[0].pad2") assertByte(t, data, largeMonsterPtr+2, 0, "monster[0].pad2")
assertByte(t, data, largeMonsterPtr+3, 0, "monster[0].pad3") assertByte(t, data, largeMonsterPtr+3, 0, "monster[0].pad3")
assertU32(t, data, largeMonsterPtr+4, 1, "monster[0].spawnAmount") assertU32(t, data, largeMonsterPtr+4, 1, "monster[0].spawnAmount")
assertU32(t, data, largeMonsterPtr+8, 5, "monster[0].spawnStage") assertU32(t, data, largeMonsterPtr+8, 5, "monster[0].spawnStage")
// pad[16] at +0x0C
for i := 0; i < 16; i++ { for i := 0; i < 16; i++ {
assertByte(t, data, largeMonsterPtr+0x0C+i, 0, "monster[0].pad16") assertByte(t, data, largeMonsterPtr+0x0C+i, 0, "monster[0].pad16")
} }
@@ -739,26 +957,196 @@ func TestGolden_MinimalQuestBinaryLayout(t *testing.T) {
assertF32(t, data, largeMonsterPtr+0x20, 1500.0, "monster[0].x") assertF32(t, data, largeMonsterPtr+0x20, 1500.0, "monster[0].x")
assertF32(t, data, largeMonsterPtr+0x24, 0.0, "monster[0].y") assertF32(t, data, largeMonsterPtr+0x24, 0.0, "monster[0].y")
assertF32(t, data, largeMonsterPtr+0x28, -2000.0, "monster[0].z") assertF32(t, data, largeMonsterPtr+0x28, -2000.0, "monster[0].z")
// pad[16] at +0x2C
for i := 0; i < 16; i++ { for i := 0; i < 16; i++ {
assertByte(t, data, largeMonsterPtr+0x2C+i, 0, "monster[0].trailing_pad") 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") assertByte(t, data, largeMonsterPtr+60, 0xFF, "monster list terminator")
// ── Total file size ────────────────────────────────────────────────── // ── 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 minExpectedLen := largeMonsterPtr + 61
if len(data) < minExpectedLen { if len(data) < minExpectedLen {
t.Errorf("file too short: len=%d, need at least %d", len(data), minExpectedLen) t.Errorf("file too short: len=%d, need at least %d", len(data), minExpectedLen)
} }
} }
// ── Golden test: generalQuestProperties with populated sections ───────────────
func TestGolden_GeneralQuestPropertiesCounts(t *testing.T) {
var q QuestJSON
_ = json.Unmarshal([]byte(minimalQuestJSON), &q)
q.AreaTransitions = []QuestAreaTransitionsJSON{{}, {}, {}}
q.GatheringTables = []QuestGatheringTableJSON{
{Items: []QuestGatherItemJSON{{Rate: 100, Item: 1}}},
{Items: []QuestGatherItemJSON{{Rate: 100, Item: 2}}},
}
b, _ := json.Marshal(q)
data, err := CompileQuestJSON(b)
if err != nil {
t.Fatalf("compile: %v", err)
}
// area1Zones at 0x7C should be 3.
assertByte(t, data, 0x7C, 3, "area1Zones")
// gatheringTablesQty at 0x78 should be 2.
assertU16(t, data, 0x78, 2, "gatheringTablesQty")
}
// ── Golden test: map sections binary layout ───────────────────────────────────
func TestGolden_MapSectionsBinaryLayout(t *testing.T) {
var q QuestJSON
_ = json.Unmarshal([]byte(minimalQuestJSON), &q)
q.MapSections = []QuestMapSectionJSON{
{
LoadedStage: 7,
SpawnMonsters: []uint8{0x0B}, // Rathalos
MinionSpawns: []QuestMinionSpawnJSON{
{Monster: 0x0B, SpawnToggle: 1, SpawnAmount: 2, X: 500.0, Y: 10.0, Z: -300.0},
},
},
}
data, err := CompileQuestJSON(func() []byte { b, _ := json.Marshal(q); return b }())
if err != nil {
t.Fatalf("compile: %v", err)
}
// questAreaPtr must be non-null.
questAreaPtr := int(binary.LittleEndian.Uint32(data[0x14:]))
if questAreaPtr == 0 {
t.Fatal("questAreaPtr is null, expected non-null")
}
// First entry in pointer array must be non-null (points to mapSection).
sectionPtr := int(binary.LittleEndian.Uint32(data[questAreaPtr:]))
if sectionPtr == 0 {
t.Fatal("mapSection[0] ptr is null")
}
// Terminator after the pointer.
terminatorOff := questAreaPtr + 4
if terminatorOff+4 > len(data) {
t.Fatalf("terminator out of bounds")
}
termVal := binary.LittleEndian.Uint32(data[terminatorOff:])
if termVal != 0 {
t.Errorf("pointer array terminator = 0x%08X, want 0", termVal)
}
// mapSection at sectionPtr: loadedStage = 7.
if sectionPtr+16 > len(data) {
t.Fatalf("mapSection out of bounds")
}
loadedStage := binary.LittleEndian.Uint32(data[sectionPtr:])
if loadedStage != 7 {
t.Errorf("mapSection.loadedStage = %d, want 7", loadedStage)
}
// spawnTypes and spawnStats ptrs must be non-null.
spawnTypesPtr := int(binary.LittleEndian.Uint32(data[sectionPtr+8:]))
spawnStatsPtr := int(binary.LittleEndian.Uint32(data[sectionPtr+12:]))
if spawnTypesPtr == 0 {
t.Fatal("spawnTypesPtr is null")
}
if spawnStatsPtr == 0 {
t.Fatal("spawnStatsPtr is null")
}
// spawnTypes: first entry = Rathalos (0x0B) + pad[3], then 0xFFFF terminator.
if spawnTypesPtr+6 > len(data) {
t.Fatalf("spawnTypes data out of bounds")
}
if data[spawnTypesPtr] != 0x0B {
t.Errorf("spawnTypes[0].monster = 0x%02X, want 0x0B", data[spawnTypesPtr])
}
termU16 := binary.LittleEndian.Uint16(data[spawnTypesPtr+4:])
if termU16 != 0xFFFF {
t.Errorf("spawnTypes terminator = 0x%04X, want 0xFFFF", termU16)
}
// spawnStats: first entry monster = Rathalos (0x0B).
if data[spawnStatsPtr] != 0x0B {
t.Errorf("spawnStats[0].monster = 0x%02X, want 0x0B", data[spawnStatsPtr])
}
// spawnToggle at +2 = 1.
spawnToggle := binary.LittleEndian.Uint16(data[spawnStatsPtr+2:])
if spawnToggle != 1 {
t.Errorf("spawnStats[0].spawnToggle = %d, want 1", spawnToggle)
}
// spawnAmount at +4 = 2.
spawnAmount := binary.LittleEndian.Uint32(data[spawnStatsPtr+4:])
if spawnAmount != 2 {
t.Errorf("spawnStats[0].spawnAmount = %d, want 2", spawnAmount)
}
// xPos at +0x20 = 500.0.
xBits := binary.LittleEndian.Uint32(data[spawnStatsPtr+0x20:])
xPos := math.Float32frombits(xBits)
if xPos != 500.0 {
t.Errorf("spawnStats[0].x = %v, want 500.0", xPos)
}
}
// ── Golden test: gathering tables binary layout ───────────────────────────────
func TestGolden_GatheringTablesBinaryLayout(t *testing.T) {
var q QuestJSON
_ = json.Unmarshal([]byte(minimalQuestJSON), &q)
q.GatheringTables = []QuestGatheringTableJSON{
{Items: []QuestGatherItemJSON{{Rate: 75, Item: 500}, {Rate: 25, Item: 501}}},
}
b, _ := json.Marshal(q)
data, err := CompileQuestJSON(b)
if err != nil {
t.Fatalf("compile: %v", err)
}
// gatheringTablesPtr must be non-null.
gatherTablesPtr := int(binary.LittleEndian.Uint32(data[0x38:]))
if gatherTablesPtr == 0 {
t.Fatal("gatheringTablesPtr is null")
}
// gatheringTablesQty at 0x78 must be 1.
assertU16(t, data, 0x78, 1, "gatheringTablesQty")
// Table 0: pointer to item data.
tblPtr := int(binary.LittleEndian.Uint32(data[gatherTablesPtr:]))
if tblPtr == 0 {
t.Fatal("gathering table[0] ptr is null")
}
// Item 0: rate=75, item=500.
if tblPtr+4 > len(data) {
t.Fatalf("gathering table items out of bounds")
}
rate0 := binary.LittleEndian.Uint16(data[tblPtr:])
item0 := binary.LittleEndian.Uint16(data[tblPtr+2:])
if rate0 != 75 {
t.Errorf("table[0].items[0].rate = %d, want 75", rate0)
}
if item0 != 500 {
t.Errorf("table[0].items[0].item = %d, want 500", item0)
}
// Item 1: rate=25, item=501.
rate1 := binary.LittleEndian.Uint16(data[tblPtr+4:])
item1 := binary.LittleEndian.Uint16(data[tblPtr+6:])
if rate1 != 25 {
t.Errorf("table[0].items[1].rate = %d, want 25", rate1)
}
if item1 != 501 {
t.Errorf("table[0].items[1].item = %d, want 501", item1)
}
// Terminator: 0xFFFF.
term := binary.LittleEndian.Uint16(data[tblPtr+8:])
if term != 0xFFFF {
t.Errorf("gathering table terminator = 0x%04X, want 0xFFFF", term)
}
}
// ── Objective encoding golden tests ───────────────────────────────────────── // ── Objective encoding golden tests ─────────────────────────────────────────
func TestGolden_ObjectiveEncoding(t *testing.T) { func TestGolden_ObjectiveEncoding(t *testing.T) {