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

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

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

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

845 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package channelserver
import (
"encoding/binary"
"fmt"
"math"
"golang.org/x/text/encoding/japanese"
"golang.org/x/text/transform"
)
// ParseQuestBinary reads a MHF quest binary (ZZ/G10 layout, little-endian)
// and returns a QuestJSON ready for re-compilation with CompileQuestJSON.
//
// The binary layout is described in quest_json.go (CompileQuestJSON).
// Sections guarded by null pointers in the header are skipped; the
// corresponding QuestJSON slices will be nil/empty.
func ParseQuestBinary(data []byte) (*QuestJSON, error) {
if len(data) < 0x86 {
return nil, fmt.Errorf("quest binary too short: %d bytes (minimum 0x86)", len(data))
}
// ── Helper closures ──────────────────────────────────────────────────
u8 := func(off int) uint8 {
return data[off]
}
u16 := func(off int) uint16 {
return binary.LittleEndian.Uint16(data[off:])
}
i16 := func(off int) int16 {
return int16(binary.LittleEndian.Uint16(data[off:]))
}
u32 := func(off int) uint32 {
return binary.LittleEndian.Uint32(data[off:])
}
f32 := func(off int) float32 {
return math.Float32frombits(binary.LittleEndian.Uint32(data[off:]))
}
// 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))
}
return nil
}
// readSJIS reads a null-terminated Shift-JIS string starting at off.
readSJIS := func(off int) (string, error) {
if off < 0 || off >= len(data) {
return "", fmt.Errorf("string offset 0x%X out of bounds", off)
}
end := off
for end < len(data) && data[end] != 0 {
end++
}
sjis := data[off:end]
if len(sjis) == 0 {
return "", nil
}
dec := japanese.ShiftJIS.NewDecoder()
utf8, _, err := transform.Bytes(dec, sjis)
if err != nil {
return "", fmt.Errorf("shift-jis decode at 0x%X: %w", off, err)
}
return string(utf8), nil
}
q := &QuestJSON{}
// ── Header (0x000x43) ───────────────────────────────────────────────
questTypeFlagsPtr := int(u32(0x00))
loadedStagesPtr := int(u32(0x04))
supplyBoxPtr := int(u32(0x08))
rewardPtr := int(u32(0x0C))
questAreaPtr := int(u32(0x14))
largeMonsterPtr := int(u32(0x18))
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
gatheringTablesPtr := int(u32(0x38))
// ── General Quest Properties (0x440x85) ────────────────────────────
q.MonsterSizeMulti = u16(0x44)
q.SizeRange = u16(0x46)
q.StatTable1 = u32(0x48)
q.MainRankPoints = u32(0x4C)
// 0x50 unknown u32 — skipped
q.SubARankPoints = u32(0x54)
q.SubBRankPoints = u32(0x58)
// 0x5C questTypeID/unknown — skipped
// 0x60 padding
q.StatTable2 = u8(0x61)
// 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 {
return nil, fmt.Errorf("questTypeFlagsPtr is null; cannot read main quest properties")
}
if err := check(questTypeFlagsPtr, questBodyLenZZ, "mainQuestProperties"); err != nil {
return nil, err
}
mp := questTypeFlagsPtr // shorthand
q.RankBand = u16(mp + 0x08)
q.Fee = u32(mp + 0x0C)
q.RewardMain = u32(mp + 0x10)
q.RewardSubA = u16(mp + 0x18)
q.RewardSubB = u16(mp + 0x1C)
q.HardHRReq = u16(mp + 0x1E)
questFrames := u32(mp + 0x20)
q.TimeLimitMinutes = questFrames / (60 * 30)
q.Map = u32(mp + 0x24)
questStringsPtr := int(u32(mp + 0x28))
q.QuestID = u16(mp + 0x2E)
// +0x30 objectives[3] (8 bytes each)
objectives, err := parseObjectives(data, mp+0x30)
if err != nil {
return nil, err
}
q.ObjectiveMain = objectives[0]
q.ObjectiveSubA = objectives[1]
q.ObjectiveSubB = objectives[2]
// +0x4C joinRankMin/Max, postRankMin/Max
q.JoinRankMin = u16(mp + 0x4C)
q.JoinRankMax = u16(mp + 0x4E)
q.PostRankMin = u16(mp + 0x50)
q.PostRankMax = u16(mp + 0x52)
// +0x5C forced equipment (6 slots × 4 × u16 = 48 bytes)
eq, hasEquip := parseForcedEquip(data, mp+0x5C)
if hasEquip {
q.ForcedEquipment = eq
}
// +0x97 questVariants
q.QuestVariant1 = u8(mp + 0x97)
q.QuestVariant2 = u8(mp + 0x98)
q.QuestVariant3 = u8(mp + 0x99)
q.QuestVariant4 = u8(mp + 0x9A)
// ── QuestText strings ────────────────────────────────────────────────
if questStringsPtr != 0 {
if err := check(questStringsPtr, 32, "questTextTable"); err != nil {
return nil, err
}
strPtrs := make([]int, 8)
for i := range strPtrs {
strPtrs[i] = int(u32(questStringsPtr + i*4))
}
texts := make([]string, 8)
for i, ptr := range strPtrs {
if ptr == 0 {
continue
}
s, err := readSJIS(ptr)
if err != nil {
return nil, fmt.Errorf("string[%d]: %w", i, err)
}
texts[i] = s
}
q.Title = texts[0]
q.TextMain = texts[1]
q.TextSubA = texts[2]
q.TextSubB = texts[3]
q.SuccessCond = texts[4]
q.FailCond = texts[5]
q.Contractor = texts[6]
q.Description = texts[7]
}
// ── Stages ───────────────────────────────────────────────────────────
if loadedStagesPtr != 0 && unk34Ptr > loadedStagesPtr {
off := loadedStagesPtr
for off+16 <= unk34Ptr {
if err := check(off, 16, "stage"); err != nil {
return nil, err
}
stageID := u32(off)
q.Stages = append(q.Stages, QuestStageJSON{StageID: stageID})
off += 16
}
}
// ── Supply Box ───────────────────────────────────────────────────────
if supplyBoxPtr != 0 {
const supplyBoxSize = (24 + 8 + 8) * 4
if err := check(supplyBoxPtr, supplyBoxSize, "supplyBox"); err != nil {
return nil, err
}
q.SupplyMain = readSupplySlots(data, supplyBoxPtr, 24)
q.SupplySubA = readSupplySlots(data, supplyBoxPtr+24*4, 8)
q.SupplySubB = readSupplySlots(data, supplyBoxPtr+24*4+8*4, 8)
}
// ── Reward Tables ────────────────────────────────────────────────────
if rewardPtr != 0 {
tables, err := parseRewardTables(data, rewardPtr)
if err != nil {
return nil, err
}
q.Rewards = tables
}
// ── Large Monster Spawns ─────────────────────────────────────────────
if largeMonsterPtr != 0 {
monsters, err := parseMonsterSpawns(data, largeMonsterPtr, f32)
if err != nil {
return nil, err
}
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
}
// ── Section parsers ──────────────────────────────────────────────────────────
// parseObjectives reads the three 8-byte objective entries at off.
func parseObjectives(data []byte, off int) ([3]QuestObjectiveJSON, error) {
var objs [3]QuestObjectiveJSON
for i := range objs {
base := off + i*8
if base+8 > len(data) {
return objs, fmt.Errorf("objective[%d] at 0x%X out of bounds", i, base)
}
goalType := binary.LittleEndian.Uint32(data[base:])
typeName, ok := objTypeToString(goalType)
if !ok {
typeName = "none"
}
obj := QuestObjectiveJSON{Type: typeName}
if goalType != questObjNone {
switch goalType {
case questObjHunt, questObjCapture, questObjSlay, questObjDamage,
questObjSlayOrDamage, questObjBreakPart:
obj.Target = uint16(data[base+4])
// data[base+5] is padding
default:
obj.Target = binary.LittleEndian.Uint16(data[base+4:])
}
secondary := binary.LittleEndian.Uint16(data[base+6:])
if goalType == questObjBreakPart {
obj.Part = secondary
} else {
obj.Count = secondary
}
}
objs[i] = obj
}
return objs, nil
}
// parseForcedEquip reads 6 slots × 4 uint16 at off.
// Returns nil, false if all values are zero (no forced equipment).
func parseForcedEquip(data []byte, off int) (*QuestForcedEquipJSON, bool) {
eq := &QuestForcedEquipJSON{}
slots := []*[4]uint16{&eq.Legs, &eq.Weapon, &eq.Head, &eq.Chest, &eq.Arms, &eq.Waist}
anyNonZero := false
for _, slot := range slots {
for j := range slot {
v := binary.LittleEndian.Uint16(data[off:])
slot[j] = v
if v != 0 {
anyNonZero = true
}
off += 2
}
}
if !anyNonZero {
return nil, false
}
return eq, true
}
// readSupplySlots reads n supply item slots (each 4 bytes: u16 item + u16 qty)
// starting at off and returns only non-empty entries (item != 0).
func readSupplySlots(data []byte, off, n int) []QuestSupplyItemJSON {
var out []QuestSupplyItemJSON
for i := 0; i < n; i++ {
base := off + i*4
item := binary.LittleEndian.Uint16(data[base:])
qty := binary.LittleEndian.Uint16(data[base+2:])
if item == 0 {
continue
}
out = append(out, QuestSupplyItemJSON{Item: item, Quantity: qty})
}
return out
}
// parseRewardTables reads the reward table array starting at baseOff.
// Header array: {u8 tableId, u8 pad, u16 pad, u32 tableOffset} per entry,
// terminated by int16(-1). tableOffset is relative to baseOff.
// Each item list: {u16 rate, u16 item, u16 quantity} terminated by int16(-1).
func parseRewardTables(data []byte, baseOff int) ([]QuestRewardTableJSON, error) {
var tables []QuestRewardTableJSON
off := baseOff
for {
if off+2 > len(data) {
return nil, fmt.Errorf("reward table header truncated at 0x%X", off)
}
if binary.LittleEndian.Uint16(data[off:]) == 0xFFFF {
break
}
if off+8 > len(data) {
return nil, fmt.Errorf("reward table header entry truncated at 0x%X", off)
}
tableID := data[off]
tableOff := int(binary.LittleEndian.Uint32(data[off+4:])) + baseOff
off += 8
items, err := parseRewardItems(data, tableOff)
if err != nil {
return nil, fmt.Errorf("reward table %d items: %w", tableID, err)
}
tables = append(tables, QuestRewardTableJSON{TableID: tableID, Items: items})
}
return tables, nil
}
// parseRewardItems reads a null-terminated reward item list at off.
func parseRewardItems(data []byte, off int) ([]QuestRewardItemJSON, error) {
var items []QuestRewardItemJSON
for {
if off+2 > len(data) {
return nil, fmt.Errorf("reward item list truncated at 0x%X", off)
}
if binary.LittleEndian.Uint16(data[off:]) == 0xFFFF {
break
}
if off+6 > len(data) {
return nil, fmt.Errorf("reward item entry truncated at 0x%X", off)
}
rate := binary.LittleEndian.Uint16(data[off:])
item := binary.LittleEndian.Uint16(data[off+2:])
qty := binary.LittleEndian.Uint16(data[off+4:])
items = append(items, QuestRewardItemJSON{Rate: rate, Item: item, Quantity: qty})
off += 6
}
return items, nil
}
// parseMonsterSpawns reads large monster spawn entries at baseOff.
// Each entry is 60 bytes; the list is terminated by a 0xFF byte.
func parseMonsterSpawns(data []byte, baseOff int, f32fn func(int) float32) ([]QuestMonsterJSON, error) {
var monsters []QuestMonsterJSON
off := baseOff
const entrySize = 60
for {
if off >= len(data) {
return nil, fmt.Errorf("monster spawn list unterminated at end of file")
}
if data[off] == 0xFF {
break
}
if off+entrySize > len(data) {
return nil, fmt.Errorf("monster spawn entry at 0x%X truncated", off)
}
m := QuestMonsterJSON{
ID: data[off],
SpawnAmount: binary.LittleEndian.Uint32(data[off+4:]),
SpawnStage: binary.LittleEndian.Uint32(data[off+8:]),
// +0x0C padding[16]
Orientation: binary.LittleEndian.Uint32(data[off+0x1C:]),
X: f32fn(off + 0x20),
Y: f32fn(off + 0x24),
Z: f32fn(off + 0x28),
// +0x2C padding[16]
}
monsters = append(monsters, m)
off += entrySize
}
return monsters, nil
}
// 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) {
for name, v := range questObjTypeMap {
if v == t {
return name, true
}
}
return "", false
}