Unity Modding

Patching Unity games with class-scoped TOML/script patches, IL patching, and raw file patches

Unity Modding

Besides Unreal Engine games, automod patches Unity games using asset4j (the JVM port of AssetsTools.NET). Unity mods are scoped to a game’s data directory (e.g., WarmSnow_Data) configured in .config.json, and the patch files live in patches\<game-id>\<mod-name>\ just like UE mods.

A Unity game is selected with its unity flag in .config.json. automod then:

  1. Decodes the target bundles with asset4j, using the game’s per-version ttmap schema (auto-downloaded from the automod releases during setup when the game’s mapUri is set but the map is not yet present in tools/ttmap).
  2. Patches decoded objects via class-scoped TOML or script patches, raw files (e.g., non-asset files) via whole-file script patches, and .NET assemblies via IL patching using dnlib4j.
  3. Re-encodes the bundles in-process with asset4j.
  4. Packages everything into a <mod-name>.zip/.7z archive embedding the install.py installer.

Enabling a mod is just removing the leading dot from its folder name (.easy-mode -> easy-mode); dot-prefixed folders are skipped by .batch unless selectively specified as a .batch argument.

Class-scoped TOML patches

A patch file named <ClassName>@<bundle>.toml (e.g., MonsterSetting@resources.assets.toml) targets the objects of a script class (MonsterSetting) inside a bundle (resources.assets). Every .@ section is a JSONPath applied to each matching object’s decoded data, and $ refers to the object’s own root:

# Warm Snow "easy mode" — MonsterSetting@resources.assets.toml
['.@: $.list.Array[*]']
MonsterHP = '=> v.orig[Double] * 0.5'
MonsterAtk = '=> v.orig[Double] * 0.5'
MonsterDef = '=> v.orig[Double] * 0.5'
SoulDrop = '=> v.orig[Double] * 10'

Class-scoped script patches

A patch file named <ClassName>@<bundle>.kt (or .js, .ts, .py, .sc, .lua) runs the script body as the transform over v.objects – a list of {id, data} where data is each matching object’s decoded JSON. The script edits the objects in place and returns them; automod re-encodes via the targeted round-trip. v.className exposes the class name.

// MonsterSetting@resources.assets.kt — halve every monster's stats
import com.fasterxml.jackson.databind.node.*
import com.fasterxml.jackson.databind.ObjectMapper

for (obj in v.objects) {
  val data = obj["data"] as ObjectNode
  for (m in data["list"]["Array"]) {
    m as ObjectNode
    m.replace("MonsterHP", DoubleNode.valueOf(m["MonsterHP"].asDouble() * 0.5))
    m.replace("MonsterAtk", DoubleNode.valueOf(m["MonsterAtk"].asDouble() * 0.5))
  }
}
v.objects

Whole-file raw patches

A patch file named after the target file (with $ in place of /) patches a whole file. v.orig is the original bytes, v.current the bytes after any earlier patches; the script returns the patched bytes.

# Warm Snow player cooldowns -> 2s — Managed$Assembly-CSharp.dll.py
import struct
data = bytearray(v["current"])
new = struct.pack("<f", 2.0)
for base, expected in [
    (0x127E73, 14.0),   # base drawCoolDown
    (0x126E74, 28.0),   # GourdLiquor
]:
    assert data[base] == 0x22 and struct.unpack("<f", bytes(data[base+1:base+5]))[0] == expected
    data[base + 1:base + 5] = new
data

For a bundle/asset file (e.g. config), the script can instead work on the JSON representation: v.toJson() decodes the current bytes with asset4j, the script patches the tree, and v.fromJson(tree) re-encodes. v.resource(name) reads any file from the patch directory.

// Bladed Fury "easier mode" — config.kt
val tree = v.toJson() as com.fasterxml.jackson.databind.node.ObjectNode
// ... JSONPath-select and scale MonsterData / BehaviorNode ...
v.fromJson(tree)

IL patching

