From e827ecf7d43edf19e34df753a134d702df745cba Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Thu, 19 Mar 2026 18:20:00 +0100 Subject: [PATCH] feat(quests): implement all remaining binary sections in JSON format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- server/channelserver/quest_json.go | 731 ++++++++++++++++++---- server/channelserver/quest_json_parser.go | 495 ++++++++++++++- server/channelserver/quest_json_test.go | 462 ++++++++++++-- 3 files changed, 1488 insertions(+), 200 deletions(-) diff --git a/server/channelserver/quest_json.go b/server/channelserver/quest_json.go index 3637d5d11..1814cfe3f 100644 --- a/server/channelserver/quest_json.go +++ b/server/channelserver/quest_json.go @@ -105,11 +105,107 @@ type QuestForcedEquipJSON struct { Waist [4]uint16 `json:"waist,omitempty"` } +// QuestMinionSpawnJSON is one minion spawn entry within a map section. +type QuestMinionSpawnJSON struct { + Monster uint8 `json:"monster"` + SpawnToggle uint16 `json:"spawn_toggle"` + SpawnAmount uint32 `json:"spawn_amount"` + X float32 `json:"x"` + Y float32 `json:"y"` + Z float32 `json:"z"` +} + +// QuestMapSectionJSON defines one map section with its minion spawns. +// Each section corresponds to a loaded stage area. +type QuestMapSectionJSON struct { + LoadedStage uint32 `json:"loaded_stage"` + SpawnMonsters []uint8 `json:"spawn_monsters,omitempty"` // monster IDs for spawn type list + MinionSpawns []QuestMinionSpawnJSON `json:"minion_spawns,omitempty"` +} + +// QuestAreaTransitionJSON is one zone transition (floatSet). +type QuestAreaTransitionJSON struct { + TargetStageID1 int16 `json:"target_stage_id"` + StageVariant int16 `json:"stage_variant"` + CurrentX float32 `json:"current_x"` + CurrentY float32 `json:"current_y"` + CurrentZ float32 `json:"current_z"` + TransitionBox [5]float32 `json:"transition_box"` + TargetX float32 `json:"target_x"` + TargetY float32 `json:"target_y"` + TargetZ float32 `json:"target_z"` + TargetRotation [2]int16 `json:"target_rotation"` +} + +// QuestAreaTransitionsJSON holds the transitions for one area zone entry. +// The pointer may be null (empty transitions list) for zones without transitions. +type QuestAreaTransitionsJSON struct { + Transitions []QuestAreaTransitionJSON `json:"transitions,omitempty"` +} + +// QuestAreaMappingJSON defines coordinate mappings between area and base map. +// Layout: 32 bytes per entry (Area_xPos, Area_zPos, pad8, Base_xPos, Base_zPos, kn_Pos, pad4). +type QuestAreaMappingJSON struct { + AreaX float32 `json:"area_x"` + AreaZ float32 `json:"area_z"` + BaseX float32 `json:"base_x"` + BaseZ float32 `json:"base_z"` + KnPos float32 `json:"kn_pos"` +} + +// QuestMapInfoJSON contains the map ID and return base camp ID. +type QuestMapInfoJSON struct { + MapID uint32 `json:"map_id"` + ReturnBCID uint32 `json:"return_bc_id"` +} + +// QuestGatheringPointJSON is one gathering point (24 bytes). +type QuestGatheringPointJSON struct { + X float32 `json:"x"` + Y float32 `json:"y"` + Z float32 `json:"z"` + Range float32 `json:"range"` + GatheringID uint16 `json:"gathering_id"` + MaxCount uint16 `json:"max_count"` + MinCount uint16 `json:"min_count"` +} + +// QuestAreaGatheringJSON holds up to 4 gathering points for one area zone entry. +// A nil/empty list means the pointer is null for this zone. +type QuestAreaGatheringJSON struct { + Points []QuestGatheringPointJSON `json:"points,omitempty"` +} + +// QuestFacilityPointJSON is one facility point (24 bytes, facPoint in hexpat). +type QuestFacilityPointJSON struct { + Type uint16 `json:"type"` // SpecAc: 1=cooking, 2=fishing, 3=bluebox, etc. + X float32 `json:"x"` + Y float32 `json:"y"` + Z float32 `json:"z"` + Range float32 `json:"range"` + ID uint16 `json:"id"` +} + +// QuestAreaFacilitiesJSON holds the facilities block for one area zone entry. +// A nil/empty list means the pointer is null for this zone. +type QuestAreaFacilitiesJSON struct { + Points []QuestFacilityPointJSON `json:"points,omitempty"` +} + +// QuestGatherItemJSON is one entry in a gathering table. +type QuestGatherItemJSON struct { + Rate uint16 `json:"rate"` + Item uint16 `json:"item"` +} + +// QuestGatheringTableJSON is one gathering loot table. +type QuestGatheringTableJSON struct { + Items []QuestGatherItemJSON `json:"items,omitempty"` +} + // QuestJSON is the human-readable quest definition. // Time values: TimeLimitMinutes is converted to frames (×30×60) in the binary. // Strings: encoded as UTF-8 here, converted to Shift-JIS in the binary. -// All pointer-based sections (gathering, area transitions, facilities) are -// omitted — those fields are set to null pointers so the client uses defaults. type QuestJSON struct { // Quest identification QuestID uint16 `json:"quest_id"` @@ -174,6 +270,32 @@ type QuestJSON struct { // Forced equipment (optional) ForcedEquipment *QuestForcedEquipJSON `json:"forced_equipment,omitempty"` + + // Map sections with minion spawns (questAreaPtr) + MapSections []QuestMapSectionJSON `json:"map_sections,omitempty"` + + // Area transitions per zone (areaTransitionsPtr); one entry per zone. + // Length determines area1Zones in generalQuestProperties. + AreaTransitions []QuestAreaTransitionsJSON `json:"area_transitions,omitempty"` + + // Area coordinate mappings (areaMappingPtr) + AreaMappings []QuestAreaMappingJSON `json:"area_mappings,omitempty"` + + // Map info: map ID + return base camp ID (mapInfoPtr) + MapInfo *QuestMapInfoJSON `json:"map_info,omitempty"` + + // Per-zone gathering points (gatheringPointsPtr); one entry per zone. + GatheringPoints []QuestAreaGatheringJSON `json:"gathering_points,omitempty"` + + // Per-zone area facilities (areaFacilitiesPtr); one entry per zone. + AreaFacilities []QuestAreaFacilitiesJSON `json:"area_facilities,omitempty"` + + // Additional metadata strings (someStringsPtr / unk30). Optional. + SomeString string `json:"some_string,omitempty"` + QuestType string `json:"quest_type_string,omitempty"` + + // Gathering loot tables (gatheringTablesPtr) + GatheringTables []QuestGatheringTableJSON `json:"gathering_tables,omitempty"` } // toShiftJIS converts a UTF-8 string to a null-terminated Shift-JIS byte slice. @@ -194,6 +316,11 @@ func writeUint16LE(buf *bytes.Buffer, v uint16) { buf.Write(b[:]) } +// writeInt16LE writes a little-endian int16 to buf. +func writeInt16LE(buf *bytes.Buffer, v int16) { + writeUint16LE(buf, uint16(v)) +} + // writeUint32LE writes a little-endian uint32 to buf. func writeUint32LE(buf *bytes.Buffer, v uint32) { b := [4]byte{} @@ -213,6 +340,29 @@ func pad(buf *bytes.Buffer, n int) { buf.Write(make([]byte, n)) } +// questBuilder is a small helper for building a quest binary with pointer patching. +// All pointers are absolute offsets from the start of the buffer (file start). +type questBuilder struct { + out *bytes.Buffer +} + +// reserve writes a u32(0) placeholder and returns its offset in the buffer. +func (b *questBuilder) reserve() int { + off := b.out.Len() + writeUint32LE(b.out, 0) + return off +} + +// patch writes the current buffer length as a u32 at the previously reserved offset. +func (b *questBuilder) patch(reservedOff int) { + binary.LittleEndian.PutUint32(b.out.Bytes()[reservedOff:], uint32(b.out.Len())) +} + +// patchValue writes a specific uint32 value at a previously reserved offset. +func (b *questBuilder) patchValue(reservedOff int, v uint32) { + binary.LittleEndian.PutUint32(b.out.Bytes()[reservedOff:], v) +} + // objectiveBytes serialises one QuestObjectiveJSON to 8 bytes. // Layout per hexpat objective.hexpat: // @@ -267,13 +417,30 @@ func objectiveBytes(obj QuestObjectiveJSON) ([]byte, error) { // 0x044–0x085 generalQuestProperties (66 bytes) // 0x086–0x1C5 mainQuestProperties (320 bytes, questBodyLenZZ) // 0x1C6+ QuestText pointer table (32 bytes) + strings (Shift-JIS) -// aligned+ stages, supply box, reward tables, monster spawns +// aligned+ stages, supply box, reward tables, monster spawns, +// map sections, area mappings, area transitions, +// map info, gathering points, area facilities, +// some strings, gathering tables func CompileQuestJSON(data []byte) ([]byte, error) { var q QuestJSON if err := json.Unmarshal(data, &q); err != nil { return nil, fmt.Errorf("parse quest JSON: %w", err) } + // ── Compute counts before writing generalQuestProperties ───────────── + numZones := len(q.AreaTransitions) + numGatheringTables := len(q.GatheringTables) + + // Validate zone-length consistency. + if len(q.GatheringPoints) != 0 && len(q.GatheringPoints) != numZones { + return nil, fmt.Errorf("GatheringPoints len (%d) must equal AreaTransitions len (%d) or be 0", + len(q.GatheringPoints), numZones) + } + if len(q.AreaFacilities) != 0 && len(q.AreaFacilities) != numZones { + return nil, fmt.Errorf("AreaFacilities len (%d) must equal AreaTransitions len (%d) or be 0", + len(q.AreaFacilities), numZones) + } + // ── Section offsets (computed as we build) ────────────────────────── const ( headerSize = 68 // 0x44 @@ -335,88 +502,101 @@ func CompileQuestJSON(data []byte) ([]byte, error) { // Large monster spawns: each is 60 bytes + 1-byte terminator. largeMonsterPtr := afterRewards monsterBuf := buildMonsterSpawns(q.LargeMonsters) + afterMonsters := align4(largeMonsterPtr + uint32(len(monsterBuf))) // ── Assemble file ──────────────────────────────────────────────────── - out := &bytes.Buffer{} + qb := &questBuilder{out: &bytes.Buffer{}} - // ── Header (68 bytes) ──────────────────────────────────────────────── - writeUint32LE(out, questTypeFlagsPtr) // 0x00 questTypeFlagsPtr - writeUint32LE(out, loadedStagesPtr) // 0x04 loadedStagesPtr - writeUint32LE(out, supplyBoxPtr) // 0x08 supplyBoxPtr - writeUint32LE(out, rewardPtr) // 0x0C rewardPtr - writeUint16LE(out, 0) // 0x10 subSupplyBoxPtr (unused) - out.WriteByte(0) // 0x12 hidden - out.WriteByte(0) // 0x13 subSupplyBoxLen - writeUint32LE(out, 0) // 0x14 questAreaPtr (null) - writeUint32LE(out, largeMonsterPtr) // 0x18 largeMonsterPtr - writeUint32LE(out, 0) // 0x1C areaTransitionsPtr (null) - writeUint32LE(out, 0) // 0x20 areaMappingPtr (null) - writeUint32LE(out, 0) // 0x24 mapInfoPtr (null) - writeUint32LE(out, 0) // 0x28 gatheringPointsPtr (null) - writeUint32LE(out, 0) // 0x2C areaFacilitiesPtr (null) - writeUint32LE(out, 0) // 0x30 someStringsPtr (null) - writeUint32LE(out, unk34Ptr) // 0x34 fixedCoords1Ptr (stages end) - writeUint32LE(out, 0) // 0x38 gatheringTablesPtr (null) - writeUint32LE(out, 0) // 0x3C fixedCoords2Ptr (null) - writeUint32LE(out, 0) // 0x40 fixedInfoPtr (null) + // ── Header placeholders (68 bytes) ──────────────────────────────────── + // We'll write the header now with known values; variable section pointers + // that depend on the preceding variable sections are also known at this + // point because we computed them above. The new sections (area, gathering, + // etc.) will be appended after the monster spawns and patched in. + hdrQuestAreaOff := 0x14 // questAreaPtr placeholder + hdrAreaTransOff := 0x1C // areaTransitionsPtr placeholder + hdrAreaMappingOff := 0x20 // areaMappingPtr placeholder + hdrMapInfoOff := 0x24 // mapInfoPtr placeholder + hdrGatherPtsOff := 0x28 // gatheringPointsPtr placeholder + hdrFacilitiesOff := 0x2C // areaFacilitiesPtr placeholder + hdrSomeStringsOff := 0x30 // someStringsPtr placeholder + hdrGatherTablesOff := 0x38 // gatheringTablesPtr placeholder - if out.Len() != headerSize { - return nil, fmt.Errorf("header size mismatch: got %d want %d", out.Len(), headerSize) + writeUint32LE(qb.out, questTypeFlagsPtr) // 0x00 questTypeFlagsPtr + writeUint32LE(qb.out, loadedStagesPtr) // 0x04 loadedStagesPtr + writeUint32LE(qb.out, supplyBoxPtr) // 0x08 supplyBoxPtr + writeUint32LE(qb.out, rewardPtr) // 0x0C rewardPtr + writeUint16LE(qb.out, 0) // 0x10 subSupplyBoxPtr (unused) + qb.out.WriteByte(0) // 0x12 hidden + qb.out.WriteByte(0) // 0x13 subSupplyBoxLen + writeUint32LE(qb.out, 0) // 0x14 questAreaPtr (patched later) + writeUint32LE(qb.out, largeMonsterPtr) // 0x18 largeMonsterPtr + writeUint32LE(qb.out, 0) // 0x1C areaTransitionsPtr (patched later) + writeUint32LE(qb.out, 0) // 0x20 areaMappingPtr (patched later) + writeUint32LE(qb.out, 0) // 0x24 mapInfoPtr (patched later) + writeUint32LE(qb.out, 0) // 0x28 gatheringPointsPtr (patched later) + writeUint32LE(qb.out, 0) // 0x2C areaFacilitiesPtr (patched later) + writeUint32LE(qb.out, 0) // 0x30 someStringsPtr (patched later) + writeUint32LE(qb.out, unk34Ptr) // 0x34 fixedCoords1Ptr (stages end) + writeUint32LE(qb.out, 0) // 0x38 gatheringTablesPtr (patched later) + writeUint32LE(qb.out, 0) // 0x3C fixedCoords2Ptr (null) + writeUint32LE(qb.out, 0) // 0x40 fixedInfoPtr (null) + + if qb.out.Len() != headerSize { + return nil, fmt.Errorf("header size mismatch: got %d want %d", qb.out.Len(), headerSize) } // ── General Quest Properties (66 bytes, 0x44–0x85) ────────────────── - writeUint16LE(out, q.MonsterSizeMulti) // 0x44 monsterSizeMulti - writeUint16LE(out, q.SizeRange) // 0x46 sizeRange - writeUint32LE(out, q.StatTable1) // 0x48 statTable1 - writeUint32LE(out, q.MainRankPoints) // 0x4C mainRankPoints - writeUint32LE(out, 0) // 0x50 unknown - writeUint32LE(out, q.SubARankPoints) // 0x54 subARankPoints - writeUint32LE(out, q.SubBRankPoints) // 0x58 subBRankPoints - writeUint32LE(out, 0) // 0x5C questTypeID / unknown - out.WriteByte(0) // 0x60 padding - out.WriteByte(q.StatTable2) // 0x61 statTable2 - pad(out, 0x11) // 0x62–0x72 padding - out.WriteByte(0) // 0x73 questKn1 - writeUint16LE(out, 0) // 0x74 questKn2 - writeUint16LE(out, 0) // 0x76 questKn3 - writeUint16LE(out, 0) // 0x78 gatheringTablesQty - writeUint16LE(out, 0) // 0x7A unknown - out.WriteByte(0) // 0x7C area1Zones - out.WriteByte(0) // 0x7D area2Zones - out.WriteByte(0) // 0x7E area3Zones - out.WriteByte(0) // 0x7F area4Zones - writeUint16LE(out, 0) // 0x80 unknown - writeUint16LE(out, 0) // 0x82 unknown - writeUint16LE(out, 0) // 0x84 unknown + writeUint16LE(qb.out, q.MonsterSizeMulti) // 0x44 monsterSizeMulti + writeUint16LE(qb.out, q.SizeRange) // 0x46 sizeRange + writeUint32LE(qb.out, q.StatTable1) // 0x48 statTable1 + writeUint32LE(qb.out, q.MainRankPoints) // 0x4C mainRankPoints + writeUint32LE(qb.out, 0) // 0x50 unknown + writeUint32LE(qb.out, q.SubARankPoints) // 0x54 subARankPoints + writeUint32LE(qb.out, q.SubBRankPoints) // 0x58 subBRankPoints + writeUint32LE(qb.out, 0) // 0x5C questTypeID / unknown + qb.out.WriteByte(0) // 0x60 padding + qb.out.WriteByte(q.StatTable2) // 0x61 statTable2 + pad(qb.out, 0x11) // 0x62–0x72 padding + qb.out.WriteByte(0) // 0x73 questKn1 + writeUint16LE(qb.out, 0) // 0x74 questKn2 + writeUint16LE(qb.out, 0) // 0x76 questKn3 + writeUint16LE(qb.out, uint16(numGatheringTables)) // 0x78 gatheringTablesQty + writeUint16LE(qb.out, 0) // 0x7A unknown + qb.out.WriteByte(uint8(numZones)) // 0x7C area1Zones + qb.out.WriteByte(0) // 0x7D area2Zones + qb.out.WriteByte(0) // 0x7E area3Zones + qb.out.WriteByte(0) // 0x7F area4Zones + writeUint16LE(qb.out, 0) // 0x80 unknown + writeUint16LE(qb.out, 0) // 0x82 unknown + writeUint16LE(qb.out, 0) // 0x84 unknown - if out.Len() != headerSize+genPropSize { - return nil, fmt.Errorf("genProp size mismatch: got %d want %d", out.Len(), headerSize+genPropSize) + if qb.out.Len() != headerSize+genPropSize { + return nil, fmt.Errorf("genProp size mismatch: got %d want %d", qb.out.Len(), headerSize+genPropSize) } // ── Main Quest Properties (320 bytes, 0x86–0x1C5) ─────────────────── - // Matches mainQuestProperties struct in questfile.bin.hexpat. - mainStart := out.Len() - out.WriteByte(0) // +0x00 unknown - out.WriteByte(0) // +0x01 musicMode - out.WriteByte(0) // +0x02 localeFlags - out.WriteByte(0) // +0x03 unknown - out.WriteByte(0) // +0x04 rankingID - out.WriteByte(0) // +0x05 unknown - writeUint16LE(out, 0) // +0x06 unknown - writeUint16LE(out, q.RankBand) // +0x08 rankBand - writeUint16LE(out, 0) // +0x0A questTypeID - writeUint32LE(out, q.Fee) // +0x0C questFee - writeUint32LE(out, q.RewardMain) // +0x10 rewardMain - writeUint32LE(out, 0) // +0x14 cartsOrReduction - writeUint16LE(out, q.RewardSubA) // +0x18 rewardA - writeUint16LE(out, 0) // +0x1A padding - writeUint16LE(out, q.RewardSubB) // +0x1C rewardB - writeUint16LE(out, q.HardHRReq) // +0x1E hardHRReq - writeUint32LE(out, q.TimeLimitMinutes*60*30) // +0x20 questTime (frames at 30Hz) - writeUint32LE(out, q.Map) // +0x24 questMap - writeUint32LE(out, questStringsTablePtr) // +0x28 questStringsPtr - writeUint16LE(out, 0) // +0x2C unknown - writeUint16LE(out, q.QuestID) // +0x2E questID + mainStart := qb.out.Len() + qb.out.WriteByte(0) // +0x00 unknown + qb.out.WriteByte(0) // +0x01 musicMode + qb.out.WriteByte(0) // +0x02 localeFlags + qb.out.WriteByte(0) // +0x03 unknown + qb.out.WriteByte(0) // +0x04 rankingID + qb.out.WriteByte(0) // +0x05 unknown + writeUint16LE(qb.out, 0) // +0x06 unknown + writeUint16LE(qb.out, q.RankBand) // +0x08 rankBand + writeUint16LE(qb.out, 0) // +0x0A questTypeID + writeUint32LE(qb.out, q.Fee) // +0x0C questFee + writeUint32LE(qb.out, q.RewardMain) // +0x10 rewardMain + writeUint32LE(qb.out, 0) // +0x14 cartsOrReduction + writeUint16LE(qb.out, q.RewardSubA) // +0x18 rewardA + writeUint16LE(qb.out, 0) // +0x1A padding + writeUint16LE(qb.out, q.RewardSubB) // +0x1C rewardB + writeUint16LE(qb.out, q.HardHRReq) // +0x1E hardHRReq + writeUint32LE(qb.out, q.TimeLimitMinutes*60*30) // +0x20 questTime (frames at 30Hz) + writeUint32LE(qb.out, q.Map) // +0x24 questMap + writeUint32LE(qb.out, questStringsTablePtr) // +0x28 questStringsPtr + writeUint16LE(qb.out, 0) // +0x2C unknown + writeUint16LE(qb.out, q.QuestID) // +0x2E questID // +0x30 objectives[3] (8 bytes each) for _, obj := range []QuestObjectiveJSON{q.ObjectiveMain, q.ObjectiveSubA, q.ObjectiveSubB} { @@ -424,18 +604,18 @@ func CompileQuestJSON(data []byte) ([]byte, error) { if err != nil { return nil, err } - out.Write(b) + qb.out.Write(b) } // +0x48 post-objectives fields - out.WriteByte(0) // +0x48 unknown - out.WriteByte(0) // +0x49 unknown - writeUint16LE(out, 0) // +0x4A padding - writeUint16LE(out, q.JoinRankMin) // +0x4C joinRankMin - writeUint16LE(out, q.JoinRankMax) // +0x4E joinRankMax - writeUint16LE(out, q.PostRankMin) // +0x50 postRankMin - writeUint16LE(out, q.PostRankMax) // +0x52 postRankMax - pad(out, 8) // +0x54 padding[8] + qb.out.WriteByte(0) // +0x48 unknown + qb.out.WriteByte(0) // +0x49 unknown + writeUint16LE(qb.out, 0) // +0x4A padding + writeUint16LE(qb.out, q.JoinRankMin) // +0x4C joinRankMin + writeUint16LE(qb.out, q.JoinRankMax) // +0x4E joinRankMax + writeUint16LE(qb.out, q.PostRankMin) // +0x50 postRankMin + writeUint16LE(qb.out, q.PostRankMax) // +0x52 postRankMax + pad(qb.out, 8) // +0x54 padding[8] // +0x5C forced equipment (6 slots × 4 u16 = 48 bytes) eq := q.ForcedEquipment @@ -444,90 +624,88 @@ func CompileQuestJSON(data []byte) ([]byte, error) { } for _, slot := range [][4]uint16{eq.Legs, eq.Weapon, eq.Head, eq.Chest, eq.Arms, eq.Waist} { for _, v := range slot { - writeUint16LE(out, v) + writeUint16LE(qb.out, v) } } // +0x8C unknown u32 - writeUint32LE(out, 0) + writeUint32LE(qb.out, 0) // +0x90 monster variants[3] + mapVariant - out.WriteByte(0) // monsterVariants[0] - out.WriteByte(0) // monsterVariants[1] - out.WriteByte(0) // monsterVariants[2] - out.WriteByte(0) // mapVariant + qb.out.WriteByte(0) // monsterVariants[0] + qb.out.WriteByte(0) // monsterVariants[1] + qb.out.WriteByte(0) // monsterVariants[2] + qb.out.WriteByte(0) // mapVariant // +0x94 requiredItemType (ItemID = u16), requiredItemCount - writeUint16LE(out, 0) - out.WriteByte(0) // requiredItemCount + writeUint16LE(qb.out, 0) + qb.out.WriteByte(0) // requiredItemCount // +0x97 questVariants - out.WriteByte(q.QuestVariant1) - out.WriteByte(q.QuestVariant2) - out.WriteByte(q.QuestVariant3) - out.WriteByte(q.QuestVariant4) + qb.out.WriteByte(q.QuestVariant1) + qb.out.WriteByte(q.QuestVariant2) + qb.out.WriteByte(q.QuestVariant3) + qb.out.WriteByte(q.QuestVariant4) // +0x9B padding[5] - pad(out, 5) + pad(qb.out, 5) // +0xA0 allowedEquipBitmask, points - writeUint32LE(out, 0) // allowedEquipBitmask - writeUint32LE(out, 0) // mainPoints - writeUint32LE(out, 0) // subAPoints - writeUint32LE(out, 0) // subBPoints + writeUint32LE(qb.out, 0) // allowedEquipBitmask + writeUint32LE(qb.out, 0) // mainPoints + writeUint32LE(qb.out, 0) // subAPoints + writeUint32LE(qb.out, 0) // subBPoints // +0xB0 rewardItems[3] (ItemID = u16, 3 items = 6 bytes) - pad(out, 6) + pad(qb.out, 6) // +0xB6 interception section (non-SlayAll path: padding[3] + MonsterID[1] = 4 bytes) - pad(out, 4) + pad(qb.out, 4) // +0xBA padding[0xA] = 10 bytes - pad(out, 10) + pad(qb.out, 10) // +0xC4 questClearsAllowed - writeUint32LE(out, 0) + writeUint32LE(qb.out, 0) // +0xC8 = 200 bytes so far for documented fields. ZZ body = 320 bytes. // Zero-pad the remaining unknown ZZ-specific fields. - writtenInMain := out.Len() - mainStart + writtenInMain := qb.out.Len() - mainStart if writtenInMain < mainPropSize { - pad(out, mainPropSize-writtenInMain) + pad(qb.out, mainPropSize-writtenInMain) } else if writtenInMain > mainPropSize { return nil, fmt.Errorf("mainQuestProperties overflowed: wrote %d, max %d", writtenInMain, mainPropSize) } - if out.Len() != int(questTypeFlagsPtr)+mainPropSize { - return nil, fmt.Errorf("main prop end mismatch: at %d, want %d", out.Len(), int(questTypeFlagsPtr)+mainPropSize) + if qb.out.Len() != int(questTypeFlagsPtr)+mainPropSize { + return nil, fmt.Errorf("main prop end mismatch: at %d, want %d", qb.out.Len(), int(questTypeFlagsPtr)+mainPropSize) } // ── QuestText pointer table (32 bytes) ─────────────────────────────── for _, ptr := range stringPtrs { - writeUint32LE(out, ptr) + writeUint32LE(qb.out, ptr) } // ── String data ────────────────────────────────────────────────────── for _, s := range sjisStrings { - out.Write(s) + qb.out.Write(s) } // Pad to afterStrings alignment. - for uint32(out.Len()) < afterStrings { - out.WriteByte(0) + for uint32(qb.out.Len()) < afterStrings { + qb.out.WriteByte(0) } // ── Stages ─────────────────────────────────────────────────────────── - // Each Stage: u32 stageID + 12 bytes padding = 16 bytes. for _, st := range q.Stages { - writeUint32LE(out, st.StageID) - pad(out, 12) + writeUint32LE(qb.out, st.StageID) + pad(qb.out, 12) } - for uint32(out.Len()) < afterStages { - out.WriteByte(0) + for uint32(qb.out.Len()) < afterStages { + qb.out.WriteByte(0) } // ── Supply Box ─────────────────────────────────────────────────────── - // Three sections: main (24 slots), subA (8 slots), subB (8 slots). type slot struct { items []QuestSupplyItemJSON max int @@ -542,29 +720,323 @@ func CompileQuestJSON(data []byte) ([]byte, error) { if written >= section.max { break } - writeUint16LE(out, item.Item) - writeUint16LE(out, item.Quantity) + writeUint16LE(qb.out, item.Item) + writeUint16LE(qb.out, item.Quantity) written++ } - // Pad remaining slots with zeros. for written < section.max { - writeUint32LE(out, 0) + writeUint32LE(qb.out, 0) written++ } } // ── Reward Tables ──────────────────────────────────────────────────── - // Written immediately after the supply box (at rewardPtr), then padded - // to 4-byte alignment before the monster spawn list. - out.Write(rewardBuf) - for uint32(out.Len()) < largeMonsterPtr { - out.WriteByte(0) + qb.out.Write(rewardBuf) + for uint32(qb.out.Len()) < largeMonsterPtr { + qb.out.WriteByte(0) } // ── Large Monster Spawns ───────────────────────────────────────────── - out.Write(monsterBuf) + qb.out.Write(monsterBuf) + for uint32(qb.out.Len()) < afterMonsters { + qb.out.WriteByte(0) + } - return out.Bytes(), nil + // ── Variable sections: map sections, area mappings, transitions, etc. ── + // All written at afterMonsters and beyond, pointers patched into header. + + // ── Map Sections (questAreaPtr) ────────────────────────────────────── + // Layout: + // u32 ptr[0], u32 ptr[1], ..., u32(0) terminator + // For each section: + // mapSection: u32 loadedStage, u32 unk, u32 spawnTypesPtr, u32 spawnStatsPtr + // u32(0) gap, u16 unk (= 6 bytes after mapSection) + // spawnTypes data: (MonsterID u8 + pad[3]) per entry, terminated by 0xFFFF + // spawnStats data: MinionSpawn (60 bytes) per entry, terminated by 0xFFFF + if len(q.MapSections) > 0 { + questAreaOff := qb.out.Len() + qb.patchValue(hdrQuestAreaOff, uint32(questAreaOff)) + + // Write pointer array (one u32 per section + terminator). + sectionPtrOffs := make([]int, len(q.MapSections)) + for i := range q.MapSections { + sectionPtrOffs[i] = qb.reserve() + } + writeUint32LE(qb.out, 0) // terminator + + // Write each mapSection block. + type sectionPtrs struct { + spawnTypesOff int + spawnStatsOff int + } + internalPtrs := make([]sectionPtrs, len(q.MapSections)) + + for i, ms := range q.MapSections { + // Patch the pointer-array entry to point here. + qb.patch(sectionPtrOffs[i]) + + // mapSection: loadedStage, unk, spawnTypesPtr, spawnStatsPtr + writeUint32LE(qb.out, ms.LoadedStage) + writeUint32LE(qb.out, 0) // unk + internalPtrs[i].spawnTypesOff = qb.reserve() + internalPtrs[i].spawnStatsOff = qb.reserve() + + // u32(0) gap + u16 unk immediately after the 16-byte mapSection. + writeUint32LE(qb.out, 0) + writeUint16LE(qb.out, 0) + } + + // Write spawn data for each section. + for i, ms := range q.MapSections { + // spawnTypes: varPaddT = 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[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[gatheringTablesQty] = u32 ptr per table. + // Each ptr points to GatherItem[] terminated by u16(0xFFFF). + // GatherItem: u16 rate + u16 item = 4 bytes. + if numGatheringTables > 0 { + gatherTablesOff := qb.out.Len() + qb.patchValue(hdrGatherTablesOff, uint32(gatherTablesOff)) + + tblPtrOffs := make([]int, numGatheringTables) + for i := range q.GatheringTables { + tblPtrOffs[i] = qb.reserve() + } + + for i, tbl := range q.GatheringTables { + qb.patch(tblPtrOffs[i]) + for _, item := range tbl.Items { + writeUint16LE(qb.out, item.Rate) + writeUint16LE(qb.out, item.Item) + } + writeUint16LE(qb.out, 0xFFFF) // terminator + } + } + + return qb.out.Bytes(), nil } // buildRewardTables serialises the reward table array and all reward item lists. @@ -587,7 +1059,6 @@ func buildRewardTables(tables []QuestRewardTableJSON) []byte { for _, t := range tables { // tableOffset is relative to the start of rewardPtr in the file. - // We compute it as headerArraySize + offset into itemData. tableOffset := headerArraySize + uint32(itemData.Len()) headers.WriteByte(t.TableID) diff --git a/server/channelserver/quest_json_parser.go b/server/channelserver/quest_json_parser.go index 417e67528..b91a1efc6 100644 --- a/server/channelserver/quest_json_parser.go +++ b/server/channelserver/quest_json_parser.go @@ -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 (0x44–0x85) ──────────────────────────── q.MonsterSizeMulti = u16(0x44) @@ -95,7 +95,12 @@ func ParseQuestBinary(data []byte) (*QuestJSON, error) { // 0x5C questTypeID/unknown — skipped // 0x60 padding q.StatTable2 = u8(0x61) - // 0x62–0x85 padding, questKn1/2/3, gatheringTablesQty, zone counts, unknowns — skipped + // 0x62–0x72 padding + // 0x73 questKn1, 0x74 questKn2, 0x76 questKn3 — skipped + gatheringTablesQty := int(u16(0x78)) + // 0x7A unknown + area1Zones := int(u8(0x7C)) + // 0x7D–0x7F area2–4Zones (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[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[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 = 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 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[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[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) { diff --git a/server/channelserver/quest_json_test.go b/server/channelserver/quest_json_test.go index 1446f9917..cf0ff89bf 100644 --- a/server/channelserver/quest_json_test.go +++ b/server/channelserver/quest_json_test.go @@ -520,6 +520,246 @@ func TestRoundTrip_EmptyQuest(t *testing.T) { 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 ───────────────────────────────────────────────────────── // // 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. // // Layout constants for minimalQuestJSON: -// headerSize = 68 (0x44) -// genPropSize = 66 (0x42) -// mainPropOffset = 0x86 (= headerSize + genPropSize) -// questStringsPtr = 0x1C6 (= mainPropOffset + 320) - +// +// headerSize = 68 (0x44) +// genPropSize = 66 (0x42) +// mainPropOffset = 0x86 (= headerSize + genPropSize) +// questStringsPtr = 0x1C6 (= mainPropOffset + 320) func TestGolden_MinimalQuestBinaryLayout(t *testing.T) { data, err := CompileQuestJSON([]byte(minimalQuestJSON)) if err != nil { @@ -545,9 +785,6 @@ func TestGolden_MinimalQuestBinaryLayout(t *testing.T) { // ── Header (0x00–0x43) ─────────────────────────────────────────────── 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)") assertByte(t, data, 0x12, 0, "hidden") 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, 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:]) unk34Ptr := binary.LittleEndian.Uint32(data[0x34:]) if unk34Ptr != loadedStagesPtr+16 { @@ -620,14 +855,12 @@ func TestGolden_MinimalQuestBinaryLayout(t *testing.T) { assertU16(t, data, mp+0x2E, 1, "mp.questID") // 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") assertByte(t, data, mp+0x34, 11, "obj[0].target") assertByte(t, data, mp+0x35, 0, "obj[0].pad") assertU16(t, data, mp+0x36, 1, "obj[0].count") // Objective[1]: deliver, target=149, count=3 - // goalType=0x00000002, u16(target)=0x0095, u16(count)=0x0003 assertU32(t, data, mp+0x38, questObjDeliver, "obj[1].goalType") assertU16(t, data, mp+0x3C, 149, "obj[1].target") 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+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++ { 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") // ── QuestText pointer table (0x1C6–0x1E5) ─────────────────────────── - // 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++ { off := int(questStringsPtr) + i*4 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):])) end := titlePtr for end < len(data) && data[end] != 0 { @@ -674,7 +905,6 @@ func TestGolden_MinimalQuestBinaryLayout(t *testing.T) { // ── Stage entry (1 stage: stageID=2) ──────────────────────────────── assertU32(t, data, int(loadedStagesPtr), 2, "stage[0].stageID") - // padding 12 bytes after stageID must be zero for i := 1; i < 16; i++ { 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:])) assertU16(t, data, supplyBoxPtr, 1, "supply_main[0].item") assertU16(t, data, supplyBoxPtr+2, 5, "supply_main[0].quantity") - // Remaining 23 main slots must be zero. for i := 1; i < 24; i++ { assertU32(t, data, supplyBoxPtr+i*4, 0, "supply_main slot empty") } - // All 8 subA slots zero. subABase := supplyBoxPtr + 24*4 for i := 0; i < 8; i++ { assertU32(t, data, subABase+i*4, 0, "supply_subA slot empty") } - // All 8 subB slots zero. subBBase := subABase + 8*4 for i := 0; i < 8; i++ { assertU32(t, data, subBBase+i*4, 0, "supply_subB slot empty") } // ── 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:])) assertByte(t, data, rewardPtr, 1, "reward header[0].tableID") assertByte(t, data, rewardPtr+1, 0, "reward header[0].pad1") assertU16(t, data, rewardPtr+2, 0, "reward header[0].pad2") // headerArraySize = 1×8 + 2 = 10 assertU32(t, data, rewardPtr+4, 10, "reward header[0].tableOffset") - // terminator at rewardPtr+8 assertU16(t, data, rewardPtr+8, 0xFFFF, "reward header terminator") - // item 0: rate=50, item=149, qty=1 itemsBase := rewardPtr + 10 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+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+8, 153, "reward[0].items[1].item") assertU16(t, data, itemsBase+10, 1, "reward[0].items[1].quantity") - // item list terminator assertU16(t, data, itemsBase+12, 0xFFFF, "reward item terminator") // ── 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:])) assertByte(t, data, largeMonsterPtr, 11, "monster[0].id") - // pad[3] assertByte(t, data, largeMonsterPtr+1, 0, "monster[0].pad1") assertByte(t, data, largeMonsterPtr+2, 0, "monster[0].pad2") assertByte(t, data, largeMonsterPtr+3, 0, "monster[0].pad3") assertU32(t, data, largeMonsterPtr+4, 1, "monster[0].spawnAmount") assertU32(t, data, largeMonsterPtr+8, 5, "monster[0].spawnStage") - // pad[16] at +0x0C for i := 0; i < 16; i++ { 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+0x24, 0.0, "monster[0].y") assertF32(t, data, largeMonsterPtr+0x28, -2000.0, "monster[0].z") - // pad[16] at +0x2C for i := 0; i < 16; i++ { 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") // ── 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 if 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 ───────────────────────────────────────── func TestGolden_ObjectiveEncoding(t *testing.T) {