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

@@ -27,6 +27,9 @@ func ParseQuestBinary(data []byte) (*QuestJSON, error) {
u16 := func(off int) uint16 {
return binary.LittleEndian.Uint16(data[off:])
}
i16 := func(off int) int16 {
return int16(binary.LittleEndian.Uint16(data[off:]))
}
u32 := func(off int) uint32 {
return binary.LittleEndian.Uint32(data[off:])
}
@@ -34,7 +37,7 @@ func ParseQuestBinary(data []byte) (*QuestJSON, error) {
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 {
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))
@@ -70,19 +73,16 @@ func ParseQuestBinary(data []byte) (*QuestJSON, error) {
loadedStagesPtr := int(u32(0x04))
supplyBoxPtr := int(u32(0x08))
rewardPtr := int(u32(0x0C))
// 0x10 subSupplyBoxPtr (u16), 0x12 hidden, 0x13 subSupplyBoxLen — not in QuestJSON
// 0x14 questAreaPtr — null, not parsed
questAreaPtr := int(u32(0x14))
largeMonsterPtr := int(u32(0x18))
// 0x1C areaTransitionsPtr — null, not parsed
// 0x20 areaMappingPtr — null, not parsed
// 0x24 mapInfoPtr — null, not parsed
// 0x28 gatheringPointsPtr — null, not parsed
// 0x2C areaFacilitiesPtr — null, not parsed
// 0x30 someStringsPtr — null, not parsed
areaTransitionsPtr := int(u32(0x1C))
areaMappingPtr := int(u32(0x20))
mapInfoPtr := int(u32(0x24))
gatheringPointsPtr := int(u32(0x28))
areaFacilitiesPtr := int(u32(0x2C))
someStringsPtr := int(u32(0x30))
unk34Ptr := int(u32(0x34)) // stages-end sentinel
// 0x38 gatheringTablesPtr — null, not parsed
// 0x3C fixedCoords2Ptr — null, not parsed
// 0x40 fixedInfoPtr — null, not parsed
gatheringTablesPtr := int(u32(0x38))
// ── General Quest Properties (0x440x85) ────────────────────────────
q.MonsterSizeMulti = u16(0x44)
@@ -95,7 +95,12 @@ func ParseQuestBinary(data []byte) (*QuestJSON, error) {
// 0x5C questTypeID/unknown — skipped
// 0x60 padding
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) ─────────
if questTypeFlagsPtr == 0 {
@@ -107,26 +112,16 @@ func ParseQuestBinary(data []byte) (*QuestJSON, error) {
mp := questTypeFlagsPtr // shorthand
// +0x08 rankBand
q.RankBand = u16(mp + 0x08)
// +0x0C questFee
q.Fee = u32(mp + 0x0C)
// +0x10 rewardMain
q.RewardMain = u32(mp + 0x10)
// +0x18 rewardA
q.RewardSubA = u16(mp + 0x18)
// +0x1C rewardB
q.RewardSubB = u16(mp + 0x1C)
// +0x1E hardHRReq
q.HardHRReq = u16(mp + 0x1E)
// +0x20 questTime (frames at 30 Hz → minutes)
questFrames := u32(mp + 0x20)
q.TimeLimitMinutes = questFrames / (60 * 30)
// +0x24 questMap
q.Map = u32(mp + 0x24)
// +0x28 questStringsPtr (absolute file offset)
questStringsPtr := int(u32(mp + 0x28))
// +0x2E questID
q.QuestID = u16(mp + 0x2E)
// +0x30 objectives[3] (8 bytes each)
@@ -161,8 +156,6 @@ func ParseQuestBinary(data []byte) (*QuestJSON, error) {
if err := check(questStringsPtr, 32, "questTextTable"); err != nil {
return nil, err
}
// 8 pointers × 4 bytes: title, textMain, textSubA, textSubB,
// successCond, failCond, contractor, description.
strPtrs := make([]int, 8)
for i := range strPtrs {
strPtrs[i] = int(u32(questStringsPtr + i*4))
@@ -189,8 +182,6 @@ func ParseQuestBinary(data []byte) (*QuestJSON, error) {
}
// ── Stages ───────────────────────────────────────────────────────────
// Guarded by loadedStagesPtr; terminated when we reach unk34Ptr.
// Each stage: u32 stageID + 12 bytes padding = 16 bytes.
if loadedStagesPtr != 0 && unk34Ptr > loadedStagesPtr {
off := loadedStagesPtr
for off+16 <= unk34Ptr {
@@ -204,7 +195,6 @@ func ParseQuestBinary(data []byte) (*QuestJSON, error) {
}
// ── Supply Box ───────────────────────────────────────────────────────
// Guarded by supplyBoxPtr. Layout: main(24) + subA(8) + subB(8) × 4 bytes each.
if supplyBoxPtr != 0 {
const supplyBoxSize = (24 + 8 + 8) * 4
if err := check(supplyBoxPtr, supplyBoxSize, "supplyBox"); err != nil {
@@ -216,8 +206,6 @@ func ParseQuestBinary(data []byte) (*QuestJSON, error) {
}
// ── Reward Tables ────────────────────────────────────────────────────
// Guarded by rewardPtr. Header array terminated by int16(-1); item lists
// each terminated by int16(-1).
if rewardPtr != 0 {
tables, err := parseRewardTables(data, rewardPtr)
if err != nil {
@@ -227,7 +215,6 @@ func ParseQuestBinary(data []byte) (*QuestJSON, error) {
}
// ── Large Monster Spawns ─────────────────────────────────────────────
// Guarded by largeMonsterPtr. Each entry is 60 bytes; terminated by 0xFF.
if largeMonsterPtr != 0 {
monsters, err := parseMonsterSpawns(data, largeMonsterPtr, f32)
if err != nil {
@@ -236,6 +223,109 @@ func ParseQuestBinary(data []byte) (*QuestJSON, error) {
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
}
@@ -327,7 +417,6 @@ func parseRewardTables(data []byte, baseOff int) ([]QuestRewardTableJSON, error)
if off+2 > len(data) {
return nil, fmt.Errorf("reward table header truncated at 0x%X", off)
}
// Check for terminator (0xFFFF).
if binary.LittleEndian.Uint16(data[off:]) == 0xFFFF {
break
}
@@ -338,7 +427,6 @@ func parseRewardTables(data []byte, baseOff int) ([]QuestRewardTableJSON, error)
tableOff := int(binary.LittleEndian.Uint32(data[off+4:])) + baseOff
off += 8
// Read items at tableOff.
items, err := parseRewardItems(data, tableOff)
if err != nil {
return nil, fmt.Errorf("reward table %d items: %w", tableID, err)
@@ -403,6 +491,347 @@ func parseMonsterSpawns(data []byte, baseOff int, f32fn func(int) float32) ([]Qu
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.
// Returns "", false for unknown types.
func objTypeToString(t uint32) (string, bool) {