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:
njohnson 2026-01-11 12:11:38 -05:00
parent 19459d7aaf
commit 8277be6518
4 changed files with 457 additions and 1 deletions

68
tools/gml_disasm.py Normal file
View 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())

View File

@ -1,6 +1,7 @@
TEMPLATE = subdirs TEMPLATE = subdirs
SUBDIRS += #hexes \ SUBDIRS += xscript-cli
#hexes \
#zentry \ #zentry \
#asset_assess \ #asset_assess \
#compro #compro

360
tools/xscript-cli/main.cpp Normal file
View 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;
}
}

View 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/../../