Skip to content

Decompile scripts when hooking a game that has compressed scripts #586

Description

@NanobotZ

Currently the hook preprocessor simply tries loading a script provided by the path, but that approach doesn't work if the game was exported with compressed scripts (.gdc).

I did some tests and it is possible to decompile the compressed .gdc scripts and patch them into the .pck - adding them as .gd files (not replacing the .gdc).
Doing that makes the mod loader properly generate hooks when launching the game.
It is important to also patch the *.gd.remap files then doing that, otherwise the game will still load the compiled .gdc scripts.

My proof of concept:

from pathlib import Path
import os
import shutil
import subprocess
import sys

pck_path = 'Game.pck'
temp_folder = 'temp'
tool_path = 'gdre_tools.exe'


temp_path = Path(temp_folder)

def recover_scripts_and_remaps():
    # recover/decompile all scripts, we do this first because it cleans the temp folder
    subprocess.call([tool_path, '--headless', f'--recover={pck_path}', '--include=res://**/*.gdc', f'--output={temp_folder}'])
    # extract all the .gd.remap files
    subprocess.call([tool_path, '--headless', f'--extract={pck_path}', '--include=res://**/*.gd.remap', f'--output={temp_folder}'])

def fix_remaps():
    # go through all .gd.remap files and make them point back to the .gd file
    for remap_file_path in temp_path.rglob('*.gd.remap'):
        content = ''
        with open(remap_file_path, 'rt') as remap_file:
            content = remap_file.read()
            content = content.replace('.gdc"', '.gd"')
        with open(remap_file_path, 'wt') as remap_file:
            remap_file.write(content)

def get_max_cmd_length():
    if os.name == 'nt':
        return 30000
    return 100000

MAX_CMD_LENGTH = get_max_cmd_length()

def collect_patch_args(patterns):
    patch_args = []
    seen_files = set()

    for pattern in patterns:
        for file_path in temp_path.rglob(pattern):
            if file_path in seen_files:
                continue
            seen_files.add(file_path)

            source_rel = file_path.relative_to(temp_path)
            source_path = str(temp_path / source_rel)
            dest_path = 'res://' + source_rel.as_posix()

            patch_args.append(f'--patch-file={source_path}={dest_path}')

    return patch_args


def chunk_args(base_args, patch_args):
    chunks = []
    current = []

    base_len = sum(len(a) + 1 for a in base_args)
    current_len = base_len

    for arg in patch_args:
        arg_len = len(arg) + 1

        if current_len + arg_len > MAX_CMD_LENGTH:
            chunks.append(current)
            current = [arg]
            current_len = base_len + arg_len
        else:
            current.append(arg)
            current_len += arg_len

    if current:
        chunks.append(current)

    return chunks


def patch_many(patterns):
    patch_args = collect_patch_args(patterns)

    if not patch_args:
        return

    base_args = [
        tool_path,
        '--headless',
        f'--pck-patch={pck_path}',
        f'--output={pck_path}',
    ]
    
    # chunking the --patch-file commands to avoid reloading GDRE each file
    chunks = chunk_args(base_args, patch_args)

    for i, chunk in enumerate(chunks, 1):
        args = base_args + chunk
        print(f'Running patch batch {i}/{len(chunks)} with {len(chunk)} files')
        subprocess.check_call(args)


recover_scripts_and_remaps()
fix_remaps()
patch_many(['*.gd', '*.gd.remap'])

shutil.rmtree(temp_path)

In theory it might be possible to do this without patching the game's PCK, but I haven't tested that:

  • while preparing the hooks, check whether there is a compressed script, if it's not then continue processing the current way,
  • decompile with GDRE to .gd file, apply patches onto that, put it into the zip,
  • also extract .gd.remap file and fix the path inside from .gdc to .gd (or generate a new .gd.remap file), also put into the zip.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status
    Backlog

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions