Skip to content
vbxqPublic

About

Declarative .NET assembly patching engine

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

ntrupt

Declarative patching for .NET assemblies.

ntrupt applies patches to compiled .NET assemblies from JSON manifests.

ntrupt apply patch.json target.exe -o patched.exe

Instead of hiding modifications inside a custom script or executable, ntrupt describes them as data that can be read, reviewed, committed, and reused. The input assembly is never modified in place. Existing output files are left untouched unless --overwrite is passed.

License

GNU Affero General Public License v3

ntrupt is free software released under the GNU Affero General Public License v3.


Warning

This guide is provided for educational and interoperability research purposes only. Use ntrupt exclusively on software you own or are explicitly authorized to modify.

Do not use it to bypass licensing systems, DRM, access controls, paid content, or other technical protections.

I do not endorse piracy or unauthorized distribution of modified software.

Tutorial: a real patch, step by step

The best way to learn ntrupt is to walk through a complete manifest. This one removes Steamworks integration from Celeste, stubs out networked stats and achievements, and adds a splash of personal branding, all without touching a compiler.

The full file is celeste.ntrupt.json (22 patches, 19 applied).

Step 1, inspect the target

$ ntrupt inspect Celeste.exe --json
{
  "Name": "Celeste",
  "Version": "1.0.0.0",
  "ModuleId": "4a26f9de-d670-4c87-a2f4-7e66d2d85163",
  "Sha256": "3ee734ec8c767f638e9a7c2cbc07109f0a0bf1eb19...",
  "References": [
    { "Name": "FNA", "Version": "21.3.5.0" },
    { "Name": "Steamworks.NET", "Version": "10.0.0.0" },
    ...
  ]
}

We can see that Steamworks.NET is a dependency. The goal is to sever that dependency completely.

Step 2, drop the assembly reference

The manifest starts with a target constraint and a gentle removal:

{
  "schemaVersion": 1,
  "name": "Celeste, Steamworks removal by vbxq",
  "target": { "assemblyName": "Celeste" },
  "patches": [
    {
      "id": "remove-steamworks-asmref",
      "operation": "remove-assembly-reference",
      "reference": "Steamworks.NET",
      "required": false
    }
  ]
}

required: false means "if it's already gone, don't fail, just skip", conservative by default; permissive when you ask.

Step 3, neuter the static constructor

Celeste.Celeste..cctor initialises SteamID with a value from the Steamworks SDK. We remove the three instructions that do this: ldc.i4 504230, newobj Steamworks.AppId_t::.ctor, and stsfld SteamID.

{
  "id": "cctor-remove-version-check",
  "operation": "remove-instructions-by-opcode",
  "type": "Celeste.Celeste",
  "method": ".cctor",
  "opcode": "Ldc_I4",
  "operand": "504230",
  "operandType": "int",
  "count": 3
}
  • type : the full type name (Celeste.Celeste).
  • method : the method to operate on (.cctor = static constructor).
  • opcode / operand / operandType : what to find. operand is optional; omit it to match on opcode alone.
  • count : how many adjacent instructions to remove, starting at the match.

Step 4, remove a method call

In Celeste.Celeste.Update, the game calls Steamworks.SteamAPI::RunCallbacks every frame. Removing the call is one operation:

{
  "id": "update-remove-steam-runcallbacks",
  "operation": "remove-instructions-by-call",
  "type": "Celeste.Celeste",
  "method": "Update",
  "callType": "Steamworks.SteamAPI",
  "callMethod": "RunCallbacks",
  "count": 1
}
  • callType, the full declaring type of the method being called.
  • callMethod, the method name.
  • count, instructions to remove from the call onward (usually 1 for a bare call).

Step 5, clean up the Main method and the field itself

Celeste.Celeste.Main has a 15-instruction block that reads SteamID, calls RestartAppIfNecessary, Init, and GetCurrentGameLanguage. The block starts with ldsfld Celeste.Celeste::SteamID. We match it with operandType: "field":

{
  "id": "main-remove-steamid-usage",
  "operation": "remove-instructions-by-opcode",
  "type": "Celeste.Celeste",
  "method": "Main",
  "opcode": "Ldsfld",
  "operand": "Celeste.Celeste::SteamID",
  "operandType": "field",
  "count": 15
}

The Type::FieldName syntax (Celeste.Celeste::SteamID) lets you target a specific field when the same name appears in multiple types.

Once the code is gone, we remove the field definition itself:

{
  "id": "main-remove-steamid-field",
  "operation": "remove-field",
  "type": "Celeste.Celeste",
  "field": "SteamID"
}

Step 6, rewrite the version at runtime

Celeste's constructor passes four integers to new Version(...). We replace them with 1337, 0, 0, 0 using anchor-relative matching:

{
  "id": "ctor-patch-version-major",
  "operation": "replace-field-operand",
  "type": "Celeste.Celeste",
  "method": ".ctor",
  "opcode": "Ldc_I4",
  "anchorOpcode": "Newobj",
  "anchorOperand": "System.Version::.ctor",
  "anchorOperandType": "method",
  "offset": -4,
  "newOperand": "1337",
  "operandType": "int"
}

This says: find the newobj that calls System.Version::.ctor, then replace the Ldc_I4 four positions before it with 1337. Three more patches handle the remaining arguments at offsets -3, -2, and -1.

The :: syntax in anchorOperand matches against DeclaringType::MethodName. It disambiguates when multiple .ctor calls exist in the same method (the other one creates an AutoSplitterInfo, the System.Version prefix avoids it).

Step 7, stub out networked methods

Celeste.Stats and Celeste.Achievements call into SteamUserStats. Every method body gets replaced:

{ "id": "stats-makerequest-clear", "operation": "clear-method-body",
  "type": "Celeste.Stats", "method": "MakeRequest" },

{ "id": "stats-local-clear-return-0", "operation": "clear-method-body",
  "type": "Celeste.Stats", "method": "Local",
  "returnValue": "0", "operandType": "int" }

Methods that return void end with a bare ret. Methods that return a value get a constant load before the ret. Use operandType to control how the constant is emitted (int, float, string, bool, int64, double).

Step 8, add a watermark on the title screen

Celeste.OuiTitleScreen..ctor calls the base constructor, then does its own setup. We insert three instructions right after that base constructor call:

{
  "id": "oui-ctor-brand",
  "operation": "insert-instructions",
  "type": "Celeste.OuiTitleScreen",
  "method": ".ctor",
  "anchor": "after-base-ctor",
  "instructions": [
    { "opcode": "Ldarg_0" },
    { "opcode": "Ldstr", "operand": "hey\nplayer!", "operandType": "string" },
    { "opcode": "Stfld", "operand": "version", "operandType": "field" }
  ]
}

after-base-ctor finds the call to the base .ctor and inserts after it. before-ret is also available, it inserts before the last ret of the method.

operandType: "field" resolves the field by name within the current type.

Step 9, tweak the visuals

A direct match changes the title colour, a float match changes the scale:

{
  "id": "oui-render-replace-color",
  "operation": "replace-field-operand",
  "type": "Celeste.OuiTitleScreen", "method": "Render",
  "opcode": "Call",
  "oldOperand": "get_DarkSlateBlue",
  "operandType": "method",
  "newOperand": "get_LightSkyBlue"
},
{
  "id": "oui-render-replace-scale",
  "operation": "replace-field-operand",
  "type": "Celeste.OuiTitleScreen", "method": "Render",
  "opcode": "Ldc_R4",
  "oldOperand": "0.5", "newOperand": "0.9",
  "operandType": "float"
}

When operandType is "method", the replacement keeps the same declaring type and return type, only the method name changes.

Running the patch

$ ntrupt apply celeste.ntrupt.json Celeste.exe -o Celeste_patched.exe --overwrite
Celeste, Steamworks removal by vbxq
  applied remove-steamworks-asmref: Removed reference 'Steamworks.NET'.
  applied cctor-remove-version-check: Removed 3 instruction(s) ... in 'Celeste.Celeste..cctor'.
  ... (19 patches applied)
Saved: Celeste/Celeste_patched.exe

Well played!


Manifest reference

Top-level structure

{
  "$schema": "./schemas/patch-manifest.schema.json",
  "schemaVersion": 1,
  "name": "Descriptive name for this patch set",
  "target": { ... },
  "patches": [ ... ]
}
Field Required Notes
schemaVersion yes Must be 1.
name yes Human-readable label.
target yes At least one constraint is required.
patches yes Ordered list; at least one operation.

Target constraints

{
  "target": {
    "assemblyName": "Celeste",
    "version": "1.0.0.0",
    "moduleId": "4a26f9de-d670-4c87-a2f4-7e66d2d85163",
    "sha256": "3ee734ec8c767f638e9a7c2cbc07109f0a0bf1eb1973dbe447e99144ca9e7dd9"
  }
}

Every provided constraint must match the target assembly. The manifest is rejected before any operation runs when a constraint fails.

Common fields

Every operation accepts:

Field Default Notes
id (required) Unique identifier within the manifest.
operation (required) One of the eight operation names below.
required true When false, the operation is skipped cleanly if its target isn't found instead of failing the whole patch.

IL-level operations share:

Field Notes
type Full type name, e.g. Celeste.Celeste.
method Method name, e.g. .ctor, .cctor, Update.

operandType values

Value C# equivalent
int, int32 System.Int32
int64, long System.Int64
float, float32 System.Single
float64, double System.Double
string System.String
bool System.Boolean
field FieldReference, resolves Type::Name or just Name
method MethodReference, resolves Type::Name or just Name

Operation reference

remove-assembly-reference

Removes an assembly reference from the module.

Param Notes
reference Assembly name (e.g. "Steamworks.NET").
{ "id": "...", "operation": "remove-assembly-reference",
  "reference": "Steamworks.NET", "required": false }

set-assembly-reference-version

Changes the version of a referenced assembly.

