450 lines
16 KiB
Python
450 lines
16 KiB
Python
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
from dataclasses import asdict, dataclass, field
|
||
from itertools import count
|
||
from typing import Optional
|
||
|
||
|
||
_TEMP_COUNTER = count()
|
||
_MAX_DEPTH = 10
|
||
|
||
|
||
def ghidra_slice(text: str, start: int, end: int) -> str:
|
||
start = max(start, 0)
|
||
end = max(end, 0)
|
||
if end < start:
|
||
return ""
|
||
return text[start:end]
|
||
|
||
|
||
def split_args_like_ctor(args_text: str) -> list[str]:
|
||
parts: list[str] = []
|
||
start = 0
|
||
depth = 0
|
||
for index, char in enumerate(args_text):
|
||
if char == "(":
|
||
depth += 1
|
||
elif char == ")":
|
||
depth -= 1
|
||
elif char == "," and depth == 0:
|
||
parts.append(args_text[start:index])
|
||
start = index + 1
|
||
if index == len(args_text) - 1:
|
||
parts.append(args_text[start:index + 1])
|
||
return [part for part in parts if part]
|
||
|
||
|
||
@dataclass
|
||
class ExpressionAst:
|
||
node_type: str
|
||
raw: str
|
||
inner: str
|
||
source_branch: str
|
||
|
||
|
||
@dataclass
|
||
class BehaviourBlockAst:
|
||
node_type: str
|
||
raw: str
|
||
body: str
|
||
temp_name: str
|
||
|
||
|
||
@dataclass
|
||
class TargetAst:
|
||
kind: str
|
||
raw: str
|
||
expression: Optional[ExpressionAst] = None
|
||
note: Optional[str] = None
|
||
|
||
|
||
@dataclass
|
||
class ValueAst:
|
||
kind: str
|
||
raw: str
|
||
text: Optional[str] = None
|
||
expression: Optional[ExpressionAst] = None
|
||
note: Optional[str] = None
|
||
|
||
|
||
@dataclass
|
||
class ArgumentAst:
|
||
raw: str
|
||
branch: str
|
||
storage: str
|
||
simple_argument: bool
|
||
expression: Optional[ExpressionAst] = None
|
||
nested_entry: Optional["EntryAst"] = None
|
||
variable_name: Optional[str] = None
|
||
behaviour_block: Optional[BehaviourBlockAst] = None
|
||
trace: list[str] = field(default_factory=list)
|
||
|
||
|
||
@dataclass
|
||
class EntryAst:
|
||
node_type: str
|
||
raw: str
|
||
normalized: str
|
||
first_char: str
|
||
runner_name: Optional[str] = None
|
||
target: Optional[TargetAst] = None
|
||
value: Optional[ValueAst] = None
|
||
args_text: str = ""
|
||
direct_args: list[ArgumentAst] = field(default_factory=list)
|
||
entry_args: list[ArgumentAst] = field(default_factory=list)
|
||
flags: dict[str, bool] = field(default_factory=dict)
|
||
trace: list[str] = field(default_factory=list)
|
||
|
||
|
||
def make_expression(raw: str, source_branch: str) -> ExpressionAst:
|
||
inner = raw[1:-1] if raw.startswith("[") and raw.endswith("]") else raw
|
||
return ExpressionAst(
|
||
node_type="expression",
|
||
raw=raw,
|
||
inner=inner,
|
||
source_branch=source_branch,
|
||
)
|
||
|
||
|
||
def extract_call_parts(text: str) -> tuple[int, str]:
|
||
open_paren = text.find("(")
|
||
if open_paren < 0:
|
||
return -1, ""
|
||
close_paren = text.rfind(")")
|
||
if close_paren < 0 or close_paren < open_paren:
|
||
return open_paren, ""
|
||
return open_paren, text[open_paren + 1:close_paren]
|
||
|
||
|
||
def parse_argument(token: str, depth: int) -> ArgumentAst:
|
||
trace: list[str] = []
|
||
has_dollar = "$" in token
|
||
first_char = token[0]
|
||
|
||
if first_char == "[" and not has_dollar:
|
||
trace.append("Argument starts with '[' and has no '$': construct CMC_Expression and store it in +0x98.")
|
||
return ArgumentAst(
|
||
raw=token,
|
||
branch="bracket_expression",
|
||
storage="direct_args(+0x98)",
|
||
simple_argument=True,
|
||
expression=make_expression(token, "arg[current_char == '[']"),
|
||
trace=trace,
|
||
)
|
||
|
||
if first_char != "@" and not has_dollar and "^" not in token:
|
||
if first_char == "&":
|
||
trace.append("Argument starts with '&' and has no '$'/'^': resolve variable and store it in +0x98.")
|
||
return ArgumentAst(
|
||
raw=token,
|
||
branch="variable_reference",
|
||
storage="direct_args(+0x98)",
|
||
simple_argument=True,
|
||
variable_name=token[1:],
|
||
trace=trace,
|
||
)
|
||
|
||
trace.append("Plain argument token: recurse into nested CMC_Behaviour_Entry and store it in +0x9c.")
|
||
return ArgumentAst(
|
||
raw=token,
|
||
branch="nested_entry",
|
||
storage="entry_args(+0x9c)",
|
||
simple_argument=False,
|
||
nested_entry=parse_line(token, depth + 1),
|
||
trace=trace,
|
||
)
|
||
|
||
quote_offset = 1 if first_char == '"' else 0
|
||
if len(token) > quote_offset and token[quote_offset] == "{":
|
||
body_start = quote_offset + 1
|
||
body_end = len(token) - 1 - quote_offset
|
||
body = ghidra_slice(token, body_start, body_end)
|
||
temp_name = f"temp_{next(_TEMP_COUNTER)}"
|
||
trace.append(
|
||
"Dynamic or special argument opens an inline behaviour block: create temp behaviour and put temp string in +0x98."
|
||
)
|
||
if has_dollar:
|
||
trace.append("This branch clears the simple-arguments flag because '$' appears inside the inline behaviour token.")
|
||
return ArgumentAst(
|
||
raw=token,
|
||
branch="inline_behaviour_block",
|
||
storage="direct_args(+0x98)",
|
||
simple_argument=not has_dollar,
|
||
behaviour_block=BehaviourBlockAst(
|
||
node_type="behaviour_block",
|
||
raw=token,
|
||
body=body,
|
||
temp_name=temp_name,
|
||
),
|
||
trace=trace,
|
||
)
|
||
|
||
trace.append(
|
||
"Argument is '@'-prefixed or contains '$'/'^': build nested CMC_Behaviour_Entry and store it in +0x9c."
|
||
)
|
||
return ArgumentAst(
|
||
raw=token,
|
||
branch="special_nested_entry",
|
||
storage="entry_args(+0x9c)",
|
||
simple_argument=False,
|
||
nested_entry=parse_line(token, depth + 1),
|
||
trace=trace,
|
||
)
|
||
|
||
|
||
def parse_line(raw_line: str, depth: int = 0) -> EntryAst:
|
||
raw_line = raw_line.rstrip("\r\n")
|
||
normalized = raw_line[:-1] if raw_line.endswith(";") else raw_line
|
||
|
||
if depth > _MAX_DEPTH:
|
||
return EntryAst(
|
||
node_type="behaviour_entry",
|
||
raw=raw_line,
|
||
normalized=normalized,
|
||
first_char=normalized[:1],
|
||
flags={"max_depth_reached": True},
|
||
trace=["Recursion guard fired. Remaining subtree was not expanded."],
|
||
)
|
||
|
||
first_char = normalized[:1]
|
||
entry = EntryAst(
|
||
node_type="behaviour_entry",
|
||
raw=raw_line,
|
||
normalized=normalized,
|
||
first_char=first_char,
|
||
flags={
|
||
"explicit_target": False,
|
||
"this_prefix": False,
|
||
"dynamic_target_before_caret": False,
|
||
"top_level_bracket_runtime_text": False,
|
||
"simple_arguments": True,
|
||
},
|
||
)
|
||
|
||
if raw_line.endswith(";"):
|
||
entry.trace.append("Trailing ';' removed before further parsing.")
|
||
|
||
open_paren, args_text = extract_call_parts(normalized)
|
||
entry.args_text = args_text
|
||
|
||
if not normalized:
|
||
entry.trace.append("Empty input line.")
|
||
return entry
|
||
|
||
if first_char == "@":
|
||
runner_end = open_paren if open_paren >= 0 else len(normalized)
|
||
entry.runner_name = ghidra_slice(normalized, 1, runner_end)
|
||
entry.target = TargetAst(
|
||
kind="behaviour_owner",
|
||
raw="@",
|
||
note="Uses owner behaviour as subject (+0x6c) and runner 0 as fallback.",
|
||
)
|
||
entry.trace.append("current_char == '@': subject becomes owner behaviour, runner name is taken from text after '@'.")
|
||
|
||
elif first_char == "*":
|
||
close_bracket = normalized.find("]")
|
||
caret_index = normalized.find("^", close_bracket + 1 if close_bracket >= 0 else 0)
|
||
runner_open = open_paren
|
||
if close_bracket >= 0 and open_paren >= 0 and close_bracket + 1 < open_paren:
|
||
runner_open = normalized.find("(", caret_index if caret_index >= 0 else 0)
|
||
else:
|
||
caret_index = normalized.find("^")
|
||
|
||
runner_end = runner_open if runner_open >= 0 else len(normalized)
|
||
target_end = caret_index if caret_index >= 0 else runner_end
|
||
target_text = ghidra_slice(normalized, 1, target_end)
|
||
entry.flags["explicit_target"] = True
|
||
|
||
if target_text.startswith("["):
|
||
entry.target = TargetAst(
|
||
kind="expression_target",
|
||
raw=target_text,
|
||
expression=make_expression(target_text, "entry['*' explicit target]"),
|
||
note="Sets +0xbe and stores a compiled CMC_Expression in +0xb8.",
|
||
)
|
||
entry.trace.append("'*' branch with '[' target: compile explicit target expression and keep it in +0xb8.")
|
||
else:
|
||
entry.target = TargetAst(
|
||
kind="object_target",
|
||
raw=target_text,
|
||
note="Lookup object by name and store it in +0xb8.",
|
||
)
|
||
entry.trace.append("'*' branch with plain target: resolve explicit object name into +0xb8.")
|
||
|
||
if caret_index >= 0:
|
||
entry.runner_name = ghidra_slice(normalized, caret_index + 1, runner_end)
|
||
entry.trace.append("Runner name in '*' branch comes from the slice after '^'.")
|
||
|
||
elif normalized.startswith("THIS"):
|
||
runner_end = open_paren if open_paren >= 0 else len(normalized)
|
||
entry.runner_name = ghidra_slice(normalized, 5, runner_end)
|
||
entry.target = TargetAst(
|
||
kind="this_object",
|
||
raw="THIS",
|
||
note="Sets +0xa1 and treats THIS as the callee object.",
|
||
)
|
||
entry.flags["this_prefix"] = True
|
||
entry.trace.append("Line starts with 'THIS': set +0xa1 and take runner text after 'THIS'.")
|
||
|
||
elif first_char == "[":
|
||
bracket_text = normalized
|
||
if "$" in bracket_text:
|
||
entry.value = ValueAst(
|
||
kind="runtime_bracket_text",
|
||
raw=bracket_text,
|
||
text=bracket_text,
|
||
note="This branch does not build CMC_Expression. It copies bracketed text into +0xa8 and returns early.",
|
||
)
|
||
entry.flags["top_level_bracket_runtime_text"] = True
|
||
entry.trace.append(
|
||
"Top-level current_char == '[' with '$': keep bracketed text in +0xa8, no CMC_Expression is allocated."
|
||
)
|
||
else:
|
||
entry.value = ValueAst(
|
||
kind="expression_value",
|
||
raw=bracket_text,
|
||
expression=make_expression(bracket_text, "entry[current_char == '[']"),
|
||
note="This branch builds CMC_Expression and stores it in +0xa4.",
|
||
)
|
||
entry.trace.append(
|
||
"Top-level current_char == '[' without '$': construct CMC_Expression and store it in +0xa4."
|
||
)
|
||
|
||
else:
|
||
caret_index = normalized.find("^")
|
||
if first_char == '"' or caret_index < 0:
|
||
entry.value = ValueAst(
|
||
kind="atom_or_object_lookup",
|
||
raw=normalized,
|
||
text=normalized,
|
||
note="Runtime decides whether this resolves to an existing object (+0xa4) or remains plain text in +0xa8.",
|
||
)
|
||
entry.trace.append("No '^' (or quoted atom): this stays a leaf value/object lookup.")
|
||
else:
|
||
lhs = normalized[:caret_index]
|
||
runner_end = open_paren if open_paren >= 0 else len(normalized)
|
||
entry.runner_name = ghidra_slice(normalized, caret_index + 1, runner_end)
|
||
if "$" in lhs:
|
||
entry.flags["dynamic_target_before_caret"] = True
|
||
entry.target = TargetAst(
|
||
kind="dynamic_target",
|
||
raw=lhs,
|
||
note="Sets +0xbc; target must be resolved later because '$' appears before '^'.",
|
||
)
|
||
entry.trace.append("Target text before '^' contains '$': set +0xbc and defer target resolution.")
|
||
elif "|" in lhs:
|
||
object_name, field_name = lhs.split("|", 1)
|
||
entry.target = TargetAst(
|
||
kind="structure_field",
|
||
raw=lhs,
|
||
note=f"Resolve object '{object_name}', then field '{field_name}' via CMC_Structure::getField.",
|
||
)
|
||
entry.trace.append("Target before '^' contains '|': treat it as object|field.")
|
||
else:
|
||
entry.target = TargetAst(
|
||
kind="object_target",
|
||
raw=lhs,
|
||
note="Resolve callee object name before selecting the runner.",
|
||
)
|
||
entry.trace.append("Target before '^' is a plain object name.")
|
||
|
||
if not entry.args_text:
|
||
return entry
|
||
|
||
entry.trace.append("Split argument string exactly like the ctor: commas only count at parenthesis depth 0.")
|
||
for token in split_args_like_ctor(entry.args_text):
|
||
argument = parse_argument(token, depth)
|
||
if argument.storage.startswith("direct_args"):
|
||
entry.direct_args.append(argument)
|
||
else:
|
||
entry.entry_args.append(argument)
|
||
if not argument.simple_argument:
|
||
entry.flags["simple_arguments"] = False
|
||
|
||
return entry
|
||
|
||
def prepare_input(code: str) -> str:
|
||
# original used right and left instructions for striping code from braces (yeah, they are unnecessary)
|
||
code = code.strip()
|
||
if code[0] == '{':
|
||
code = code[1::]
|
||
if code[-1] == '}':
|
||
code = code[:-1]
|
||
code = code.strip() # yep, another strip to remove spaces after removing braces
|
||
return code
|
||
|
||
def initializeBehaviours(code: str) -> str:
|
||
code_lines = []
|
||
|
||
depth = 0
|
||
tmp_buffer = ""
|
||
for i, c in enumerate(code):
|
||
if c == '(':
|
||
depth += 1
|
||
tmp_buffer += c
|
||
continue
|
||
elif c == ')':
|
||
depth -= 1
|
||
tmp_buffer += c
|
||
continue
|
||
elif c == ';':
|
||
if depth == 0:
|
||
code_lines.append(tmp_buffer)
|
||
tmp_buffer = ""
|
||
continue
|
||
else:
|
||
tmp_buffer += c
|
||
continue
|
||
elif c == ' ':
|
||
continue
|
||
else:
|
||
tmp_buffer += c
|
||
|
||
return code_lines
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(
|
||
description=(
|
||
"Approximate AST reconstruction for BlooMooDLL::CMC_Behaviour_Entry::CMC_Behaviour_Entry "
|
||
"(thunk 0x1000331e -> body 0x10042670)."
|
||
)
|
||
)
|
||
parser.add_argument(
|
||
"--compact",
|
||
action="store_true",
|
||
help="Emit compact JSON instead of pretty-printed JSON.",
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
import sys
|
||
|
||
lines = "{@STRING(\"TEST\", \"\");@IF(\"2+3*4'\"2+3*4\"&&1'1\",\"{@RETURN(\"TRUE\");}\",\"{@RETURN(\"FALSE\");}\");@RETURN(TEST);}"
|
||
|
||
prepared_code = prepare_input(lines)
|
||
|
||
print("Prepared code:")
|
||
print(prepared_code)
|
||
|
||
print("\nCode lines:")
|
||
lines = initializeBehaviours(prepared_code)
|
||
|
||
json_data = []
|
||
|
||
for index, line in enumerate(lines):
|
||
if index and not args.compact:
|
||
print()
|
||
tree = parse_line(line)
|
||
|
||
json_data.append(asdict(tree))
|
||
|
||
if args.compact:
|
||
print(json.dumps(json_data, ensure_ascii=True, separators=(",", ":")))
|
||
else:
|
||
print(json.dumps(json_data, ensure_ascii=True, indent=2))
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main()) |