For Unity .NET assemblies (e.g., Managed$Assembly-CSharp.dll), automod supports static IL patching via dnlib4j. A .il.toml file replaces fragile hex-offset byte patches with semantic TOML rules that survive game updates keeping the same instruction patterns.

The file is named <AssemblyName>.il.toml (e.g., Managed$Assembly-CSharp.dll.il.toml). The target assembly is derived by stripping .il from the name.

Constant swaps ([[float]], [[int]], [[long]])

Replace numeric constants in method bodies. Searches for matching load-constant opcodes and patches the operand:

# Warm Snow "easy mode" — halve ability cooldowns
[[float]]
type = "PlayerAnimControl"
method = ".ctor"
old = 14.0
new = 2.0
occurrence = 2

[[float]]
type = "PlayerAnimControl"
method = "GourdLiquorContainerOn"
old = 28.0
new = 2.0

[[float]]
type = "PlayerAnimControl"
method = ".ctor"
old = 15.0
new = 2.0
occurrence = "1,2"

Fields: type/method (optional filters, null = match all), old (value to find), new (replacement), occurrence (which match: integer, comma-separated string, or array; omitted = patch every match).

String swaps ([[string]])

Replace string literals loaded by ldstr instructions:

[[string]]
type = "SomeClass"
method = "SomeMethod"
old = "original string"
new = "replacement string"

Instruction sequence replacement ([[il]])

Find-and-replace of IL instruction sequences using find/replace arrays:

[[il]]
type = "SomeClass"
method = "SomeMethod"
find = ["ldc.i4.1", "ret"]
replace = ["ldc.i4.0", "ret"]
occurrence = 1

Instruction specs are space-separated: "opcode" or "opcode operand". Operands can be strings, method references ("Type::Method"), field references ("ldfld Type::Field"), integers, or floats.

Method body replacement ([[method]])

Replace or prepend/append to a method’s entire body:

[[method]]
type = "SomeClass"
method = "SomeMethod"
kind = "body"     # "body" (replace all), "entry" (prepend), "exit" (before every ret)
replace = ["nop", "ret"]

Code injection ([[inject.call]], [[inject.field_read]], [[inject.field_write]])

Inject code at call sites or field access sites:

[[inject.call]]
target = "TargetType::TargetMethod"
when = "AFTER"        # BEFORE, AFTER, AROUND, or REPLACE
scope = "ScopedType"  # optional: only inject in this type's methods
code = ["ldstr \"injected\"", "call System.Console::WriteLine(string)"]

IL patching integrates with the standard raw file pipeline — .il.toml files are collected alongside other patches and applied sequentially. The patched DLL gets chmod +x on non-Windows platforms.

ttmap schemas

A ttmap records the Unity type trees (and script classes) a game needs for decoding external-tree assets. Each supported game pins its ttmap (e.g., warmsnow_3.1.0.1.ttmap); when the game’s mapUri is configured but the map file is not yet present in tools/ttmap (or tools/usmap for Unreal Engine games), automod downloads it from the automod releases during setup. To regenerate schemas (e.g., after a game update), use the .ttmapgen command:

./automod .ttmapgen dll "game/WarmSnow_Data/Managed" warmsnow.ttmap 2020.3.22f1 3.1.0.1
./automod .ttmapgen il2cpp GameAssembly.dll global-metadata.dat silksong.ttmap 2022.3.10f1 1.0.0

ttmapgen.jar is auto-installed (version-matched to automod’s asset4j) on first use; the dll/il2cpp modes additionally require a .NET 10 runtime to build the C# harness.

Installing Unity mods

Each generated archive embeds install.py and a gameDataDir.txt hint. Uncompress the archive, then:

python3 install.py        # install; originals are backed up first
python3 install.py -r     # restore the originals (undo)

The installer needs Python 3 on the machine installing the mod (not to run automod itself); if Python is unavailable, uncompress the archive and copy the files into the game’s data directory manually. Backups live in <game-dir>/../<mod-name>-backup/. The installer is cross-platform (Windows, Linux, macOS) and sets the executable bit on patched .dll files on non-Windows platforms.