Files
BlooMooExperiments/original_code_parsing_reconstr.py
2026-03-06 23:43:12 +00:00

450 lines
16 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())