Skip to content

MonoBehaviour nodes from TypeTreeGenerator misread m_Script and change it on save #388

Open

Description

@nichinichisou0609

When a MonoBehaviour has no typetree and env.typetree_generator is set, ObjectReader._get_typetree_node reads it with the node from generator.get_nodes_up. The fields up to m_Name of that node come from a fixed header in TypeTreeGeneratorAPI, which puts the align flag on m_GameObject and m_Script. Unity aligns after m_Enabled, as the MonoBehaviour node of UnityPy's class database has it:

class database: m_GameObject, m_Enabled (align), m_Script, m_Name
generated:      m_GameObject (align), m_Enabled, m_Script (align), m_Name (align)

With the generated header, m_Script is read 3 bytes too early and its m_PathID comes back wrong. The align after m_Script lands on the right offset again, so m_Name and the script's own fields read correctly and the error is easy to miss. Saving the object writes m_Script at the wrong offset, so the saved MonoBehaviour points at a different MonoScript. The AssetsTools, AssetStudio and AssetRipper backends of TypeTreeGeneratorAPI 0.0.10 all produce this header.

Reproduction

Game.dll and UnityEngine.dll are built for netstandard2.0 from the two files below and put into one folder together with netstandard.dll from the .NET SDK (sdk/<version>/ref/netstandard.dll). The script takes that folder as its argument.

// UnityEngine.dll
namespace UnityEngine
{
    public class Object { }
    public class Component : Object { }
    public class Behaviour : Component { }
    public class MonoBehaviour : Behaviour { }
}
// Game.dll, references UnityEngine.dll
using UnityEngine;
public class Sample : MonoBehaviour
{
    public int count;
    public bool flag;
    public int after;
}
import importlib.metadata
import struct
import sys

import UnityPy
from UnityPy.enums import ClassIDType
from UnityPy.helpers.Tpk import get_typetree_node
from UnityPy.helpers.TypeTreeGenerator import TypeTreeGenerator
from UnityPy.helpers.TypeTreeHelper import read_typetree, write_typetree
from UnityPy.helpers.TypeTreeNode import TypeTreeNode
from UnityPy.helpers.UnityVersion import UnityVersion
from UnityPy.streams import EndianBinaryReader, EndianBinaryWriter

# Game.dll holds:  public class Sample : MonoBehaviour { public int count; public bool flag; public int after; }
VERSION = "2022.3.0f1"
print("UnityPy", UnityPy.__version__, "TypeTreeGeneratorAPI", importlib.metadata.version("TypeTreeGeneratorAPI"))


def header(node):
    flags = []
    for child in node.m_Children:
        flags.append(f"{child.m_Name}{' (align)' if child.m_MetaFlag & 0x4000 else ''}")
        if child.m_Name == "m_Name":
            return ", ".join(flags)


def aligned_string(value):
    data = value.encode()
    return struct.pack("<i", len(data)) + data + bytes(-len(data) % 4)


# a Sample MonoBehaviour as Unity serializes it: m_Enabled is followed by 3 bytes of padding
data = (
    struct.pack("<iq", 0, 1)  # m_GameObject
    + struct.pack("<B3x", 1)  # m_Enabled
    + struct.pack("<iq", 0, 0x1122334455667788)  # m_Script
    + aligned_string("sample")  # m_Name
    + struct.pack("<i", 5)  # count
    + struct.pack("<B3x", 1)  # flag
    + struct.pack("<i", 9)  # after
)


def check(label, node):
    value = read_typetree(node, EndianBinaryReader(data, "<"), as_dict=True, byte_size=len(data))
    writer = EndianBinaryWriter(b"", "<")
    write_typetree(value, node, writer)
    saved = read_typetree(node, EndianBinaryReader(writer.bytes, "<"), as_dict=True, byte_size=len(writer.bytes))
    native = read_typetree(class_db, EndianBinaryReader(writer.bytes, "<"), as_dict=True, check_read=False)
    print(f"  {label}: {header(node)}")
    print(f"    read m_Script.m_PathID {value['m_Script']['m_PathID']:#x}, count/flag/after {value['count']}/{value['flag']}/{value['after']}")
    print(f"    saved bytes equal to the input: {writer.bytes == data}, m_Script.m_PathID in the saved bytes {native['m_Script']['m_PathID']:#x}")


