From a9f46ae4f30c21ba94fe74a75af71cd3a3a50c11 Mon Sep 17 00:00:00 2001 From: Daan Vanden Bosch Date: Mon, 22 Jul 2019 22:51:44 +0200 Subject: [PATCH] Added complete assembler error checking to the editor. Improved editor autocompletion. Script asm modifications are now persisted when saving. --- src/data_formats/parsing/quest/opcodes.ts | 2 - src/domain/index.ts | 4 +- src/scripting/assembly.ts | 319 ++++++++++++++---- src/ui/quest_editor/ScriptEditorComponent.tsx | 93 +++-- 4 files changed, 320 insertions(+), 98 deletions(-) diff --git a/src/data_formats/parsing/quest/opcodes.ts b/src/data_formats/parsing/quest/opcodes.ts index 1c3db220..df925ee5 100644 --- a/src/data_formats/parsing/quest/opcodes.ts +++ b/src/data_formats/parsing/quest/opcodes.ts @@ -1,5 +1,3 @@ -import { string } from "prop-types"; - /** * Instruction parameter types. */ diff --git a/src/domain/index.ts b/src/domain/index.ts index 0d1731b5..17ebe3d3 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -149,7 +149,7 @@ export interface EntityType { /** * Abstract class from which QuestNpc and QuestObject derive. */ -export class QuestEntity { +export abstract class QuestEntity { readonly type: Type; @observable area_id: number; @@ -216,8 +216,6 @@ export class QuestEntity { rotation: Vec3, scale: Vec3 ) { - if (Object.getPrototypeOf(this) === Object.getPrototypeOf(QuestEntity)) - throw new Error("Abstract class should not be instantiated directly."); if (!type) throw new Error("type is required."); if (!Number.isInteger(area_id) || area_id < 0) throw new Error(`Expected area_id to be a non-negative integer, got ${area_id}.`); diff --git a/src/scripting/assembly.ts b/src/scripting/assembly.ts index 68216cbe..8e2e82fe 100644 --- a/src/scripting/assembly.ts +++ b/src/scripting/assembly.ts @@ -7,11 +7,11 @@ import { Param, } from "../data_formats/parsing/quest/bin"; -type DisassemblyError = { +type AssemblyError = { line: number; col: number; length: number; - description: string; + message: string; }; export function assemble( @@ -20,9 +20,9 @@ export function assemble( ): { instructions: Instruction[]; labels: Map; - errors: DisassemblyError[]; + errors: AssemblyError[]; } { - const errors: DisassemblyError[] = []; + const errors: AssemblyError[] = []; const instructions: Instruction[] = []; const labels = new Map(); @@ -30,7 +30,7 @@ export function assemble( for (const line_text of assembly.split("\n")) { const match = line_text.match( - /^(?\s*)(?[^\s]+?:)?(?\s*)(?[a-z][a-z_=<>!]*)?(?.*)$/ + /^(?\s*)(?[^\s]+?:)?(?\s*)(?[a-z][a-z0-9_=<>!]*)?(?.*)$/ ); if (!match || !match.groups || (match.groups.lbl == null && match.groups.op == null)) { @@ -40,9 +40,9 @@ export function assemble( if (trimmed.length) { errors.push({ line, - col: line_text.length - left_trimmed.length, + col: 1 + line_text.length - left_trimmed.length, length: trimmed.length, - description: "Expected label or instruction.", + message: "Expected label or instruction.", }); } } else { @@ -51,19 +51,19 @@ export function assemble( if (lbl != null) { const label = parseInt(lbl.slice(0, -1), 10); - if (!isFinite(label)) { + if (!isFinite(label) || !/^\d+:$/.test(lbl)) { errors.push({ line, - col: lbl_ws.length, + col: 1 + lbl_ws.length, length: lbl.length, - description: "Invalid label name.", + message: "Invalid label name.", }); } else if (labels.has(label)) { errors.push({ line, - col: lbl_ws.length, + col: 1 + lbl_ws.length, length: lbl.length - 1, - description: "Duplicate label.", + message: "Duplicate label.", }); } else { labels.set(label, instructions.length); @@ -76,12 +76,13 @@ export function assemble( if (!opcode) { errors.push({ line, - col: lbl_ws.length + (lbl ? lbl.length : 0) + op_ws.length, + col: 1 + lbl_ws.length + (lbl ? lbl.length : 0) + op_ws.length, length: op.length, - description: "Unknown instruction.", + message: "Unknown instruction.", }); } else { const args_col = + 1 + lbl_ws.length + (lbl ? lbl.length : 0) + op_ws.length + @@ -96,38 +97,52 @@ export function assemble( const left_trimmed = args.trimLeft(); const trimmed = args.trimRight(); - if (trimmed.trim().length) { - errors.push({ - line, - col: args_col + args.length - left_trimmed.length, - length: trimmed.length, - description: "Instruction arguments expected.", - }); - } + errors.push({ + line, + col: args_col + args.length - left_trimmed.length, + length: trimmed.length, + message: "Instruction arguments expected.", + }); } else { + const varargs = + opcode.params.findIndex( + p => p.type === Type.U8Var || p.type === Type.U16Var + ) !== -1; + const param_count = opcode.params.length + (manual_stack ? 0 : opcode.stack_params.length); - if (arg_tokens.length !== param_count) { + if ( + varargs + ? arg_tokens.length < param_count + : arg_tokens.length !== param_count + ) { const left_trimmed = line_text.trimLeft(); - const trimmed = left_trimmed.trimRight(); errors.push({ line, - col: line_text.length - left_trimmed.length, - length: trimmed.length, - description: `Expected ${param_count} arguments, got ${arg_tokens.length}.`, + col: 1 + line_text.length - left_trimmed.length, + length: left_trimmed.length, + message: `Expected${ + varargs ? " at least" : "" + } ${param_count} argument${param_count === 1 ? "" : "s"}, got ${ + arg_tokens.length + }.`, }); - } else if (arg_tokens.length === opcode.params.length) { - parse_args(opcode.params, arg_tokens, ins_args); + } else if (varargs || arg_tokens.length === opcode.params.length) { + parse_args(opcode.params, arg_tokens, ins_args, line, errors); } else { const stack_args: Arg[] = []; - parse_args(opcode.stack_params, arg_tokens, stack_args); + parse_args(opcode.stack_params, arg_tokens, stack_args, line, errors); - // TODO: proper error checking. - // TODO: UVars. for (let i = 0; i < opcode.stack_params.length; i++) { const param = opcode.stack_params[i]; const arg = stack_args[i]; + const col = arg_tokens[i].col; + const length = arg_tokens[i].arg.length; + + if (arg == null) { + continue; + } switch (param.type) { case Type.U8: @@ -146,9 +161,12 @@ export function assemble( instructions.push(new Instruction(Opcode.arg_pushs, [arg])); break; default: - throw new Error( - `Type ${Type[param.type]} not implemented yet.` - ); + errors.push({ + line, + col, + length, + message: `Type ${Type[param.type]} not implemented.`, + }); } } } @@ -175,7 +193,7 @@ type ArgToken = { }; function tokenize_args(arg_str: string, col: number, args: ArgToken[]): boolean { - if (arg_str.length === 0) { + if (arg_str.trim().length === 0) { return true; } @@ -200,51 +218,224 @@ function tokenize_args(arg_str: string, col: number, args: ArgToken[]): boolean } } -// TODO: proper error checking. -// TODO: UVars. -function parse_args(params: Param[], arg_tokens: ArgToken[], args: Arg[]): void { +function parse_args( + params: Param[], + arg_tokens: ArgToken[], + args: Arg[], + line: number, + errors: AssemblyError[] +): void { for (let i = 0; i < params.length; i++) { const param = params[i]; - const arg_str = arg_tokens[i].arg; + const arg_token = arg_tokens[i]; + const arg_str = arg_token.arg; + const col = arg_token.col; + const length = arg_str.length; switch (param.type) { case Type.U8: - args.push({ - value: parseInt(arg_str, 10), - size: 1, - }); + parse_uint(arg_str, 1, args, line, col, errors); break; case Type.U16: - args.push({ - value: parseInt(arg_str, 10), - size: 2, - }); + parse_uint(arg_str, 2, args, line, col, errors); break; case Type.U32: + parse_uint(arg_str, 4, args, line, col, errors); + break; case Type.I32: + parse_sint(arg_str, 4, args, line, col, errors); + break; case Type.F32: - args.push({ - value: parseInt(arg_str, 10), - size: 4, - }); + parse_float(arg_str, args, line, col, errors); break; case Type.Register: - args.push({ - value: parseInt(arg_str.slice(1), 10), - size: 1, - }); + parse_register(arg_str, args, line, col, errors); break; case Type.String: - { - const value: string = JSON.parse(arg_str); - args.push({ - value, - size: 2 + 2 * value.length, - }); - } + parse_string(arg_str, args, line, col, errors); break; + case Type.U8Var: + parse_uint_varargs(arg_tokens, i, 1, args, line, errors); + return; + case Type.U16Var: + parse_uint_varargs(arg_tokens, i, 2, args, line, errors); + return; default: - throw new Error(`Type ${Type[param.type]} not implemented yet.`); + errors.push({ + line, + col, + length, + message: `Type ${Type[param.type]} not implemented.`, + }); } } } + +function parse_uint( + arg_str: string, + size: number, + args: Arg[], + line: number, + col: number, + errors: AssemblyError[] +): void { + const bit_size = 8 * size; + const value = parseInt(arg_str, 10); + const max_value = Math.pow(2, bit_size) - 1; + + if (!/^\d+$/.test(arg_str)) { + errors.push({ + line, + col, + length: arg_str.length, + message: `Expected unsigned integer.`, + }); + } else if (value > max_value) { + errors.push({ + line, + col, + length: arg_str.length, + message: `${bit_size}-Bit unsigned integer can't be greater than ${max_value}.`, + }); + } else { + args.push({ + value, + size, + }); + } +} + +function parse_sint( + arg_str: string, + size: number, + args: Arg[], + line: number, + col: number, + errors: AssemblyError[] +): void { + const bit_size = 8 * size; + const value = parseInt(arg_str, 10); + const min_value = -Math.pow(2, bit_size - 1); + const max_value = Math.pow(2, bit_size - 1) - 1; + + if (!/^-?\d+$/.test(arg_str)) { + errors.push({ + line, + col, + length: arg_str.length, + message: `Expected signed integer.`, + }); + } else if (value < min_value) { + errors.push({ + line, + col, + length: arg_str.length, + message: `${bit_size}-Bit signed integer can't be less than ${min_value}.`, + }); + } else if (value > max_value) { + errors.push({ + line, + col, + length: arg_str.length, + message: `${bit_size}-Bit signed integer can't be greater than ${max_value}.`, + }); + } else { + args.push({ + value, + size, + }); + } +} + +function parse_float( + arg_str: string, + args: Arg[], + line: number, + col: number, + errors: AssemblyError[] +): void { + const value = parseFloat(arg_str); + + if (!Number.isFinite(value)) { + errors.push({ + line, + col, + length: arg_str.length, + message: `Expected floating point number.`, + }); + } else { + args.push({ + value, + size: 4, + }); + } +} + +function parse_register( + arg_str: string, + args: Arg[], + line: number, + col: number, + errors: AssemblyError[] +): void { + const value = parseInt(arg_str.slice(1), 10); + + if (!/^r\d+$/.test(arg_str)) { + errors.push({ + line, + col, + length: arg_str.length, + message: `Expected register reference.`, + }); + } else if (value > 255) { + errors.push({ + line, + col, + length: arg_str.length, + message: `Invalid register reference, expected r0-r255.`, + }); + } else { + args.push({ + value, + size: 1, + }); + } +} + +function parse_string( + arg_str: string, + args: Arg[], + line: number, + col: number, + errors: AssemblyError[] +): void { + if (!/^"([^"\\]|\\.)*"$/.test(arg_str)) { + errors.push({ + line, + col, + length: arg_str.length, + message: `Expected string.`, + }); + } else { + const value = JSON.parse(arg_str); + args.push({ + value, + size: 2 + 2 * value.length, + }); + } +} + +function parse_uint_varargs( + arg_tokens: ArgToken[], + index: number, + size: number, + args: Arg[], + line: number, + errors: AssemblyError[] +): void { + for (; index < arg_tokens.length; index++) { + const arg_token = arg_tokens[index]; + const col = arg_token.col; + parse_uint(arg_token.arg, size, args, line, col, errors); + } +} diff --git a/src/ui/quest_editor/ScriptEditorComponent.tsx b/src/ui/quest_editor/ScriptEditorComponent.tsx index 4f284339..1e314722 100644 --- a/src/ui/quest_editor/ScriptEditorComponent.tsx +++ b/src/ui/quest_editor/ScriptEditorComponent.tsx @@ -1,11 +1,12 @@ -import { editor, languages } from "monaco-editor"; +import { autorun } from "mobx"; +import { editor, languages, MarkerSeverity } from "monaco-editor"; import React, { Component, createRef, ReactNode } from "react"; import { AutoSizer } from "react-virtualized"; import { OPCODES } from "../../data_formats/parsing/quest/bin"; +import { assemble } from "../../scripting/assembly"; +import { disassemble } from "../../scripting/disassembly"; import { quest_editor_store } from "../../stores/QuestEditorStore"; import "./ScriptEditorComponent.less"; -import { disassemble } from "../../scripting/disassembly"; -import { IReactionDisposer, autorun } from "mobx"; const ASM_SYNTAX: languages.IMonarchLanguage = { defaultToken: "invalid", @@ -73,18 +74,20 @@ languages.registerCompletionItemProvider("psoasm", { startColumn: 1, endColumn: position.column + 1, }); - const suggest = /^\s*([a-z][\w=<>!]*)?$/.test(value); + const suggestions = /^\s*([a-z][a-z0-9_=<>!]*)?$/.test(value) + ? INSTRUCTION_SUGGESTIONS + : []; return { - suggestions: suggest ? INSTRUCTION_SUGGESTIONS : [], + suggestions, incomplete: false, }; }, }); languages.setLanguageConfiguration("psoasm", { indentationRules: { - increaseIndentPattern: /\d+:/, - decreaseIndentPattern: /\d+/, + increaseIndentPattern: /^\s*\d+:/, + decreaseIndentPattern: /^\s*\d+/, }, autoClosingPairs: [{ open: '"', close: '"' }], surroundingPairs: [{ open: '"', close: '"' }], @@ -130,7 +133,7 @@ type MonacoProps = { class MonacoComponent extends Component { private div_ref = createRef(); private editor?: editor.IStandaloneCodeEditor; - private disposer?: IReactionDisposer; + private disposers: (() => void)[] = []; render(): ReactNode { return
; @@ -142,36 +145,41 @@ class MonacoComponent extends Component { theme: "phantasmal-world", scrollBeyondLastLine: false, autoIndent: true, + fontSize: 14, + wordBasedSuggestions: false, }); - this.disposer = autorun(() => { - const quest = quest_editor_store.current_quest; - const model = - quest && - editor.createModel( - disassemble(quest.instructions, quest.labels, true), - "psoasm" - ); + this.disposers.push( + () => { + if (this.editor) { + this.editor.dispose(); + const model = this.editor.getModel(); + if (model) model.dispose(); + this.editor = undefined; + } + }, + autorun(() => { + const quest = quest_editor_store.current_quest; + const model = + quest && + editor.createModel(disassemble(quest.instructions, quest.labels), "psoasm"); - if (model && this.editor) { - // model.onDidChangeContent(e => { - // }); + if (model && this.editor) { + const disposable = model.onDidChangeContent(this.validate); + this.disposers.push(() => disposable.dispose()); - this.editor.setModel(model); - } - }); + this.editor.setModel(model); + this.validate(); + } + }) + ); } } componentWillUnmount(): void { - if (this.editor) { - const model = this.editor.getModel(); - if (model) model.dispose(); - - this.editor.dispose(); + for (const disposer of this.disposers.splice(0, this.disposers.length)) { + disposer(); } - - if (this.disposer) this.disposer(); } shouldComponentUpdate(): boolean { @@ -186,4 +194,31 @@ class MonacoComponent extends Component { this.editor.layout(props); } } + + private validate = () => { + if (!this.editor) return; + + const model = this.editor.getModel(); + if (!model) return; + + const { instructions, labels, errors } = assemble(model.getValue()); + + if (quest_editor_store.current_quest) { + quest_editor_store.current_quest.instructions = instructions; + quest_editor_store.current_quest.labels = labels; + } + + editor.setModelMarkers( + model, + "psoasm", + errors.map(error => ({ + severity: MarkerSeverity.Error, + message: error.message, + startLineNumber: error.line, + endLineNumber: error.line, + startColumn: error.col, + endColumn: error.col + error.length, + })) + ); + }; }