From 9803bfe12592dcba0ffa200e628e45e3ad05aeac Mon Sep 17 00:00:00 2001 From: Daan Vanden Bosch Date: Thu, 10 Oct 2019 13:47:43 +0200 Subject: [PATCH] Wave data is now parsed/written and converted to models. --- .../cursor/AbstractWritableCursor.ts | 6 +- .../cursor/ResizableBufferCursor.ts | 2 +- src/core/data_formats/parsing/quest/bin.ts | 28 +- .../data_formats/parsing/quest/dat.test.ts | 24 +- src/core/data_formats/parsing/quest/dat.ts | 521 +++++++++++++----- .../data_formats/parsing/quest/entities.ts | 9 +- src/core/data_formats/parsing/quest/index.ts | 51 +- src/quest_editor/model/AreaVariantModel.ts | 5 + src/quest_editor/model/QuestModel.ts | 18 +- src/quest_editor/model/QuestNpcModel.ts | 4 +- src/quest_editor/model/QuestObjectModel.ts | 4 +- .../model/QuestWaveActionModel.ts | 43 ++ src/quest_editor/model/QuestWaveModel.ts | 37 ++ .../scripting/disassembly.test.ts | 4 +- src/quest_editor/scripting/disassembly.ts | 2 +- src/quest_editor/scripting/instructions.ts | 2 +- src/quest_editor/stores/QuestEditorStore.ts | 80 ++- src/quest_editor/stores/quest_creation.ts | 6 + 18 files changed, 648 insertions(+), 198 deletions(-) create mode 100644 src/quest_editor/model/QuestWaveActionModel.ts create mode 100644 src/quest_editor/model/QuestWaveModel.ts diff --git a/src/core/data_formats/cursor/AbstractWritableCursor.ts b/src/core/data_formats/cursor/AbstractWritableCursor.ts index 645be1e3..34238861 100644 --- a/src/core/data_formats/cursor/AbstractWritableCursor.ts +++ b/src/core/data_formats/cursor/AbstractWritableCursor.ts @@ -54,14 +54,14 @@ export abstract class AbstractWritableCursor extends AbstractCursor implements W return this; } - write_u8_array(array: number[]): this { + write_u8_array(array: readonly number[]): this { this.ensure_size(array.length); new Uint8Array(this.backing_buffer, this.offset + this.position).set(new Uint8Array(array)); this._position += array.length; return this; } - write_u16_array(array: number[]): this { + write_u16_array(array: readonly number[]): this { this.ensure_size(2 * array.length); const len = array.length; @@ -72,7 +72,7 @@ export abstract class AbstractWritableCursor extends AbstractCursor implements W return this; } - write_u32_array(array: number[]): this { + write_u32_array(array: readonly number[]): this { this.ensure_size(4 * array.length); const len = array.length; diff --git a/src/core/data_formats/cursor/ResizableBufferCursor.ts b/src/core/data_formats/cursor/ResizableBufferCursor.ts index 7c012105..86dceff3 100644 --- a/src/core/data_formats/cursor/ResizableBufferCursor.ts +++ b/src/core/data_formats/cursor/ResizableBufferCursor.ts @@ -12,7 +12,7 @@ export class ResizableBufferCursor extends AbstractWritableCursor implements Wri set size(size: number) { if (size > this._size) { - this.ensure_size(size - this._size); + this.ensure_size(size - this.position); } else { this._size = size; } diff --git a/src/core/data_formats/parsing/quest/bin.ts b/src/core/data_formats/parsing/quest/bin.ts index 25d4eca4..22c3868a 100644 --- a/src/core/data_formats/parsing/quest/bin.ts +++ b/src/core/data_formats/parsing/quest/bin.ts @@ -30,17 +30,15 @@ import { ResizableBuffer } from "../../ResizableBuffer"; const logger = Logger.get("core/data_formats/parsing/quest/bin"); -export class BinFile { - constructor( - readonly quest_id: number, - readonly language: number, - readonly quest_name: string, - readonly short_description: string, - readonly long_description: string, - readonly object_code: Segment[], - readonly shop_items: number[], - ) {} -} +export type BinFile = { + readonly quest_id: number; + readonly language: number; + readonly quest_name: string; + readonly short_description: string; + readonly long_description: string; + readonly object_code: readonly Segment[]; + readonly shop_items: readonly number[]; +}; const SEGMENT_PRIORITY: number[] = []; SEGMENT_PRIORITY[SegmentType.Instructions] = 2; @@ -82,15 +80,15 @@ export function parse_bin( const segments = parse_object_code(object_code, label_holder, entry_labels, lenient); - return new BinFile( + return { quest_id, language, quest_name, short_description, long_description, - segments, + object_code: segments, shop_items, - ); + }; } export function write_bin(bin: BinFile): ArrayBuffer { @@ -697,7 +695,7 @@ function parse_instruction_arguments(cursor: Cursor, opcode: Opcode): Arg[] { function write_object_code( cursor: WritableCursor, - segments: Segment[], + segments: readonly Segment[], ): { size: number; label_offsets: number[] } { const start_pos = cursor.position; // Keep track of label offsets. diff --git a/src/core/data_formats/parsing/quest/dat.test.ts b/src/core/data_formats/parsing/quest/dat.test.ts index ad383e5b..e8e2c3e8 100644 --- a/src/core/data_formats/parsing/quest/dat.test.ts +++ b/src/core/data_formats/parsing/quest/dat.test.ts @@ -2,7 +2,7 @@ import { Endianness } from "../../Endianness"; import { prs_decompress } from "../../compression/prs/decompress"; import { BufferCursor } from "../../cursor/BufferCursor"; import { ResizableBufferCursor } from "../../cursor/ResizableBufferCursor"; -import { parse_dat, write_dat } from "./dat"; +import { DatFile, parse_dat, write_dat } from "./dat"; import { readFileSync } from "fs"; /** @@ -37,13 +37,25 @@ test("parse, modify and write DAT", () => { const test_parsed = parse_dat(orig_dat); orig_dat.seek_start(0); - test_parsed.objs[9].position = { - x: 13, - y: 17, - z: 19, + const test_updated: DatFile = { + ...test_parsed, + objs: test_parsed.objs.map((obj, i) => { + if (i === 9) { + return { + ...obj, + position: { + x: 13, + y: 17, + z: 19, + }, + }; + } else { + return obj; + } + }), }; - const test_dat = new ResizableBufferCursor(write_dat(test_parsed), Endianness.Little); + const test_dat = new ResizableBufferCursor(write_dat(test_updated), Endianness.Little); expect(test_dat.size).toBe(orig_dat.size); diff --git a/src/core/data_formats/parsing/quest/dat.ts b/src/core/data_formats/parsing/quest/dat.ts index 9d71d897..7ec77260 100644 --- a/src/core/data_formats/parsing/quest/dat.ts +++ b/src/core/data_formats/parsing/quest/dat.ts @@ -5,51 +5,98 @@ import { Cursor } from "../../cursor/Cursor"; import { ResizableBufferCursor } from "../../cursor/ResizableBufferCursor"; import { ResizableBuffer } from "../../ResizableBuffer"; import { Vec3 } from "../../vector"; +import { WritableCursor } from "../../cursor/WritableCursor"; -const logger = Logger.get("data_formats/parsing/quest/dat"); +const logger = Logger.get("core/data_formats/parsing/quest/dat"); const OBJECT_SIZE = 68; const NPC_SIZE = 72; export type DatFile = { - objs: DatObject[]; - npcs: DatNpc[]; - unknowns: DatUnknown[]; + readonly objs: readonly DatObject[]; + readonly npcs: readonly DatNpc[]; + readonly waves: readonly DatWave[]; + readonly unknowns: readonly DatUnknown[]; }; export type DatEntity = { - type_id: number; - section_id: number; - position: Vec3; - rotation: Vec3; - area_id: number; - unknown: number[][]; + readonly type_id: number; + readonly section_id: number; + readonly position: Vec3; + readonly rotation: Vec3; + readonly area_id: number; + readonly unknown: readonly number[][]; }; export type DatObject = DatEntity & { - id: number; - group_id: number; - properties: number[]; + readonly id: number; + readonly group_id: number; + readonly properties: readonly number[]; }; export type DatNpc = DatEntity & { - scale: Vec3; - npc_id: number; - script_label: number; - roaming: number; + readonly scale: Vec3; + readonly npc_id: number; + readonly script_label: number; + readonly roaming: number; +}; + +export type DatWave = { + readonly id: number; + readonly section_id: number; + readonly wave: number; + readonly delay: number; + readonly actions: readonly DatWaveAction[]; + readonly area_id: number; + readonly unknown: number; +}; + +export enum DatWaveActionType { + SpawnNpcs = 0x8, + Unlock = 0xa, + Lock = 0xb, + SpawnWave = 0xc, +} + +export type DatWaveAction = + | DatWaveActionSpawnNpcs + | DatWaveActionUnlock + | DatWaveActionLock + | DatWaveActionSpawnWave; + +export type DatWaveActionSpawnNpcs = { + readonly type: DatWaveActionType.SpawnNpcs; + readonly section_id: number; + readonly appear_flag: number; +}; + +export type DatWaveActionUnlock = { + readonly type: DatWaveActionType.Unlock; + readonly door_id: number; +}; + +export type DatWaveActionLock = { + readonly type: DatWaveActionType.Lock; + readonly door_id: number; +}; + +export type DatWaveActionSpawnWave = { + readonly type: DatWaveActionType.SpawnWave; + readonly wave_id: number; }; export type DatUnknown = { - entity_type: number; - total_size: number; - area_id: number; - entities_size: number; - data: number[]; + readonly entity_type: number; + readonly total_size: number; + readonly area_id: number; + readonly entities_size: number; + readonly data: number[]; }; export function parse_dat(cursor: Cursor): DatFile { const objs: DatObject[] = []; const npcs: DatNpc[] = []; + const waves: DatWave[] = []; const unknowns: DatUnknown[] = []; while (cursor.bytes_left) { @@ -68,99 +115,16 @@ export function parse_dat(cursor: Cursor): DatFile { ); } + const entities_cursor = cursor.take(entities_size); + if (entity_type === 1) { - // Objects - const object_count = Math.floor(entities_size / OBJECT_SIZE); - const start_position = cursor.position; - - for (let i = 0; i < object_count; ++i) { - const type_id = cursor.u16(); - const unknown1 = cursor.u8_array(6); - const id = cursor.u16(); - const group_id = cursor.u16(); - const section_id = cursor.u16(); - const unknown2 = cursor.u8_array(2); - const position = cursor.vec3_f32(); - const rotation = { - x: (cursor.i32() / 0xffff) * 2 * Math.PI, - y: (cursor.i32() / 0xffff) * 2 * Math.PI, - z: (cursor.i32() / 0xffff) * 2 * Math.PI, - }; - const properties = [ - cursor.f32(), - cursor.f32(), - cursor.f32(), - cursor.u32(), - cursor.u32(), - cursor.u32(), - cursor.u32(), - ]; - - objs.push({ - type_id, - id, - group_id, - section_id, - position, - rotation, - properties, - area_id, - unknown: [unknown1, unknown2], - }); - } - - const bytes_read = cursor.position - start_position; - - if (bytes_read !== entities_size) { - logger.warn( - `Read ${bytes_read} bytes instead of expected ${entities_size} for entity type ${entity_type} (Object).`, - ); - cursor.seek(entities_size - bytes_read); - } + parse_objects(entities_cursor, area_id, objs); } else if (entity_type === 2) { - // NPCs - const npc_count = Math.floor(entities_size / NPC_SIZE); - const start_position = cursor.position; - - for (let i = 0; i < npc_count; ++i) { - const type_id = cursor.u16(); - const unknown1 = cursor.u8_array(10); - const section_id = cursor.u16(); - const unknown2 = cursor.u8_array(6); - const position = cursor.vec3_f32(); - const rotation_x = (cursor.i32() / 0xffff) * 2 * Math.PI; - const rotation_y = (cursor.i32() / 0xffff) * 2 * Math.PI; - const rotation_z = (cursor.i32() / 0xffff) * 2 * Math.PI; - const scale = cursor.vec3_f32(); - const npc_id = cursor.f32(); - const script_label = cursor.f32(); - const roaming = cursor.u32(); - const unknown3 = cursor.u8_array(4); - - npcs.push({ - type_id, - section_id, - position, - rotation: { x: rotation_x, y: rotation_y, z: rotation_z }, - scale, - npc_id, - script_label, - roaming, - area_id, - unknown: [unknown1, unknown2, unknown3], - }); - } - - const bytes_read = cursor.position - start_position; - - if (bytes_read !== entities_size) { - logger.warn( - `Read ${bytes_read} bytes instead of expected ${entities_size} for entity type ${entity_type} (NPC).`, - ); - cursor.seek(entities_size - bytes_read); - } + parse_npcs(entities_cursor, area_id, npcs); + } else if (entity_type === 3) { + parse_waves(entities_cursor, area_id, waves); } else { - // There are also waves (type 3) and unknown entity types 4 and 5. + // Unknown entity types 4 and 5. unknowns.push({ entity_type, total_size, @@ -169,13 +133,19 @@ export function parse_dat(cursor: Cursor): DatFile { data: cursor.u8_array(entities_size), }); } + + if (entities_cursor.bytes_left) { + logger.warn( + `Read ${entities_cursor.position} bytes instead of expected ${entities_cursor.size} for entity type ${entity_type}.`, + ); + } } } - return { objs, npcs, unknowns }; + return { objs, npcs, waves, unknowns }; } -export function write_dat({ objs, npcs, unknowns }: DatFile): ResizableBuffer { +export function write_dat({ objs, npcs, waves, unknowns }: DatFile): ResizableBuffer { const buffer = new ResizableBuffer( objs.length * (16 + OBJECT_SIZE) + npcs.length * (16 + NPC_SIZE) + @@ -183,6 +153,214 @@ export function write_dat({ objs, npcs, unknowns }: DatFile): ResizableBuffer { ); const cursor = new ResizableBufferCursor(buffer, Endianness.Little); + write_objects(cursor, objs); + + write_npcs(cursor, npcs); + + write_waves(cursor, waves); + + for (const unknown of unknowns) { + cursor.write_u32(unknown.entity_type); + cursor.write_u32(unknown.total_size); + cursor.write_u32(unknown.area_id); + cursor.write_u32(unknown.entities_size); + cursor.write_u8_array(unknown.data); + } + + // Final header. + cursor.write_u32(0); + cursor.write_u32(0); + cursor.write_u32(0); + cursor.write_u32(0); + + return buffer; +} + +function parse_objects(cursor: Cursor, area_id: number, objs: DatObject[]): void { + const object_count = Math.floor(cursor.size / OBJECT_SIZE); + + for (let i = 0; i < object_count; ++i) { + const type_id = cursor.u16(); + const unknown1 = cursor.u8_array(6); + const id = cursor.u16(); + const group_id = cursor.u16(); + const section_id = cursor.u16(); + const unknown2 = cursor.u8_array(2); + const position = cursor.vec3_f32(); + const rotation = { + x: (cursor.i32() / 0xffff) * 2 * Math.PI, + y: (cursor.i32() / 0xffff) * 2 * Math.PI, + z: (cursor.i32() / 0xffff) * 2 * Math.PI, + }; + const properties = [ + cursor.f32(), + cursor.f32(), + cursor.f32(), + cursor.u32(), + cursor.u32(), + cursor.u32(), + cursor.u32(), + ]; + + objs.push({ + type_id, + id, + group_id, + section_id, + position, + rotation, + properties, + area_id, + unknown: [unknown1, unknown2], + }); + } +} + +function parse_npcs(cursor: Cursor, area_id: number, npcs: DatNpc[]): void { + const npc_count = Math.floor(cursor.size / NPC_SIZE); + + for (let i = 0; i < npc_count; ++i) { + const type_id = cursor.u16(); + const unknown1 = cursor.u8_array(10); + const section_id = cursor.u16(); + const unknown2 = cursor.u8_array(6); + const position = cursor.vec3_f32(); + const rotation_x = (cursor.i32() / 0xffff) * 2 * Math.PI; + const rotation_y = (cursor.i32() / 0xffff) * 2 * Math.PI; + const rotation_z = (cursor.i32() / 0xffff) * 2 * Math.PI; + const scale = cursor.vec3_f32(); + const npc_id = cursor.f32(); + const script_label = cursor.f32(); + const roaming = cursor.u32(); + const unknown3 = cursor.u8_array(4); + + npcs.push({ + type_id, + section_id, + position, + rotation: { x: rotation_x, y: rotation_y, z: rotation_z }, + scale, + npc_id, + script_label, + roaming, + area_id, + unknown: [unknown1, unknown2, unknown3], + }); + } +} + +function parse_waves(cursor: Cursor, area_id: number, waves: DatWave[]): void { + const actions_offset = cursor.u32(); + cursor.seek(4); // Always 0x10 + const wave_count = cursor.u32(); + cursor.seek(3); // Always 0 + const wave_type = cursor.u8(); + + if (wave_type === 0x32) { + throw new Error("Can't parse challenge mode quests yet."); + } + + cursor.seek_start(actions_offset); + const actions_cursor = cursor.take(cursor.bytes_left); + cursor.seek_start(16); + + for (let i = 0; i < wave_count; ++i) { + const id = cursor.u32(); + cursor.seek(4); // Always 0x100 + const section_id = cursor.u16(); + const wave = cursor.u16(); + const delay = cursor.u16(); + const unknown = cursor.u16(); // "wavesetting"? + const wave_actions_offset = cursor.u32(); + + actions_cursor.seek_start(wave_actions_offset); + const actions = parse_wave_actions(actions_cursor); + + waves.push({ + id, + section_id, + wave, + delay, + actions, + area_id, + unknown, + }); + } + + if (cursor.position !== actions_offset) { + logger.warn( + `Read ${cursor.position - 16} bytes of wave data instead of expected ${actions_offset - + 16}.`, + ); + } + + let last_u8 = 0xff; + + while (actions_cursor.bytes_left) { + last_u8 = actions_cursor.u8(); + + if (last_u8 !== 0xff) { + break; + } + } + + if (last_u8 !== 0xff) { + actions_cursor.seek(-1); + } + + // Make sure the cursor position represents the amount of bytes we've consumed. + cursor.seek_start(actions_offset + actions_cursor.position); +} + +function parse_wave_actions(cursor: Cursor): DatWaveAction[] { + const actions: DatWaveAction[] = []; + + outer: while (cursor.bytes_left) { + const type = cursor.u8(); + + switch (type) { + case 1: + break outer; + + case DatWaveActionType.SpawnNpcs: + actions.push({ + type: DatWaveActionType.SpawnNpcs, + section_id: cursor.u16(), + appear_flag: cursor.u16(), + }); + break; + + case DatWaveActionType.Unlock: + actions.push({ + type: DatWaveActionType.Unlock, + door_id: cursor.u16(), + }); + break; + + case DatWaveActionType.Lock: + actions.push({ + type: DatWaveActionType.Lock, + door_id: cursor.u16(), + }); + break; + + case DatWaveActionType.SpawnWave: + actions.push({ + type: DatWaveActionType.SpawnWave, + wave_id: cursor.u32(), + }); + break; + + default: + logger.warn(`Unexpected wave action type ${type}.`); + break outer; + } + } + + return actions; +} + +function write_objects(cursor: WritableCursor, objs: readonly DatObject[]): void { const grouped_objs = groupBy(objs, obj => obj.area_id); const obj_area_ids = Object.keys(grouped_objs) .map(key => parseInt(key, 10)) @@ -231,7 +409,9 @@ export function write_dat({ objs, npcs, unknowns }: DatFile): ResizableBuffer { cursor.write_u32(obj.properties[6]); } } +} +function write_npcs(cursor: WritableCursor, npcs: readonly DatNpc[]): void { const grouped_npcs = groupBy(npcs, npc => npc.area_id); const npc_area_ids = Object.keys(grouped_npcs) .map(key => parseInt(key, 10)) @@ -276,20 +456,101 @@ export function write_dat({ objs, npcs, unknowns }: DatFile): ResizableBuffer { cursor.write_u8_array(npc.unknown[2]); } } - - for (const unknown of unknowns) { - cursor.write_u32(unknown.entity_type); - cursor.write_u32(unknown.total_size); - cursor.write_u32(unknown.area_id); - cursor.write_u32(unknown.entities_size); - cursor.write_u8_array(unknown.data); - } - - // Final header. - cursor.write_u32(0); - cursor.write_u32(0); - cursor.write_u32(0); - cursor.write_u32(0); - - return buffer; +} + +function write_waves(cursor: WritableCursor, waves: readonly DatWave[]): void { + const grouped_waves = groupBy(waves, wave => wave.area_id); + const wave_area_ids = Object.keys(grouped_waves) + .map(key => parseInt(key, 10)) + .sort((a, b) => a - b); + + for (const area_id of wave_area_ids) { + const area_waves = grouped_waves[area_id]; + + // Standard header. + cursor.write_u32(3); // Entity type + const total_size_offset = cursor.position; + cursor.write_u32(0); // Placeholder for the total size. + cursor.write_u32(area_id); + const entities_size_offset = cursor.position; + cursor.write_u32(0); // Placeholder for the entities size. + + // Wave header. + const start_pos = cursor.position; + // TODO: actual wave size is dependent on the wave type (challenge mode). + // Absolute offset. + const actions_offset = start_pos + 16 + 20 * area_waves.length; + cursor.size = Math.max(actions_offset, cursor.size); + + cursor.write_u32(actions_offset - start_pos); + cursor.write_u32(0x10); + cursor.write_u32(area_waves.length); + cursor.write_u32(0); // TODO: write wave type (challenge mode). + + // Relative offset. + let wave_actions_offset = 0; + + for (const wave of area_waves) { + cursor.write_u32(wave.id); + cursor.write_u32(0x10000); + cursor.write_u16(wave.section_id); + cursor.write_u16(wave.wave); + cursor.write_u16(wave.delay); + cursor.write_u16(wave.unknown); + cursor.write_u32(wave_actions_offset); + const next_wave_pos = cursor.position; + + cursor.seek_start(actions_offset + wave_actions_offset); + + for (const action of wave.actions) { + cursor.write_u8(action.type); + + switch (action.type) { + case DatWaveActionType.SpawnNpcs: + cursor.write_u16(action.section_id); + cursor.write_u16(action.appear_flag); + break; + + case DatWaveActionType.Unlock: + cursor.write_u16(action.door_id); + break; + + case DatWaveActionType.Lock: + cursor.write_u16(action.door_id); + break; + + case DatWaveActionType.SpawnWave: + cursor.write_u32(action.wave_id); + break; + + default: + // Need to cast because TypeScript infers action to be `never`. + throw new Error(`Unknown wave action type ${(action as any).type}.`); + } + } + + // End of wave actions. + cursor.write_u8(1); + + wave_actions_offset = cursor.position - actions_offset; + + cursor.seek_start(next_wave_pos); + } + + cursor.seek_start(actions_offset + wave_actions_offset); + + while ((cursor.position - actions_offset) % 4 !== 0) { + cursor.write_u8(0xff); + } + + const end_pos = cursor.position; + + cursor.seek_start(total_size_offset); + cursor.write_u32(end_pos - start_pos + 16); + + cursor.seek_start(entities_size_offset); + cursor.write_u32(end_pos - start_pos); + + cursor.seek_start(end_pos); + } } diff --git a/src/core/data_formats/parsing/quest/entities.ts b/src/core/data_formats/parsing/quest/entities.ts index bc347fa3..ce939466 100644 --- a/src/core/data_formats/parsing/quest/entities.ts +++ b/src/core/data_formats/parsing/quest/entities.ts @@ -1,8 +1,7 @@ import { Vec3 } from "../../vector"; import { npc_data, NpcType, NpcTypeData } from "./npc_types"; import { object_data, ObjectType, ObjectTypeData } from "./object_types"; - -export type QuestEntity = QuestNpc | QuestObject; +import { DatWave } from "./dat"; export type QuestNpc = { readonly type: NpcType; @@ -20,7 +19,7 @@ export type QuestNpc = { /** * Data of which the purpose hasn't been discovered yet. */ - readonly unknown: number[][]; + readonly unknown: readonly number[][]; readonly pso_type_id: number; readonly npc_id: number; readonly script_label: number; @@ -45,9 +44,11 @@ export type QuestObject = { /** * Data of which the purpose hasn't been discovered yet. */ - readonly unknown: number[][]; + readonly unknown: readonly number[][]; }; +export type QuestWave = DatWave; + export type EntityTypeData = NpcTypeData | ObjectTypeData; export type EntityType = NpcType | ObjectType; diff --git a/src/core/data_formats/parsing/quest/index.ts b/src/core/data_formats/parsing/quest/index.ts index 2542bd61..5bc12073 100644 --- a/src/core/data_formats/parsing/quest/index.ts +++ b/src/core/data_formats/parsing/quest/index.ts @@ -12,9 +12,9 @@ import { ArrayBufferCursor } from "../../cursor/ArrayBufferCursor"; import { Cursor } from "../../cursor/Cursor"; import { ResizableBufferCursor } from "../../cursor/ResizableBufferCursor"; import { Endianness } from "../../Endianness"; -import { BinFile, parse_bin, write_bin } from "./bin"; +import { parse_bin, write_bin } from "./bin"; import { DatFile, DatNpc, DatObject, DatUnknown, parse_dat, write_dat } from "./dat"; -import { QuestNpc, QuestObject } from "./entities"; +import { QuestNpc, QuestObject, QuestWave } from "./entities"; import { Episode } from "./Episode"; import { object_data, ObjectType, pso_id_to_object_type } from "./object_types"; import { parse_qst, QstContainedFile, write_qst } from "./qst"; @@ -29,14 +29,15 @@ export type Quest = { readonly short_description: string; readonly long_description: string; readonly episode: Episode; - readonly objects: QuestObject[]; - readonly npcs: QuestNpc[]; + readonly objects: readonly QuestObject[]; + readonly npcs: readonly QuestNpc[]; + readonly waves: readonly QuestWave[]; /** * (Partial) raw DAT data that can't be parsed yet by Phantasmal. */ - readonly dat_unknowns: DatUnknown[]; - readonly object_code: Segment[]; - readonly shop_items: number[]; + readonly dat_unknowns: readonly DatUnknown[]; + readonly object_code: readonly Segment[]; + readonly shop_items: readonly number[]; readonly map_designations: Map; }; @@ -125,6 +126,7 @@ export function parse_quest(cursor: Cursor, lenient: boolean = false): Quest | u episode, objects, npcs: parse_npc_data(episode, dat.npcs), + waves: dat.waves, dat_unknowns: dat.unknowns, object_code: bin.object_code, shop_items: bin.shop_items, @@ -136,19 +138,18 @@ export function write_quest_qst(quest: Quest, file_name: string): ArrayBuffer { const dat = write_dat({ objs: objects_to_dat_data(quest.objects), npcs: npcs_to_dat_data(quest.npcs), + waves: quest.waves, unknowns: quest.dat_unknowns, }); - const bin = write_bin( - new BinFile( - quest.id, - quest.language, - quest.name, - quest.short_description, - quest.long_description, - quest.object_code, - quest.shop_items, - ), - ); + const bin = write_bin({ + quest_id: quest.id, + language: quest.language, + quest_name: quest.name, + short_description: quest.short_description, + long_description: quest.long_description, + object_code: quest.object_code, + shop_items: quest.shop_items, + }); const ext_start = file_name.lastIndexOf("."); const base_file_name = ext_start === -1 ? file_name.slice(0, 11) : file_name.slice(0, Math.min(11, ext_start)); @@ -211,7 +212,10 @@ function extract_map_designations( return map_designations; } -function extract_script_entry_points(objects: QuestObject[], npcs: DatNpc[]): number[] { +function extract_script_entry_points( + objects: readonly QuestObject[], + npcs: readonly DatNpc[], +): number[] { const entry_points = new Set([0]); for (const obj of objects) { @@ -235,7 +239,7 @@ function extract_script_entry_points(objects: QuestObject[], npcs: DatNpc[]): nu return [...entry_points]; } -function parse_obj_data(objs: DatObject[]): QuestObject[] { +function parse_obj_data(objs: readonly DatObject[]): QuestObject[] { return objs.map(obj_data => { const type = pso_id_to_object_type(obj_data.type_id); @@ -270,7 +274,7 @@ function parse_obj_data(objs: DatObject[]): QuestObject[] { }); } -function parse_npc_data(episode: number, npcs: DatNpc[]): QuestNpc[] { +function parse_npc_data(episode: number, npcs: readonly DatNpc[]): QuestNpc[] { return npcs.map(npc_data => { return { type: get_npc_type(episode, npc_data), @@ -564,7 +568,7 @@ function get_npc_type(episode: number, { type_id, scale, roaming, area_id }: Dat return NpcType.Unknown; } -function objects_to_dat_data(objects: QuestObject[]): DatObject[] { +function objects_to_dat_data(objects: readonly QuestObject[]): DatObject[] { return objects.map(object => ({ type_id: object_data(object.type).pso_id!, id: object.id, @@ -578,7 +582,8 @@ function objects_to_dat_data(objects: QuestObject[]): DatObject[] { })); } -function npcs_to_dat_data(npcs: QuestNpc[]): DatNpc[] { +function npcs_to_dat_data(npcs: readonly QuestNpc[]): DatNpc[] { + // TODO: use primitive reinterpretation functions instead of DataView. const dv = new DataView(new ArrayBuffer(4)); return npcs.map(npc => { diff --git a/src/quest_editor/model/AreaVariantModel.ts b/src/quest_editor/model/AreaVariantModel.ts index 36bdc9b0..0b212833 100644 --- a/src/quest_editor/model/AreaVariantModel.ts +++ b/src/quest_editor/model/AreaVariantModel.ts @@ -18,4 +18,9 @@ export class AreaVariantModel { this.id = id; this.area = area; } + + set_sections(sections: readonly SectionModel[]): this { + this._sections.val = sections; + return this; + } } diff --git a/src/quest_editor/model/QuestModel.ts b/src/quest_editor/model/QuestModel.ts index 3743fb43..812c24a1 100644 --- a/src/quest_editor/model/QuestModel.ts +++ b/src/quest_editor/model/QuestModel.ts @@ -13,6 +13,7 @@ import { ListProperty } from "../../core/observable/property/list/ListProperty"; import { WritableListProperty } from "../../core/observable/property/list/WritableListProperty"; import { QuestEntityModel } from "./QuestEntityModel"; import { entity_type_to_string } from "../../core/data_formats/parsing/quest/entities"; +import { QuestWaveModel } from "./QuestWaveModel"; const logger = Logger.get("quest_editor/model/QuestModel"); @@ -26,6 +27,7 @@ export class QuestModel { private readonly _area_variants: WritableListProperty = list_property(); private readonly _objects: WritableListProperty; private readonly _npcs: WritableListProperty; + private readonly _waves: WritableListProperty; readonly id: Property = this._id; @@ -58,6 +60,8 @@ export class QuestModel { readonly npcs: ListProperty; + readonly waves: ListProperty; + /** * (Partial) raw DAT data that can't be parsed yet by Phantasmal. */ @@ -75,16 +79,18 @@ export class QuestModel { long_description: string, episode: Episode, map_designations: Map, - objects: QuestObjectModel[], - npcs: QuestNpcModel[], - dat_unknowns: DatUnknown[], - object_code: Segment[], - shop_items: number[], + objects: readonly QuestObjectModel[], + npcs: readonly QuestNpcModel[], + waves: readonly QuestWaveModel[], + dat_unknowns: readonly DatUnknown[], + object_code: readonly Segment[], + shop_items: readonly number[], ) { check_episode(episode); if (!map_designations) throw new Error("map_designations is required."); if (!Array.isArray(objects)) throw new Error("objs is required."); if (!Array.isArray(npcs)) throw new Error("npcs is required."); + if (!Array.isArray(waves)) throw new Error("waves is required."); if (!Array.isArray(dat_unknowns)) throw new Error("dat_unknowns is required."); if (!Array.isArray(object_code)) throw new Error("object_code is required."); if (!Array.isArray(shop_items)) throw new Error("shop_items is required."); @@ -101,6 +107,8 @@ export class QuestModel { this.objects = this._objects; this._npcs = list_property(undefined, ...npcs); this.npcs = this._npcs; + this._waves = list_property(undefined, ...waves); + this.waves = this._waves; this.dat_unknowns = dat_unknowns; this.object_code = object_code; this.shop_items = shop_items; diff --git a/src/quest_editor/model/QuestNpcModel.ts b/src/quest_editor/model/QuestNpcModel.ts index e781b7c2..7848ecb4 100644 --- a/src/quest_editor/model/QuestNpcModel.ts +++ b/src/quest_editor/model/QuestNpcModel.ts @@ -11,7 +11,7 @@ export class QuestNpcModel extends QuestEntityModel { /** * Data of which the purpose hasn't been discovered yet. */ - readonly unknown: number[][]; + readonly unknown: readonly number[][]; constructor( type: NpcType, @@ -24,7 +24,7 @@ export class QuestNpcModel extends QuestEntityModel { position: Vector3, rotation: Euler, scale: Vector3, - unknown: number[][], + unknown: readonly number[][], ) { if (!Number.isInteger(pso_type_id)) throw new Error("pso_type_id should be an integer."); if (!Number.isFinite(npc_id)) throw new Error("npc_id should be a number."); diff --git a/src/quest_editor/model/QuestObjectModel.ts b/src/quest_editor/model/QuestObjectModel.ts index a7a8130a..a766ccf8 100644 --- a/src/quest_editor/model/QuestObjectModel.ts +++ b/src/quest_editor/model/QuestObjectModel.ts @@ -9,7 +9,7 @@ export class QuestObjectModel extends QuestEntityModel { /** * Data of which the purpose hasn't been discovered yet. */ - readonly unknown: number[][]; + readonly unknown: readonly number[][]; constructor( type: ObjectType, @@ -20,7 +20,7 @@ export class QuestObjectModel extends QuestEntityModel { position: Vector3, rotation: Euler, properties: Map, - unknown: number[][], + unknown: readonly number[][], ) { super(type, area_id, section_id, position, rotation); diff --git a/src/quest_editor/model/QuestWaveActionModel.ts b/src/quest_editor/model/QuestWaveActionModel.ts new file mode 100644 index 00000000..d3128dd0 --- /dev/null +++ b/src/quest_editor/model/QuestWaveActionModel.ts @@ -0,0 +1,43 @@ +export abstract class QuestWaveActionModel {} + +export class QuestWaveActionSpawnNpcsModel extends QuestWaveActionModel { + readonly section_id: number; + readonly appear_flag: number; + + constructor(section_id: number, appear_flag: number) { + super(); + + this.section_id = section_id; + this.appear_flag = appear_flag; + } +} + +export class QuestWaveActionUnlockModel extends QuestWaveActionModel { + readonly door_id: number; + + constructor(door_id: number) { + super(); + + this.door_id = door_id; + } +} + +export class QuestWaveActionLockModel extends QuestWaveActionModel { + readonly door_id: number; + + constructor(door_id: number) { + super(); + + this.door_id = door_id; + } +} + +export class QuestWaveActionSpawnWaveModel extends QuestWaveActionModel { + readonly wave_id: number; + + constructor(wave_id: number) { + super(); + + this.wave_id = wave_id; + } +} diff --git a/src/quest_editor/model/QuestWaveModel.ts b/src/quest_editor/model/QuestWaveModel.ts new file mode 100644 index 00000000..d128c445 --- /dev/null +++ b/src/quest_editor/model/QuestWaveModel.ts @@ -0,0 +1,37 @@ +import { QuestWaveActionModel } from "./QuestWaveActionModel"; + +export class QuestWaveModel { + readonly id: number; + readonly section_id: number; + readonly wave: number; + readonly delay: number; + readonly actions: readonly QuestWaveActionModel[]; + readonly area_id: number; + readonly unknown: number; + + constructor( + id: number, + section_id: number, + wave: number, + delay: number, + actions: readonly QuestWaveActionModel[], + area_id: number, + unknown: number, + ) { + if (!Number.isInteger(id)) throw new Error("id should be an integer."); + if (!Number.isInteger(section_id)) throw new Error("section_id should be an integer."); + if (!Number.isInteger(wave)) throw new Error("wave should be an integer."); + if (!Number.isInteger(delay)) throw new Error("delay should be an integer."); + if (!Array.isArray(actions)) throw new Error("actions should be an array."); + if (!Number.isInteger(area_id)) throw new Error("area_id should be an integer."); + if (!Number.isInteger(unknown)) throw new Error("unknown should be an integer."); + + this.id = id; + this.section_id = section_id; + this.wave = wave; + this.delay = delay; + this.actions = actions; + this.area_id = area_id; + this.unknown = unknown; + } +} diff --git a/src/quest_editor/scripting/disassembly.test.ts b/src/quest_editor/scripting/disassembly.test.ts index d601235e..df477456 100644 --- a/src/quest_editor/scripting/disassembly.test.ts +++ b/src/quest_editor/scripting/disassembly.test.ts @@ -117,9 +117,7 @@ test("assembling disassembled object code with manual stack management should re expect(errors).toEqual([]); expect(warnings).toEqual([]); - bin.object_code.splice(0, bin.object_code.length, ...object_code); - - const test_bytes = new ArrayBufferCursor(write_bin(bin), Endianness.Little); + const test_bytes = new ArrayBufferCursor(write_bin({ ...bin, object_code }), Endianness.Little); orig_bytes.seek_start(0); expect(test_bytes.size).toBe(orig_bytes.size); diff --git a/src/quest_editor/scripting/disassembly.ts b/src/quest_editor/scripting/disassembly.ts index 24d9759a..cbfb8e4e 100644 --- a/src/quest_editor/scripting/disassembly.ts +++ b/src/quest_editor/scripting/disassembly.ts @@ -16,7 +16,7 @@ type ArgWithType = Arg & { * @param object_code - The object code to disassemble. * @param manual_stack - If true, will output stack management instructions (argpush variants). Otherwise the arguments of stack management instructions will be output as arguments to the instruction that pops them from the stack. */ -export function disassemble(object_code: Segment[], manual_stack = false): string[] { +export function disassemble(object_code: readonly Segment[], manual_stack = false): string[] { logger.trace("disassemble start"); const lines: string[] = []; diff --git a/src/quest_editor/scripting/instructions.ts b/src/quest_editor/scripting/instructions.ts index 584b7ddf..2ab40265 100644 --- a/src/quest_editor/scripting/instructions.ts +++ b/src/quest_editor/scripting/instructions.ts @@ -160,6 +160,6 @@ function segments_equal(a: Segment, b: Segment): boolean { } } -export function segment_arrays_equal(a: Segment[], b: Segment[]): boolean { +export function segment_arrays_equal(a: readonly Segment[], b: readonly Segment[]): boolean { return arrays_equal(a, b, segments_equal); } diff --git a/src/quest_editor/stores/QuestEditorStore.ts b/src/quest_editor/stores/QuestEditorStore.ts index 7c566b4c..3a325e1a 100644 --- a/src/quest_editor/stores/QuestEditorStore.ts +++ b/src/quest_editor/stores/QuestEditorStore.ts @@ -28,7 +28,15 @@ import { RemoveEntityAction } from "../actions/RemoveEntityAction"; import { Euler, Vector3 } from "three"; import { vec3_to_threejs } from "../../core/rendering/conversion"; import { RotateEntityAction } from "../actions/RotateEntityAction"; -import { VirtualMachine, ExecutionResult } from "../scripting/vm"; +import { ExecutionResult, VirtualMachine } from "../scripting/vm"; +import { QuestWaveModel } from "../model/QuestWaveModel"; +import { DatWaveActionType } from "../../core/data_formats/parsing/quest/dat"; +import { + QuestWaveActionLockModel, + QuestWaveActionSpawnNpcsModel, + QuestWaveActionSpawnWaveModel, + QuestWaveActionUnlockModel, +} from "../model/QuestWaveActionModel"; import Logger = require("js-logger"); const logger = Logger.get("quest_editor/gui/QuestEditorStore"); @@ -160,6 +168,36 @@ export class QuestEditorStore implements Disposable { npc.unknown, ), ), + quest.waves.map( + wave => + new QuestWaveModel( + wave.id, + wave.section_id, + wave.wave, + wave.delay, + wave.actions.map(action => { + switch (action.type) { + case DatWaveActionType.SpawnNpcs: + return new QuestWaveActionSpawnNpcsModel( + action.section_id, + action.appear_flag, + ); + case DatWaveActionType.Unlock: + return new QuestWaveActionUnlockModel( + action.door_id, + ); + case DatWaveActionType.Lock: + return new QuestWaveActionLockModel(action.door_id); + case DatWaveActionType.SpawnWave: + return new QuestWaveActionSpawnWaveModel( + action.wave_id, + ); + } + }), + wave.area_id, + wave.unknown, + ), + ), quest.dat_unknowns, quest.object_code, quest.shop_items, @@ -217,6 +255,44 @@ export class QuestEditorStore implements Disposable { script_label: npc.script_label, pso_roaming: npc.pso_roaming, })), + waves: quest.waves.val.map(wave => ({ + id: wave.id, + section_id: wave.section_id, + wave: wave.wave, + delay: wave.delay, + actions: wave.actions.map(action => { + if (action instanceof QuestWaveActionSpawnNpcsModel) { + return { + type: DatWaveActionType.SpawnNpcs, + section_id: action.section_id, + appear_flag: action.appear_flag, + }; + } else if (action instanceof QuestWaveActionUnlockModel) { + return { + type: DatWaveActionType.Unlock, + door_id: action.door_id, + }; + } else if (action instanceof QuestWaveActionLockModel) { + return { + type: DatWaveActionType.Lock, + door_id: action.door_id, + }; + } else if (action instanceof QuestWaveActionSpawnWaveModel) { + return { + type: DatWaveActionType.SpawnWave, + wave_id: action.wave_id, + }; + } else { + throw new Error( + `Unknown wave action type ${ + Object.getPrototypeOf(action).constructor + }`, + ); + } + }), + area_id: wave.area_id, + unknown: wave.unknown, + })), dat_unknowns: quest.dat_unknowns, object_code: quest.object_code, shop_items: quest.shop_items, @@ -316,7 +392,7 @@ export class QuestEditorStore implements Disposable { // Load section data. for (const variant of quest.area_variants.val) { const sections = await area_store.get_area_sections(quest.episode, variant); - variant.sections.val.splice(0, Infinity, ...sections); + variant.set_sections(sections); for (const object of quest.objects.val.filter(o => o.area_id === variant.area.id)) { try { diff --git a/src/quest_editor/stores/quest_creation.ts b/src/quest_editor/stores/quest_creation.ts index 9cdc2632..21629fce 100644 --- a/src/quest_editor/stores/quest_creation.ts +++ b/src/quest_editor/stores/quest_creation.ts @@ -17,6 +17,7 @@ import { import { QuestObjectModel } from "../model/QuestObjectModel"; import { QuestNpcModel } from "../model/QuestNpcModel"; import { Euler, Vector3 } from "three"; +import { QuestWaveModel } from "../model/QuestWaveModel"; export function create_new_quest(episode: Episode): QuestModel { if (episode === Episode.II) throw new Error("Episode II not yet supported."); @@ -32,6 +33,7 @@ export function create_new_quest(episode: Episode): QuestModel { new Map().set(0, 0), create_default_objects(), create_default_npcs(), + create_default_waves(), [], [ { @@ -804,3 +806,7 @@ function create_default_npcs(): QuestNpcModel[] { ), ]; } + +function create_default_waves(): QuestWaveModel[] { + return []; +}