class_db = get_typetree_node(ClassIDType.MonoBehaviour, UnityVersion.from_str(VERSION))
print("class database:", header(class_db))
for backend in ("AssetsTools", "AssetStudio", "AssetRipper"):
    generator = TypeTreeGenerator(VERSION, backend)
    generator.load_local_dll_folder(sys.argv[1])
    node = generator.get_nodes_up("Game", "Sample")
    print(f"{backend}:")
    check("generated", node)
    # the same node with the fields up to m_Name taken from the class database
    k = next(i for i, child in enumerate(node.m_Children) if child.m_Name == "m_Name") + 1
    swapped = TypeTreeNode(
        node.m_Level, node.m_Type, node.m_Name, node.m_ByteSize, node.m_Version,
        list(class_db.m_Children) + list(node.m_Children[k:]), m_MetaFlag=node.m_MetaFlag,
    )
    check("class database header", swapped)

Output on 417d595 (1.25.4):

UnityPy 1.25.4 TypeTreeGeneratorAPI 0.0.10
class database: m_GameObject, m_Enabled (align), m_Script, m_Name
AssetsTools:
  generated: m_GameObject (align), m_Enabled, m_Script (align), m_Name (align)
    read m_Script.m_PathID 0x4455667788000000, count/flag/after 5/1/9
    saved bytes equal to the input: False, m_Script.m_PathID in the saved bytes 0x4455667788
  class database header: m_GameObject, m_Enabled (align), m_Script, m_Name
    read m_Script.m_PathID 0x1122334455667788, count/flag/after 5/1/9
    saved bytes equal to the input: True, m_Script.m_PathID in the saved bytes 0x1122334455667788
AssetStudio:
  generated: m_GameObject (align), m_Enabled, m_Script (align), m_Name (align)
    read m_Script.m_PathID 0x4455667788000000, count/flag/after 5/True/9
    saved bytes equal to the input: False, m_Script.m_PathID in the saved bytes 0x4455667788
  class database header: m_GameObject, m_Enabled (align), m_Script, m_Name
    read m_Script.m_PathID 0x1122334455667788, count/flag/after 5/True/9
    saved bytes equal to the input: True, m_Script.m_PathID in the saved bytes 0x1122334455667788
AssetRipper:
  generated: m_GameObject (align), m_Enabled, m_Script (align), m_Name (align)
    read m_Script.m_PathID 0x4455667788000000, count/flag/after 5/True/9
    saved bytes equal to the input: False, m_Script.m_PathID in the saved bytes 0x4455667788
  class database header: m_GameObject, m_Enabled (align), m_Script, m_Name
    read m_Script.m_PathID 0x1122334455667788, count/flag/after 5/True/9
    saved bytes equal to the input: True, m_Script.m_PathID in the saved bytes 0x1122334455667788

A possible fix on the UnityPy side: generate_monobehaviour_node already reads the head of the object with the class database node in parse_monobehaviour_head. It could replace the generated fields up to and including m_Name with the children of that node, which does not depend on a new release of TypeTreeGeneratorAPI; the "class database header" lines above are the result. I can send a PR for that, or the header could be fixed in TypeTreeGeneratorAPI instead, whichever you prefer.

#181 describes the same symptom (MonoBehaviours read correctly but change after saving) with generated typetrees from the earlier TypeTreeGenerator; I cannot tell whether it had the same cause.

Tested with UnityPy 417d595 (1.25.4) and CPython 3.11.13 on Windows x86_64.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions