Declarative patching for .NET assemblies.
ntrupt applies patches to compiled .NET assemblies from JSON manifests.
ntrupt apply patch.json target.exe -o patched.exeInstead 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.
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.
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).
$ 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.
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.
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.operandis optional; omit it to match on opcode alone.count: how many adjacent instructions to remove, starting at the match.
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).
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"
}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).
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).
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.
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.
$ 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.exeWell played!
{
"$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": {
"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.
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. |
| 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 |
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 }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" }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 }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 }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" }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" }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" }
] }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" }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=trueThe published binary is self-contained
dotnet test