Add xscript-cli tool and GML disassembler
- Add xscript-cli: Command-line validator for XScript definition files - Syntax checking and reference validation - Directory batch processing - Type listing and info commands - Add gml_disasm.py: Python disassembler for Avatar GML bytecode - Update tools.pro for new subprojects Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
19459d7aaf
commit
8277be6518
68
tools/gml_disasm.py
Normal file
68
tools/gml_disasm.py
Normal file
@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""GML Bytecode Disassembler for Avatar: The Last Airbender"""
|
||||
import struct, sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
OPCODES = {
|
||||
0x00: ("PUSH_0", False), 0x01: ("PUSH_1", False), 0x02: ("PUSH_2", False),
|
||||
0x03: ("PUSH_3", False), 0x04: ("PUSH_4", False), 0x05: ("PUSH_5", False),
|
||||
0x0D: ("RETURN", False), 0x13: ("END_BLOCK", False), 0x14: ("NOT", False),
|
||||
0x17: ("NEG", False), 0x19: ("DUP", False), 0x1F: ("POP", False),
|
||||
0x23: ("ADD", False), 0x24: ("SUB", False), 0x25: ("MUL", False),
|
||||
0x26: ("DIV", False), 0x2C: ("EQ", False), 0x2D: ("NE", False),
|
||||
0x2E: ("LT", False), 0x2F: ("GT", False), 0x34: ("AND", False),
|
||||
0x35: ("OR", False), 0x28: ("GET_VAR", True), 0x33: ("SET_VAR", True),
|
||||
0x1A: ("PUSH_STR", True), 0x29: ("GET_ARG", True), 0x2A: ("GET_LOCAL", True),
|
||||
0x2B: ("SET_LOCAL", True), 0x32: ("INIT_VAR", True), 0x1B: ("JUMP", True),
|
||||
0x20: ("JMP_FALSE", True), 0x21: ("JMP_TRUE", True), 0x31: ("CALL", True),
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class Function:
|
||||
index: int; name: str; params: int; locals: int; bytecode: bytes
|
||||
|
||||
class GMLDisassembler:
|
||||
def __init__(self, data):
|
||||
self.data, self.strings, self.functions = data, [], []
|
||||
self._parse()
|
||||
def _parse(self):
|
||||
self.string_table_size = struct.unpack(">I", self.data[20:24])[0]
|
||||
st_end = 24 + self.string_table_size
|
||||
cur = b""
|
||||
for b in self.data[24:st_end]:
|
||||
if b == 0:
|
||||
if cur: self.strings.append(cur.decode("ascii", errors="replace"))
|
||||
cur = b""
|
||||
else: cur += bytes([b])
|
||||
pos_list = [i for i in range(st_end, len(self.data)-4) if self.data[i:i+4]==b"cnuf"]
|
||||
for i, pos in enumerate(pos_list):
|
||||
nxt = pos_list[i+1] if i+1<len(pos_list) else len(self.data)
|
||||
idx = struct.unpack(">I", self.data[pos+4:pos+8])[0]
|
||||
p = struct.unpack(">I", self.data[pos+12:pos+16])[0]
|
||||
l = struct.unpack(">I", self.data[pos+16:pos+20])[0]
|
||||
nm = self.strings[idx] if idx<len(self.strings) else f"func_{idx}"
|
||||
self.functions.append(Function(idx, nm, p, l, self.data[pos+24:nxt]))
|
||||
def get_str(self, i): return self.strings[i] if i<len(self.strings) else f"str_{i}"
|
||||
def disasm(self, f):
|
||||
out = [f"// {f.name} (params={f.params}, locals={f.locals})", f"function {f.name}() {{"]
|
||||
bc, p = f.bytecode, (4 if len(f.bytecode)>=4 and struct.unpack(">I",f.bytecode[0:4])[0]>=0x100 else 0)
|
||||
while p < len(bc)-3:
|
||||
op = struct.unpack(">I", bc[p:p+4])[0]
|
||||
if op in OPCODES:
|
||||
nm, has = OPCODES[op]
|
||||
if has and p+7<len(bc):
|
||||
arg = struct.unpack(">I", bc[p+4:p+8])[0]; p += 8
|
||||
if nm in ("GET_VAR","SET_VAR","PUSH_STR","CALL"): out.append(f" {nm} {self.get_str(arg)}")
|
||||
elif "JUMP" in nm or "JMP" in nm: out.append(f" {nm} @{arg}")
|
||||
else: out.append(f" {nm} {arg}")
|
||||
else: p += 4; out.append(f" {nm}")
|
||||
elif op < 0x100: out.append(f" OP_{op:02X}"); p += 4
|
||||
else: p += 4
|
||||
out.append("}"); return "\n".join(out)
|
||||
def disasm_all(self):
|
||||
return f"// GML: {len(self.functions)} funcs, {len(self.strings)} strings\n\n" + "\n\n".join(self.disasm(f) for f in self.functions)
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2: print("Usage: gml_disasm.py <file.gml>"); sys.exit(1)
|
||||
with open(sys.argv[1], "rb") as f: d = f.read()
|
||||
print(GMLDisassembler(d).disasm_all())
|
||||
@ -1,6 +1,7 @@
|
||||
TEMPLATE = subdirs
|
||||
|
||||
SUBDIRS += #hexes \
|
||||
SUBDIRS += xscript-cli
|
||||
#hexes \
|
||||
#zentry \
|
||||
#asset_assess \
|
||||
#compro
|
||||
|
||||
360
tools/xscript-cli/main.cpp
Normal file
360
tools/xscript-cli/main.cpp
Normal file
@ -0,0 +1,360 @@
|
||||
// xscript-cli - XScript definition file validator and parser
|
||||
//
|
||||
// Usage:
|
||||
// xscript-cli check <file.xscript> - Check syntax and validate references
|
||||
// xscript-cli check-dir <directory> - Check all .xscript files in directory
|
||||
// xscript-cli list-types <file.xscript> - List all types defined in file
|
||||
// xscript-cli info <file.xscript> <type> - Show info about a specific type
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QCommandLineParser>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
#include <QTextStream>
|
||||
|
||||
#include "lexer.h"
|
||||
#include "parser.h"
|
||||
#include "typeregistry.h"
|
||||
|
||||
static QTextStream out(stdout);
|
||||
static QTextStream err(stderr);
|
||||
|
||||
// Color codes for terminal output
|
||||
namespace Color {
|
||||
const char* Reset = "\033[0m";
|
||||
const char* Red = "\033[31m";
|
||||
const char* Green = "\033[32m";
|
||||
const char* Yellow = "\033[33m";
|
||||
const char* Blue = "\033[34m";
|
||||
const char* Cyan = "\033[36m";
|
||||
const char* Bold = "\033[1m";
|
||||
}
|
||||
|
||||
struct CheckResult {
|
||||
QString filePath;
|
||||
QString fileName;
|
||||
bool success = false;
|
||||
QString errorMessage;
|
||||
QStringList typeNames;
|
||||
int warningCount = 0;
|
||||
QStringList warnings;
|
||||
};
|
||||
|
||||
CheckResult checkFile(const QString& filePath) {
|
||||
CheckResult result;
|
||||
result.filePath = filePath;
|
||||
result.fileName = QFileInfo(filePath).fileName();
|
||||
|
||||
QFile file(filePath);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
result.errorMessage = "Failed to open file";
|
||||
return result;
|
||||
}
|
||||
|
||||
QString content = QString::fromUtf8(file.readAll());
|
||||
file.close();
|
||||
|
||||
try {
|
||||
Lexer lexer(content);
|
||||
Parser parser(std::move(lexer));
|
||||
Module module = parser.parseModule();
|
||||
|
||||
result.success = true;
|
||||
|
||||
// Collect type names
|
||||
for (auto it = module.types.begin(); it != module.types.end(); ++it) {
|
||||
result.typeNames.append(it.key());
|
||||
}
|
||||
|
||||
// Check for potential issues (warnings)
|
||||
for (auto it = module.types.begin(); it != module.types.end(); ++it) {
|
||||
const TypeDef& td = it.value();
|
||||
|
||||
// Warn if root type has no criteria
|
||||
if (td.isRoot && td.criteria.isEmpty()) {
|
||||
result.warnings.append(QString("Type '%1' is root but has no criteria block").arg(td.name));
|
||||
result.warningCount++;
|
||||
}
|
||||
|
||||
// Warn if type has no body
|
||||
if (td.body.isEmpty()) {
|
||||
result.warnings.append(QString("Type '%1' has an empty body").arg(td.name));
|
||||
result.warningCount++;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
result.errorMessage = QString::fromStdString(e.what());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
int cmdCheck(const QString& filePath) {
|
||||
CheckResult result = checkFile(filePath);
|
||||
|
||||
if (result.success) {
|
||||
out << Color::Green << "OK" << Color::Reset << " " << result.fileName;
|
||||
if (!result.typeNames.isEmpty()) {
|
||||
out << " (" << result.typeNames.size() << " type(s))";
|
||||
}
|
||||
out << "\n";
|
||||
|
||||
for (const QString& warning : result.warnings) {
|
||||
out << Color::Yellow << " WARNING: " << Color::Reset << warning << "\n";
|
||||
}
|
||||
|
||||
return 0;
|
||||
} else {
|
||||
err << Color::Red << "ERROR" << Color::Reset << " " << result.fileName << "\n";
|
||||
err << " " << result.errorMessage << "\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
int cmdCheckDir(const QString& dirPath) {
|
||||
QDir dir(dirPath);
|
||||
if (!dir.exists()) {
|
||||
err << Color::Red << "Error: Directory does not exist: " << dirPath << Color::Reset << "\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
QDirIterator it(dirPath, {"*.xscript"}, QDir::Files, QDirIterator::Subdirectories);
|
||||
|
||||
int totalFiles = 0;
|
||||
int successCount = 0;
|
||||
int errorCount = 0;
|
||||
int warningCount = 0;
|
||||
QStringList allTypes;
|
||||
QStringList failedFiles;
|
||||
|
||||
while (it.hasNext()) {
|
||||
QString filePath = it.next();
|
||||
totalFiles++;
|
||||
|
||||
CheckResult result = checkFile(filePath);
|
||||
|
||||
// Show relative path from the directory
|
||||
QString relativePath = dir.relativeFilePath(filePath);
|
||||
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
out << Color::Green << "OK" << Color::Reset << " " << relativePath;
|
||||
if (!result.typeNames.isEmpty()) {
|
||||
out << " (" << result.typeNames.size() << " type(s))";
|
||||
allTypes.append(result.typeNames);
|
||||
}
|
||||
out << "\n";
|
||||
|
||||
warningCount += result.warningCount;
|
||||
for (const QString& warning : result.warnings) {
|
||||
out << Color::Yellow << " WARNING: " << Color::Reset << warning << "\n";
|
||||
}
|
||||
} else {
|
||||
errorCount++;
|
||||
failedFiles.append(relativePath);
|
||||
err << Color::Red << "ERROR" << Color::Reset << " " << relativePath << "\n";
|
||||
err << " " << result.errorMessage << "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
out << "\n" << Color::Bold << "Summary:" << Color::Reset << "\n";
|
||||
out << " Files checked: " << totalFiles << "\n";
|
||||
out << " " << Color::Green << "Passed: " << successCount << Color::Reset << "\n";
|
||||
if (errorCount > 0) {
|
||||
out << " " << Color::Red << "Failed: " << errorCount << Color::Reset << "\n";
|
||||
}
|
||||
if (warningCount > 0) {
|
||||
out << " " << Color::Yellow << "Warnings: " << warningCount << Color::Reset << "\n";
|
||||
}
|
||||
out << " Total types: " << allTypes.size() << "\n";
|
||||
|
||||
// Validate cross-file references
|
||||
if (!allTypes.isEmpty()) {
|
||||
TypeRegistry registry;
|
||||
int refErrors = 0;
|
||||
|
||||
// Re-load all files into a single registry
|
||||
QDirIterator it2(dirPath, {"*.xscript"}, QDir::Files, QDirIterator::Subdirectories);
|
||||
while (it2.hasNext()) {
|
||||
QString filePath = it2.next();
|
||||
QFile file(filePath);
|
||||
if (file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
try {
|
||||
registry.ingestScript(QString::fromUtf8(file.readAll()), filePath);
|
||||
} catch (...) {
|
||||
// Already reported
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QStringList refErrorList = registry.validateTypeReferences();
|
||||
if (!refErrorList.isEmpty()) {
|
||||
out << "\n" << Color::Red << "Reference errors:" << Color::Reset << "\n";
|
||||
for (const QString& refErr : refErrorList) {
|
||||
out << " " << refErr << "\n";
|
||||
refErrors++;
|
||||
}
|
||||
}
|
||||
|
||||
if (refErrors > 0) {
|
||||
errorCount += refErrors;
|
||||
}
|
||||
}
|
||||
|
||||
return (errorCount > 0) ? 1 : 0;
|
||||
}
|
||||
|
||||
int cmdListTypes(const QString& filePath) {
|
||||
CheckResult result = checkFile(filePath);
|
||||
|
||||
if (!result.success) {
|
||||
err << Color::Red << "Error parsing file:" << Color::Reset << "\n";
|
||||
err << " " << result.errorMessage << "\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
out << Color::Bold << "Types in " << result.fileName << ":" << Color::Reset << "\n";
|
||||
|
||||
// Re-parse to get more details
|
||||
QFile file(filePath);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
QString content = QString::fromUtf8(file.readAll());
|
||||
Lexer lexer(content);
|
||||
Parser parser(std::move(lexer));
|
||||
Module module = parser.parseModule();
|
||||
|
||||
for (auto it = module.types.begin(); it != module.types.end(); ++it) {
|
||||
const TypeDef& td = it.value();
|
||||
|
||||
out << " " << Color::Cyan << td.name << Color::Reset;
|
||||
|
||||
QStringList flags;
|
||||
if (td.isRoot) flags.append("root");
|
||||
if (!td.display.isEmpty()) flags.append(QString("display=\"%1\"").arg(td.display));
|
||||
if (td.order == ByteOrder::BE) flags.append("BE");
|
||||
else flags.append("LE");
|
||||
|
||||
if (!flags.isEmpty()) {
|
||||
out << " [" << flags.join(", ") << "]";
|
||||
}
|
||||
out << "\n";
|
||||
|
||||
if (!td.criteria.isEmpty()) {
|
||||
out << " criteria: " << td.criteria.size() << " statement(s)\n";
|
||||
}
|
||||
out << " body: " << td.body.size() << " statement(s)\n";
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cmdInfo(const QString& filePath, const QString& typeName) {
|
||||
QFile file(filePath);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
err << Color::Red << "Error: Failed to open file" << Color::Reset << "\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
QString content = QString::fromUtf8(file.readAll());
|
||||
|
||||
try {
|
||||
Lexer lexer(content);
|
||||
Parser parser(std::move(lexer));
|
||||
Module module = parser.parseModule();
|
||||
|
||||
if (!module.types.contains(typeName)) {
|
||||
err << Color::Red << "Error: Type '" << typeName << "' not found" << Color::Reset << "\n";
|
||||
err << "Available types:\n";
|
||||
for (const QString& name : module.types.keys()) {
|
||||
err << " " << name << "\n";
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
const TypeDef& td = module.types[typeName];
|
||||
|
||||
out << Color::Bold << "Type: " << td.name << Color::Reset << "\n";
|
||||
out << " Root: " << (td.isRoot ? "yes" : "no") << "\n";
|
||||
out << " Display: " << (td.display.isEmpty() ? "(none)" : td.display) << "\n";
|
||||
out << " Byte order: " << (td.order == ByteOrder::BE ? "big-endian" : "little-endian") << "\n";
|
||||
out << " Criteria statements: " << td.criteria.size() << "\n";
|
||||
out << " Body statements: " << td.body.size() << "\n";
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
err << Color::Red << "Error parsing file: " << e.what() << Color::Reset << "\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
QCoreApplication app(argc, argv);
|
||||
QCoreApplication::setApplicationName("xscript-cli");
|
||||
QCoreApplication::setApplicationVersion("1.0.0");
|
||||
|
||||
QCommandLineParser parser;
|
||||
parser.setApplicationDescription("XScript definition file validator and parser");
|
||||
parser.addHelpOption();
|
||||
parser.addVersionOption();
|
||||
|
||||
parser.addPositionalArgument("command", "Command to run: check, check-dir, list-types, info");
|
||||
parser.addPositionalArgument("args", "Command arguments", "[args...]");
|
||||
|
||||
parser.process(app);
|
||||
|
||||
const QStringList args = parser.positionalArguments();
|
||||
|
||||
if (args.isEmpty()) {
|
||||
err << "Error: No command specified\n\n";
|
||||
err << "Usage:\n";
|
||||
err << " xscript-cli check <file.xscript> - Check syntax and validate\n";
|
||||
err << " xscript-cli check-dir <directory> - Check all .xscript files\n";
|
||||
err << " xscript-cli list-types <file.xscript> - List all types in file\n";
|
||||
err << " xscript-cli info <file.xscript> <type> - Show type info\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
const QString command = args[0];
|
||||
|
||||
if (command == "check") {
|
||||
if (args.size() < 2) {
|
||||
err << "Error: check requires a file path\n";
|
||||
return 1;
|
||||
}
|
||||
return cmdCheck(args[1]);
|
||||
}
|
||||
else if (command == "check-dir") {
|
||||
if (args.size() < 2) {
|
||||
err << "Error: check-dir requires a directory path\n";
|
||||
return 1;
|
||||
}
|
||||
return cmdCheckDir(args[1]);
|
||||
}
|
||||
else if (command == "list-types") {
|
||||
if (args.size() < 2) {
|
||||
err << "Error: list-types requires a file path\n";
|
||||
return 1;
|
||||
}
|
||||
return cmdListTypes(args[1]);
|
||||
}
|
||||
else if (command == "info") {
|
||||
if (args.size() < 3) {
|
||||
err << "Error: info requires a file path and type name\n";
|
||||
return 1;
|
||||
}
|
||||
return cmdInfo(args[1], args[2]);
|
||||
}
|
||||
else {
|
||||
err << "Error: Unknown command '" << command << "'\n";
|
||||
err << "Valid commands: check, check-dir, list-types, info\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
27
tools/xscript-cli/xscript-cli.pro
Normal file
27
tools/xscript-cli/xscript-cli.pro
Normal file
@ -0,0 +1,27 @@
|
||||
QT += core
|
||||
QT -= gui
|
||||
|
||||
TEMPLATE = app
|
||||
CONFIG += console c++17
|
||||
CONFIG -= app_bundle
|
||||
|
||||
TARGET = xscript-cli
|
||||
|
||||
SOURCES += main.cpp
|
||||
|
||||
# Link against the DSL library
|
||||
LIBS += -L$$OUT_PWD/../../libs -ldsl -lcompression -lcore
|
||||
|
||||
# Include paths
|
||||
INCLUDEPATH += \
|
||||
$$PWD/../../libs/dsl \
|
||||
$$PWD/../../libs/compression \
|
||||
$$PWD/../../libs/core
|
||||
|
||||
DEPENDPATH += \
|
||||
$$PWD/../../libs/dsl \
|
||||
$$PWD/../../libs/compression \
|
||||
$$PWD/../../libs/core
|
||||
|
||||
# Output directory
|
||||
DESTDIR = $$OUT_PWD/../../
|
||||
Loading…
x
Reference in New Issue
Block a user