Param Notes
reference Assembly name.
version New version string, e.g. "2.0.0.0".
{ "id": "...", "operation": "set-assembly-reference-version",
  "reference": "Example.Library", "version": "2.0.0.0" }

remove-instructions-by-opcode

Finds the first instruction matching an opcode (and optionally an operand), then removes count adjacent instructions starting at that position.

Param Notes
type Full type name.
method Method name.
opcode IL opcode name as it appears in System.Reflection.Emit.OpCodes (e.g. Ldc_I4, Ldsfld, Call).
operand Value to match (optional, omit to match on opcode alone).
operandType How to parse operand.
count Number of instructions to remove (default 1).
{ "id": "...", "operation": "remove-instructions-by-opcode",
  "type": "MyApp.Game", "method": ".cctor",
  "opcode": "Ldc_I4", "operand": "504230", "operandType": "int", "count": 3 }

remove-instructions-by-call

Finds a Call instruction by declaring type and method name, then removes count instructions starting at that call.

Param Notes
type Full type name.
method Method containing the call.
callType Full declaring type of the called method.
callMethod Name of the called method.
count Instructions to remove (default 1).
{ "id": "...", "operation": "remove-instructions-by-call",
  "type": "MyApp.Game", "method": "Update",
  "callType": "Steamworks.SteamAPI", "callMethod": "RunCallbacks", "count": 1 }

clear-method-body

Replaces a method body with a minimal return sequence.

Param Notes
type Full type name.
method Method name.
returnValue Optional constant value to return.
operandType How to emit returnValue.

Without returnValue the method becomes a bare ret. With it, a constant-load instruction is inserted before the ret.

{ "id": "...", "operation": "clear-method-body",
  "type": "MyApp.Stats", "method": "MakeRequest" }

{ "id": "...", "operation": "clear-method-body",
  "type": "MyApp.Stats", "method": "Local",
  "returnValue": "0", "operandType": "int" }

replace-field-operand

Replaces the operand of an instruction. Supports three matching modes.

Direct match, find an instruction by opcode + operand and replace its value:

Param Notes
type Full type name.
method Method name.
opcode IL opcode of the instruction to replace.
oldOperand Value to find (optional, omit to match opcode alone).
newOperand Replacement value.
operandType How to parse oldOperand and emit newOperand.
occurrence Which match to select, 1-based (default 1).
{ "id": "...", "operation": "replace-field-operand",
  "type": "MyApp.Render", "method": "Render",
  "opcode": "Ldc_R4", "oldOperand": "0.5", "newOperand": "0.9",
  "operandType": "float" }

Method operand, replace a method call on the same declaring type:

{ "id": "...", "operation": "replace-field-operand",
  "type": "MyApp.Render", "method": "Render",
  "opcode": "Call",
  "oldOperand": "get_DarkSlateBlue",
  "newOperand": "get_LightSkyBlue",
  "operandType": "method" }

Anchor-relative match, find an anchor instruction, then replace the instruction at offset from it:

Param Notes
anchorOpcode Opcode of the anchor instruction.
anchorOperand Value the anchor must carry (optional). Use Type::Method to match a call target.
anchorOperandType How to parse anchorOperand.
offset Signed distance from the anchor (negative = before).
{ "id": "...", "operation": "replace-field-operand",
  "type": "MyApp.Game", "method": ".ctor",
  "opcode": "Ldc_I4",
  "anchorOpcode": "Newobj",
  "anchorOperand": "System.Version::.ctor",
  "anchorOperandType": "method",
  "offset": -4,
  "newOperand": "1337",
  "operandType": "int" }

insert-instructions

Inserts a sequence of IL instructions after a known anchor point.

Param Notes
type Full type name.
method Method name.
anchor "after-base-ctor" or "before-ret".
instructions Array of instruction objects.

Each instruction object:

Field Notes
opcode IL opcode name.
operand Optional value.
operandType How to parse operand.
{ "id": "...", "operation": "insert-instructions",
  "type": "MyApp.TitleScreen", "method": ".ctor",
  "anchor": "after-base-ctor",
  "instructions": [
    { "opcode": "Ldarg_0" },
    { "opcode": "Ldstr", "operand": "hello", "operandType": "string" },
    { "opcode": "Stfld", "operand": "version", "operandType": "field" }
  ] }

remove-field

Removes a field definition from a type.

Param Notes
type Full type name.
field Field name.
{ "id": "...", "operation": "remove-field",
  "type": "MyApp.Game", "field": "SteamID" }

Build from source

Requires the .NET 8 SDK.

# Linux
./build.sh   # -> artifacts/linux-x64/ntrupt
# Windows
build.bat    # -> artifacts/win-x64/ntrupt.exe

# or just do it manually
dotnet publish src/Ntrupt.Cli/Ntrupt.Cli.csproj \
  -c Release -r linux-x64 -o artifacts/linux-x64 \
  --self-contained -p:PublishSingleFile=true

The published binary is self-contained

Tests

dotnet test

About

Declarative .NET assembly patching engine

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages