mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-22 15:43:49 +01:00
feat(quests): implement all remaining binary sections in JSON format
Implement the 8 pointer-addressed sections that were previously written as null pointers in the quest JSON compiler and parser: - questAreaPtr → MapSections (ptMapSection pointer array + minion spawns) - areaTransitionsPtr → AreaTransitions (per-zone floatSet arrays, 52B each) - areaMappingPtr → AreaMappings (32-byte coordinate mapping entries) - mapInfoPtr → MapInfo (qMapID + returnBC_ID, 8 bytes) - gatheringPointsPtr → GatheringPoints (per-zone 24-byte gatheringPoint entries) - areaFacilitiesPtr → AreaFacilities (per-zone facPoint blocks with sentinel) - someStringsPtr → SomeString/QuestTypeString (two u32 ptrs + Shift-JIS data) - gatheringTablesPtr → GatheringTables (pointer array → GatherItem[] lists) Also set gatheringTablesQty and area1Zones in generalQuestProperties from JSON data, and validate zone-length consistency between AreaTransitions, GatheringPoints, and AreaFacilities arrays. Round-trip tests cover all new sections to ensure compile(parse(bin)) == bin.
This commit is contained in:
@@ -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<MonsterID,3> = u8 monster + pad[3] per entry.
|
||||
// Terminated by first 2 bytes == 0xFFFF.
|
||||
qb.patch(internalPtrs[i].spawnTypesOff)
|
||||
for _, monID := range ms.SpawnMonsters {
|
||||
qb.out.WriteByte(monID)
|
||||
pad(qb.out, 3)
|
||||
}
|
||||
writeUint16LE(qb.out, 0xFFFF) // terminator
|
||||
|
||||
// Align to 4 bytes before spawnStats.
|
||||
for qb.out.Len()%4 != 0 {
|
||||
qb.out.WriteByte(0)
|
||||
}
|
||||
|
||||
// spawnStats: MinionSpawn per entry (60 bytes), terminated by 0xFFFF.
|
||||
qb.patch(internalPtrs[i].spawnStatsOff)
|
||||
for _, ms2 := range ms.MinionSpawns {
|
||||
qb.out.WriteByte(ms2.Monster)
|
||||
qb.out.WriteByte(0) // padding[1]
|
||||
writeUint16LE(qb.out, ms2.SpawnToggle) // spawnToggle
|
||||
writeUint32LE(qb.out, ms2.SpawnAmount) // spawnAmount
|
||||
writeUint32LE(qb.out, 0) // unk u32
|
||||
pad(qb.out, 0x10) // padding[16]
|
||||
writeUint32LE(qb.out, 0) // unk u32
|
||||
writeFloat32LE(qb.out, ms2.X)
|
||||
writeFloat32LE(qb.out, ms2.Y)
|
||||
writeFloat32LE(qb.out, ms2.Z)
|
||||
pad(qb.out, 0x10) // padding[16]
|
||||
}
|
||||
writeUint16LE(qb.out, 0xFFFF) // terminator
|
||||
|
||||
// Align for next section.
|
||||
for qb.out.Len()%4 != 0 {
|
||||
qb.out.WriteByte(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Area Mappings (areaMappingPtr) ────────────────────────────────────
|
||||
// Written BEFORE area transitions so the parser can use
|
||||
// "read until areaTransitionsPtr" to know the count.
|
||||
// Layout: AreaMappings[n] × 32 bytes each, back-to-back.
|
||||
// float area_xPos, float area_zPos, pad[8],
|
||||
// float base_xPos, float base_zPos, float kn_Pos, pad[4]
|
||||
if len(q.AreaMappings) > 0 {
|
||||
areaMappingOff := qb.out.Len()
|
||||
qb.patchValue(hdrAreaMappingOff, uint32(areaMappingOff))
|
||||
|
||||
for _, am := range q.AreaMappings {
|
||||
writeFloat32LE(qb.out, am.AreaX)
|
||||
writeFloat32LE(qb.out, am.AreaZ)
|
||||
pad(qb.out, 8)
|
||||
writeFloat32LE(qb.out, am.BaseX)
|
||||
writeFloat32LE(qb.out, am.BaseZ)
|
||||
writeFloat32LE(qb.out, am.KnPos)
|
||||
pad(qb.out, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Area Transitions (areaTransitionsPtr) ─────────────────────────────
|
||||
// Layout: playerAreaChange[area1Zones] = u32 ptr per zone.
|
||||
// Then floatSet arrays for each zone with transitions.
|
||||
if numZones > 0 {
|
||||
areaTransOff := qb.out.Len()
|
||||
qb.patchValue(hdrAreaTransOff, uint32(areaTransOff))
|
||||
|
||||
// Write pointer array.
|
||||
zonePtrOffs := make([]int, numZones)
|
||||
for i := range q.AreaTransitions {
|
||||
zonePtrOffs[i] = qb.reserve()
|
||||
}
|
||||
|
||||
// Write floatSet arrays for non-empty zones.
|
||||
for i, zone := range q.AreaTransitions {
|
||||
if len(zone.Transitions) == 0 {
|
||||
// Null pointer — leave as 0.
|
||||
continue
|
||||
}
|
||||
qb.patch(zonePtrOffs[i])
|
||||
for _, tr := range zone.Transitions {
|
||||
writeInt16LE(qb.out, tr.TargetStageID1)
|
||||
writeInt16LE(qb.out, tr.StageVariant)
|
||||
writeFloat32LE(qb.out, tr.CurrentX)
|
||||
writeFloat32LE(qb.out, tr.CurrentY)
|
||||
writeFloat32LE(qb.out, tr.CurrentZ)
|
||||
for _, f := range tr.TransitionBox {
|
||||
writeFloat32LE(qb.out, f)
|
||||
}
|
||||
writeFloat32LE(qb.out, tr.TargetX)
|
||||
writeFloat32LE(qb.out, tr.TargetY)
|
||||
writeFloat32LE(qb.out, tr.TargetZ)
|
||||
for _, r := range tr.TargetRotation {
|
||||
writeInt16LE(qb.out, r)
|
||||
}
|
||||
}
|
||||
// Terminate with s16(-1).
|
||||
writeInt16LE(qb.out, -1)
|
||||
// Align.
|
||||
for qb.out.Len()%4 != 0 {
|
||||
qb.out.WriteByte(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Map Info (mapInfoPtr) ─────────────────────────────────────────────
|
||||
if q.MapInfo != nil {
|
||||
mapInfoOff := qb.out.Len()
|
||||
qb.patchValue(hdrMapInfoOff, uint32(mapInfoOff))
|
||||
writeUint32LE(qb.out, q.MapInfo.MapID)
|
||||
writeUint32LE(qb.out, q.MapInfo.ReturnBCID)
|
||||
}
|
||||
|
||||
// ── Gathering Points (gatheringPointsPtr) ─────────────────────────────
|
||||
// Layout: ptGatheringPoint[area1Zones] = u32 ptr per zone.
|
||||
// Each non-null ptr points to gatheringPoint[4] terminated by xPos=-1.0.
|
||||
if numZones > 0 && len(q.GatheringPoints) > 0 {
|
||||
gatherPtsOff := qb.out.Len()
|
||||
qb.patchValue(hdrGatherPtsOff, uint32(gatherPtsOff))
|
||||
|
||||
// Write pointer array.
|
||||
gpPtrOffs := make([]int, numZones)
|
||||
for i := range q.GatheringPoints {
|
||||
gpPtrOffs[i] = qb.reserve()
|
||||
}
|
||||
|
||||
// Write gathering point arrays for non-empty zones.
|
||||
for i, zone := range q.GatheringPoints {
|
||||
if len(zone.Points) == 0 {
|
||||
continue
|
||||
}
|
||||
qb.patch(gpPtrOffs[i])
|
||||
for _, gp := range zone.Points {
|
||||
writeFloat32LE(qb.out, gp.X)
|
||||
writeFloat32LE(qb.out, gp.Y)
|
||||
writeFloat32LE(qb.out, gp.Z)
|
||||
writeFloat32LE(qb.out, gp.Range)
|
||||
writeUint16LE(qb.out, gp.GatheringID)
|
||||
writeUint16LE(qb.out, gp.MaxCount)
|
||||
pad(qb.out, 2)
|
||||
writeUint16LE(qb.out, gp.MinCount)
|
||||
}
|
||||
// Terminator: xPos == -1.0 (0xBF800000).
|
||||
writeFloat32LE(qb.out, float32(math.Float32frombits(0xBF800000)))
|
||||
// Pad terminator entry to 24 bytes total (only wrote 4).
|
||||
pad(qb.out, 20)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Area Facilities (areaFacilitiesPtr) ───────────────────────────────
|
||||
// Layout: ptVar<facPointBlock>[area1Zones] = u32 ptr per zone.
|
||||
// Each non-null ptr points to a facPointBlock.
|
||||
// facPoint: pad[2] + SpecAc(u16) + xPos + yPos + zPos + range + id(u16) + pad[2] = 24 bytes
|
||||
// facPointBlock: facPoints[] terminated by (xPos-at-$+4 == 0xBF800000) + pad[0xC] + float + float
|
||||
// Terminator layout: write pad[2]+type[2] then float32(-1.0) to trigger termination,
|
||||
// then block footer: pad[0xC] + float(0) + float(0).
|
||||
if numZones > 0 && len(q.AreaFacilities) > 0 {
|
||||
facOff := qb.out.Len()
|
||||
qb.patchValue(hdrFacilitiesOff, uint32(facOff))
|
||||
|
||||
facPtrOffs := make([]int, numZones)
|
||||
for i := range q.AreaFacilities {
|
||||
facPtrOffs[i] = qb.reserve()
|
||||
}
|
||||
|
||||
for i, zone := range q.AreaFacilities {
|
||||
if len(zone.Points) == 0 {
|
||||
continue
|
||||
}
|
||||
qb.patch(facPtrOffs[i])
|
||||
|
||||
for _, fp := range zone.Points {
|
||||
pad(qb.out, 2) // pad[2]
|
||||
writeUint16LE(qb.out, fp.Type) // SpecAc type
|
||||
writeFloat32LE(qb.out, fp.X)
|
||||
writeFloat32LE(qb.out, fp.Y)
|
||||
writeFloat32LE(qb.out, fp.Z)
|
||||
writeFloat32LE(qb.out, fp.Range)
|
||||
writeUint16LE(qb.out, fp.ID)
|
||||
pad(qb.out, 2) // pad[2]
|
||||
}
|
||||
|
||||
// Terminator: the while condition checks read_unsigned($+4,4).
|
||||
// Write 4 bytes header (pad[2]+type[2]) then float32(-1.0).
|
||||
pad(qb.out, 2)
|
||||
writeUint16LE(qb.out, 0)
|
||||
writeFloat32LE(qb.out, float32(math.Float32frombits(0xBF800000)))
|
||||
|
||||
// Block footer: padding[0xC] + float(0) + float(0) = 20 bytes.
|
||||
pad(qb.out, 0xC)
|
||||
writeFloat32LE(qb.out, 0)
|
||||
writeFloat32LE(qb.out, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Some Strings (someStringsPtr / unk30) ─────────────────────────────
|
||||
// Layout at unk30: ptr someStringPtr, ptr questTypePtr (8 bytes),
|
||||
// then the string data.
|
||||
hasSomeStrings := q.SomeString != "" || q.QuestType != ""
|
||||
if hasSomeStrings {
|
||||
someStringsOff := qb.out.Len()
|
||||
qb.patchValue(hdrSomeStringsOff, uint32(someStringsOff))
|
||||
|
||||
// Two pointer slots.
|
||||
someStrPtrOff := qb.reserve()
|
||||
questTypePtrOff := qb.reserve()
|
||||
|
||||
if q.SomeString != "" {
|
||||
qb.patch(someStrPtrOff)
|
||||
b, err := toShiftJIS(q.SomeString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
qb.out.Write(b)
|
||||
}
|
||||
|
||||
if q.QuestType != "" {
|
||||
qb.patch(questTypePtrOff)
|
||||
b, err := toShiftJIS(q.QuestType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
qb.out.Write(b)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gathering Tables (gatheringTablesPtr) ─────────────────────────────
|
||||
// Layout: ptVar<gatheringTable>[gatheringTablesQty] = u32 ptr per table.
|
||||
// Each ptr points to GatherItem[] terminated by u16(0xFFFF).
|
||||
// GatherItem: u16 rate + u16 item = 4 bytes.
|
||||
if numGatheringTables > 0 {
|
||||
gatherTablesOff := qb.out.Len()
|
||||
qb.patchValue(hdrGatherTablesOff, uint32(gatherTablesOff))
|
||||
|
||||
tblPtrOffs := make([]int, numGatheringTables)
|
||||
for i := range q.GatheringTables {
|
||||
tblPtrOffs[i] = qb.reserve()
|
||||
}
|
||||
|
||||
for i, tbl := range q.GatheringTables {
|
||||
qb.patch(tblPtrOffs[i])
|
||||
for _, item := range tbl.Items {
|
||||
writeUint16LE(qb.out, item.Rate)
|
||||
writeUint16LE(qb.out, item.Item)
|
||||
}
|
||||
writeUint16LE(qb.out, 0xFFFF) // terminator
|
||||
}
|
||||
}
|
||||
|
||||
return qb.out.Bytes(), nil
|
||||
}
|
||||
|
||||
// buildRewardTables serialises the reward table array and all reward item lists.
|
||||
@@ -587,7 +1059,6 @@ func buildRewardTables(tables []QuestRewardTableJSON) []byte {
|
||||
|
||||
for _, t := range tables {
|
||||
// tableOffset is relative to the start of rewardPtr in the file.
|
||||
// We compute it as headerArraySize + offset into itemData.
|
||||
tableOffset := headerArraySize + uint32(itemData.Len())
|
||||
|
||||
headers.WriteByte(t.TableID)
|
||||
|
||||
@@ -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<facPointBlock>[area1Zones]: one u32 ptr per zone.
|
||||
if areaFacilitiesPtr != 0 && area1Zones > 0 {
|
||||
facilities, err := parseAreaFacilities(data, areaFacilitiesPtr, area1Zones, u32, u16, f32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q.AreaFacilities = facilities
|
||||
}
|
||||
|
||||
// ── Some Strings (someStringsPtr / unk30) ─────────────────────────────
|
||||
// Layout: ptr someStringPtr, ptr questTypePtr (8 bytes at someStringsPtr).
|
||||
if someStringsPtr != 0 {
|
||||
if err := check(someStringsPtr, 8, "someStrings"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
someStrP := int(u32(someStringsPtr))
|
||||
questTypeP := int(u32(someStringsPtr + 4))
|
||||
if someStrP != 0 {
|
||||
s, err := readSJIS(someStrP)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("someString: %w", err)
|
||||
}
|
||||
q.SomeString = s
|
||||
}
|
||||
if questTypeP != 0 {
|
||||
s, err := readSJIS(questTypeP)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("questTypeString: %w", err)
|
||||
}
|
||||
q.QuestType = s
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gathering Tables (gatheringTablesPtr) ─────────────────────────────
|
||||
// ptVar<gatheringTable>[gatheringTablesQty]: one u32 ptr per table.
|
||||
// GatherItem: u16 rate + u16 item, terminated by u16(0xFFFF).
|
||||
if gatheringTablesPtr != 0 && gatheringTablesQty > 0 {
|
||||
tables, err := parseGatheringTables(data, gatheringTablesPtr, gatheringTablesQty, u32, u16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q.GatheringTables = tables
|
||||
}
|
||||
|
||||
return q, nil
|
||||
}
|
||||
|
||||
@@ -327,7 +417,6 @@ func parseRewardTables(data []byte, baseOff int) ([]QuestRewardTableJSON, error)
|
||||
if off+2 > len(data) {
|
||||
return nil, fmt.Errorf("reward table header truncated at 0x%X", off)
|
||||
}
|
||||
// Check for terminator (0xFFFF).
|
||||
if binary.LittleEndian.Uint16(data[off:]) == 0xFFFF {
|
||||
break
|
||||
}
|
||||
@@ -338,7 +427,6 @@ func parseRewardTables(data []byte, baseOff int) ([]QuestRewardTableJSON, error)
|
||||
tableOff := int(binary.LittleEndian.Uint32(data[off+4:])) + baseOff
|
||||
off += 8
|
||||
|
||||
// Read items at tableOff.
|
||||
items, err := parseRewardItems(data, tableOff)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reward table %d items: %w", tableID, err)
|
||||
@@ -403,6 +491,347 @@ func parseMonsterSpawns(data []byte, baseOff int, f32fn func(int) float32) ([]Qu
|
||||
return monsters, nil
|
||||
}
|
||||
|
||||
// parseMapSections reads the MapZones structure at baseOff.
|
||||
// Layout: u32 ptr[] terminated by u32(0); each ptr points to a mapSection:
|
||||
//
|
||||
// u32 loadedStage, u32 unk, u32 spawnTypesPtr, u32 spawnStatsPtr.
|
||||
//
|
||||
// After the 16-byte mapSection: u32(0) gap + u16 unk (2 bytes).
|
||||
// spawnTypes: varPaddT<MonsterID,3> = u8+pad[3] per entry, terminated by 0xFFFF.
|
||||
// spawnStats: MinionSpawn (60 bytes) per entry, terminated by 0xFFFF in first 2 bytes.
|
||||
func parseMapSections(data []byte, baseOff int,
|
||||
u32fn func(int) uint32,
|
||||
u16fn func(int) uint16,
|
||||
f32fn func(int) float32,
|
||||
) ([]QuestMapSectionJSON, error) {
|
||||
var sections []QuestMapSectionJSON
|
||||
|
||||
// Read pointer array (terminated by u32(0)).
|
||||
off := baseOff
|
||||
for {
|
||||
if off+4 > len(data) {
|
||||
return nil, fmt.Errorf("mapSection pointer array truncated at 0x%X", off)
|
||||
}
|
||||
ptr := int(u32fn(off))
|
||||
off += 4
|
||||
if ptr == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// Read mapSection at ptr.
|
||||
if ptr+16 > len(data) {
|
||||
return nil, fmt.Errorf("mapSection at 0x%X truncated", ptr)
|
||||
}
|
||||
loadedStage := u32fn(ptr)
|
||||
// ptr+4 is unk u32 — skip
|
||||
spawnTypesPtr := int(u32fn(ptr + 8))
|
||||
spawnStatsPtr := int(u32fn(ptr + 12))
|
||||
|
||||
ms := QuestMapSectionJSON{LoadedStage: loadedStage}
|
||||
|
||||
// Read spawnTypes: varPaddT<MonsterID,3> terminated by 0xFFFF.
|
||||
if spawnTypesPtr != 0 {
|
||||
stOff := spawnTypesPtr
|
||||
for {
|
||||
if stOff+2 > len(data) {
|
||||
return nil, fmt.Errorf("spawnTypes at 0x%X truncated", stOff)
|
||||
}
|
||||
if u16fn(stOff) == 0xFFFF {
|
||||
break
|
||||
}
|
||||
if stOff+4 > len(data) {
|
||||
return nil, fmt.Errorf("spawnType entry at 0x%X truncated", stOff)
|
||||
}
|
||||
monID := data[stOff]
|
||||
ms.SpawnMonsters = append(ms.SpawnMonsters, monID)
|
||||
stOff += 4 // u8 + pad[3]
|
||||
}
|
||||
}
|
||||
|
||||
// Read spawnStats: MinionSpawn terminated by 0xFFFF in first 2 bytes.
|
||||
if spawnStatsPtr != 0 {
|
||||
const minionSize = 60
|
||||
ssOff := spawnStatsPtr
|
||||
for {
|
||||
if ssOff+2 > len(data) {
|
||||
return nil, fmt.Errorf("spawnStats at 0x%X truncated", ssOff)
|
||||
}
|
||||
// Terminator: first 2 bytes == 0xFFFF.
|
||||
if u16fn(ssOff) == 0xFFFF {
|
||||
break
|
||||
}
|
||||
if ssOff+minionSize > len(data) {
|
||||
return nil, fmt.Errorf("minionSpawn at 0x%X truncated", ssOff)
|
||||
}
|
||||
spawn := QuestMinionSpawnJSON{
|
||||
Monster: data[ssOff],
|
||||
// ssOff+1 padding
|
||||
SpawnToggle: u16fn(ssOff + 2),
|
||||
SpawnAmount: u32fn(ssOff + 4),
|
||||
// +8 unk u32, +0xC pad[16], +0x1C unk u32
|
||||
X: f32fn(ssOff + 0x20),
|
||||
Y: f32fn(ssOff + 0x24),
|
||||
Z: f32fn(ssOff + 0x28),
|
||||
}
|
||||
ms.MinionSpawns = append(ms.MinionSpawns, spawn)
|
||||
ssOff += minionSize
|
||||
}
|
||||
}
|
||||
|
||||
sections = append(sections, ms)
|
||||
}
|
||||
|
||||
return sections, nil
|
||||
}
|
||||
|
||||
// parseAreaMappings reads AreaMappings entries at baseOff until endOff.
|
||||
// Each entry is 32 bytes: float areaX, float areaZ, pad[8],
|
||||
// float baseX, float baseZ, float knPos, pad[4].
|
||||
func parseAreaMappings(data []byte, baseOff, endOff int, f32fn func(int) float32) ([]QuestAreaMappingJSON, error) {
|
||||
var mappings []QuestAreaMappingJSON
|
||||
const entrySize = 32
|
||||
off := baseOff
|
||||
for off+entrySize <= endOff {
|
||||
if off+entrySize > len(data) {
|
||||
return nil, fmt.Errorf("areaMapping at 0x%X truncated", off)
|
||||
}
|
||||
am := QuestAreaMappingJSON{
|
||||
AreaX: f32fn(off),
|
||||
AreaZ: f32fn(off + 4),
|
||||
// off+8: pad[8]
|
||||
BaseX: f32fn(off + 16),
|
||||
BaseZ: f32fn(off + 20),
|
||||
KnPos: f32fn(off + 24),
|
||||
// off+28: pad[4]
|
||||
}
|
||||
mappings = append(mappings, am)
|
||||
off += entrySize
|
||||
}
|
||||
return mappings, nil
|
||||
}
|
||||
|
||||
// parseAreaTransitions reads playerAreaChange[numZones] at baseOff.
|
||||
// Each entry is a u32 pointer to a floatSet array terminated by s16(-1).
|
||||
// floatSet: s16 targetStageId + s16 stageVariant + float[3] current + float[5] box +
|
||||
// float[3] target + s16[2] rotation = 52 bytes.
|
||||
func parseAreaTransitions(data []byte, baseOff, numZones int,
|
||||
u32fn func(int) uint32,
|
||||
i16fn func(int) int16,
|
||||
f32fn func(int) float32,
|
||||
) ([]QuestAreaTransitionsJSON, error) {
|
||||
result := make([]QuestAreaTransitionsJSON, numZones)
|
||||
|
||||
if baseOff+numZones*4 > len(data) {
|
||||
return nil, fmt.Errorf("areaTransitions pointer array at 0x%X truncated", baseOff)
|
||||
}
|
||||
|
||||
for i := 0; i < numZones; i++ {
|
||||
ptr := int(u32fn(baseOff + i*4))
|
||||
if ptr == 0 {
|
||||
// Null pointer — no transitions for this zone.
|
||||
continue
|
||||
}
|
||||
|
||||
// Read floatSet entries until targetStageId1 == -1.
|
||||
var transitions []QuestAreaTransitionJSON
|
||||
off := ptr
|
||||
for {
|
||||
if off+2 > len(data) {
|
||||
return nil, fmt.Errorf("floatSet at 0x%X truncated", off)
|
||||
}
|
||||
targetStageID := i16fn(off)
|
||||
if targetStageID == -1 {
|
||||
break
|
||||
}
|
||||
// Each floatSet is 52 bytes:
|
||||
// s16 targetStageId1 + s16 stageVariant = 4
|
||||
// float[3] current = 12
|
||||
// float[5] transitionBox = 20
|
||||
// float[3] target = 12
|
||||
// s16[2] rotation = 4
|
||||
// Total = 52
|
||||
const floatSetSize = 52
|
||||
if off+floatSetSize > len(data) {
|
||||
return nil, fmt.Errorf("floatSet at 0x%X truncated (need %d bytes)", off, floatSetSize)
|
||||
}
|
||||
tr := QuestAreaTransitionJSON{
|
||||
TargetStageID1: targetStageID,
|
||||
StageVariant: i16fn(off + 2),
|
||||
CurrentX: f32fn(off + 4),
|
||||
CurrentY: f32fn(off + 8),
|
||||
CurrentZ: f32fn(off + 12),
|
||||
TargetX: f32fn(off + 36),
|
||||
TargetY: f32fn(off + 40),
|
||||
TargetZ: f32fn(off + 44),
|
||||
}
|
||||
for j := 0; j < 5; j++ {
|
||||
tr.TransitionBox[j] = f32fn(off + 16 + j*4)
|
||||
}
|
||||
tr.TargetRotation[0] = i16fn(off + 48)
|
||||
tr.TargetRotation[1] = i16fn(off + 50)
|
||||
transitions = append(transitions, tr)
|
||||
off += floatSetSize
|
||||
}
|
||||
result[i] = QuestAreaTransitionsJSON{Transitions: transitions}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseGatheringPoints reads ptGatheringPoint[numZones] at baseOff.
|
||||
// Each entry is a u32 pointer to gatheringPoint[4] terminated by xPos==-1.0.
|
||||
// gatheringPoint: float xPos, yPos, zPos, range, u16 gatheringID, u16 maxCount, pad[2], u16 minCount = 24 bytes.
|
||||
func parseGatheringPoints(data []byte, baseOff, numZones int,
|
||||
u32fn func(int) uint32,
|
||||
u16fn func(int) uint16,
|
||||
f32fn func(int) float32,
|
||||
) ([]QuestAreaGatheringJSON, error) {
|
||||
result := make([]QuestAreaGatheringJSON, numZones)
|
||||
|
||||
if baseOff+numZones*4 > len(data) {
|
||||
return nil, fmt.Errorf("gatheringPoints pointer array at 0x%X truncated", baseOff)
|
||||
}
|
||||
|
||||
const sentinel = uint32(0xBF800000) // float32(-1.0)
|
||||
const pointSize = 24
|
||||
|
||||
for i := 0; i < numZones; i++ {
|
||||
ptr := int(u32fn(baseOff + i*4))
|
||||
if ptr == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var points []QuestGatheringPointJSON
|
||||
off := ptr
|
||||
for {
|
||||
if off+4 > len(data) {
|
||||
return nil, fmt.Errorf("gatheringPoint at 0x%X truncated", off)
|
||||
}
|
||||
// Terminator: xPos bit pattern == 0xBF800000 (-1.0f).
|
||||
if binary.LittleEndian.Uint32(data[off:]) == sentinel {
|
||||
break
|
||||
}
|
||||
if off+pointSize > len(data) {
|
||||
return nil, fmt.Errorf("gatheringPoint entry at 0x%X truncated", off)
|
||||
}
|
||||
gp := QuestGatheringPointJSON{
|
||||
X: f32fn(off),
|
||||
Y: f32fn(off + 4),
|
||||
Z: f32fn(off + 8),
|
||||
Range: f32fn(off + 12),
|
||||
GatheringID: u16fn(off + 16),
|
||||
MaxCount: u16fn(off + 18),
|
||||
// off+20 pad[2]
|
||||
MinCount: u16fn(off + 22),
|
||||
}
|
||||
points = append(points, gp)
|
||||
off += pointSize
|
||||
}
|
||||
result[i] = QuestAreaGatheringJSON{Points: points}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseAreaFacilities reads ptVar<facPointBlock>[numZones] at baseOff.
|
||||
// Each entry is a u32 pointer to a facPointBlock.
|
||||
// facPoint: pad[2] + SpecAc(u16) + xPos + yPos + zPos + range + id(u16) + pad[2] = 24 bytes.
|
||||
// Termination: the loop condition checks read_unsigned($+4,4) != 0xBF800000.
|
||||
// So a facPoint whose xPos (at offset +4 from start of that potential entry) == -1.0 terminates.
|
||||
// After all facPoints: padding[0xC] + float + float = 20 bytes (block footer, not parsed into JSON).
|
||||
func parseAreaFacilities(data []byte, baseOff, numZones int,
|
||||
u32fn func(int) uint32,
|
||||
u16fn func(int) uint16,
|
||||
f32fn func(int) float32,
|
||||
) ([]QuestAreaFacilitiesJSON, error) {
|
||||
result := make([]QuestAreaFacilitiesJSON, numZones)
|
||||
|
||||
if baseOff+numZones*4 > len(data) {
|
||||
return nil, fmt.Errorf("areaFacilities pointer array at 0x%X truncated", baseOff)
|
||||
}
|
||||
|
||||
const sentinel = uint32(0xBF800000)
|
||||
const pointSize = 24
|
||||
|
||||
for i := 0; i < numZones; i++ {
|
||||
ptr := int(u32fn(baseOff + i*4))
|
||||
if ptr == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var points []QuestFacilityPointJSON
|
||||
off := ptr
|
||||
for off+8 <= len(data) {
|
||||
// Check: read_unsigned($+4, 4) == sentinel means terminate.
|
||||
// $+4 is the xPos field of the potential next facPoint.
|
||||
if binary.LittleEndian.Uint32(data[off+4:]) == sentinel {
|
||||
break
|
||||
}
|
||||
if off+pointSize > len(data) {
|
||||
return nil, fmt.Errorf("facPoint at 0x%X truncated", off)
|
||||
}
|
||||
fp := QuestFacilityPointJSON{
|
||||
// off+0: pad[2]
|
||||
Type: u16fn(off + 2),
|
||||
X: f32fn(off + 4),
|
||||
Y: f32fn(off + 8),
|
||||
Z: f32fn(off + 12),
|
||||
Range: f32fn(off + 16),
|
||||
ID: u16fn(off + 20),
|
||||
// off+22: pad[2]
|
||||
}
|
||||
points = append(points, fp)
|
||||
off += pointSize
|
||||
}
|
||||
result[i] = QuestAreaFacilitiesJSON{Points: points}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseGatheringTables reads ptVar<gatheringTable>[count] at baseOff.
|
||||
// Each entry is a u32 pointer to GatherItem[] terminated by u16(0xFFFF).
|
||||
// GatherItem: u16 rate + u16 item = 4 bytes.
|
||||
func parseGatheringTables(data []byte, baseOff, count int,
|
||||
u32fn func(int) uint32,
|
||||
u16fn func(int) uint16,
|
||||
) ([]QuestGatheringTableJSON, error) {
|
||||
result := make([]QuestGatheringTableJSON, count)
|
||||
|
||||
if baseOff+count*4 > len(data) {
|
||||
return nil, fmt.Errorf("gatheringTables pointer array at 0x%X truncated", baseOff)
|
||||
}
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
ptr := int(u32fn(baseOff + i*4))
|
||||
if ptr == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var items []QuestGatherItemJSON
|
||||
off := ptr
|
||||
for {
|
||||
if off+2 > len(data) {
|
||||
return nil, fmt.Errorf("gatheringTable at 0x%X truncated", off)
|
||||
}
|
||||
if u16fn(off) == 0xFFFF {
|
||||
break
|
||||
}
|
||||
if off+4 > len(data) {
|
||||
return nil, fmt.Errorf("gatherItem at 0x%X truncated", off)
|
||||
}
|
||||
items = append(items, QuestGatherItemJSON{
|
||||
Rate: u16fn(off),
|
||||
Item: u16fn(off + 2),
|
||||
})
|
||||
off += 4
|
||||
}
|
||||
result[i] = QuestGatheringTableJSON{Items: items}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// objTypeToString maps a uint32 goal type to its JSON string name.
|
||||
// Returns "", false for unknown types.
|
||||
func objTypeToString(t uint32) (string, bool) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user