Skip to content

Instantly share code, notes, and snippets.

@frostbtn
Created January 26, 2026 14:38
Show Gist options
  • Select an option

  • Save frostbtn/f85b574ce132c8853945df852e1e0d31 to your computer and use it in GitHub Desktop.

Select an option

Save frostbtn/f85b574ce132c8853945df852e1e0d31 to your computer and use it in GitHub Desktop.

ClaudePatcher

Runtime memory patcher for Claude Code's Windows markdown bug (since around v2.1.14). Hacks #14720.

Patches claude.exe in memory before it starts. Searches for

.normalize().replaceAll(`\r\n`,`\n`) and replaces it with

.normalize().replaceAll(`\v\n`,`\n`) - making it essentially a no-op since nobody's used vertical tabs (\v) since like the 70s.

The patcher looks for claude.exe next to itself first, then falls back to %USERPROFILE%\.local\bin\claude.exe where native Claude Code usually installs. Passes through all arguments as-is and stays silent unless something goes wrong.

Requires .NET 8.0 or later.

Build:

dotnet publish ClaudePatcher.csproj -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -p:PublishTrimmed=true -p:EnableCompressionInSingleFile=true

Run the resulting ClaudePatcher.exe instead of claude.exe. Start with ClaudePatcher.exe -v it should print the CC's version as usual and exit.

<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<OutputPath>.\</OutputPath>
<PublishDir>.\</PublishDir>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
</PropertyGroup>
</Project>
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
class ClaudePatcher
{
// Win32 API declarations
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool ReadProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress,
[Out] byte[] lpBuffer, int dwSize, out IntPtr lpNumberOfBytesRead);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool WriteProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress,
byte[] lpBuffer, int nSize, out IntPtr lpNumberOfBytesWritten);
[DllImport("kernel32.dll")]
static extern bool VirtualQueryEx(IntPtr hProcess, IntPtr lpAddress,
out MEMORY_BASIC_INFORMATION lpBuffer, uint dwLength);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool VirtualProtectEx(IntPtr hProcess, IntPtr lpAddress,
UIntPtr dwSize, uint flNewProtect, out uint lpflOldProtect);
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
static extern bool CreateProcess(
string? lpApplicationName,
string lpCommandLine,
IntPtr lpProcessAttributes,
IntPtr lpThreadAttributes,
bool bInheritHandles,
uint dwCreationFlags,
IntPtr lpEnvironment,
string? lpCurrentDirectory,
ref STARTUPINFO lpStartupInfo,
out PROCESS_INFORMATION lpProcessInformation);
[DllImport("kernel32.dll", SetLastError = true)]
static extern uint ResumeThread(IntPtr hThread);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool CloseHandle(IntPtr hObject);
[DllImport("kernel32.dll", SetLastError = true)]
static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
private const uint CREATE_SUSPENDED = 0x00000004;
private const uint INFINITE = 0xFFFFFFFF;
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
struct STARTUPINFO
{
public int cb;
public string lpReserved;
public string lpDesktop;
public string lpTitle;
public int dwX;
public int dwY;
public int dwXSize;
public int dwYSize;
public int dwXCountChars;
public int dwYCountChars;
public int dwFillAttribute;
public int dwFlags;
public short wShowWindow;
public short cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[StructLayout(LayoutKind.Sequential)]
struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public int dwProcessId;
public int dwThreadId;
}
[StructLayout(LayoutKind.Sequential)]
struct MEMORY_BASIC_INFORMATION
{
public IntPtr BaseAddress;
public IntPtr AllocationBase;
public uint AllocationProtect;
public IntPtr RegionSize;
public uint State;
public uint Protect;
public uint Type;
}
private const uint MEM_COMMIT = 0x1000;
private const uint PAGE_READONLY = 0x02;
private const uint PAGE_READWRITE = 0x04;
private const uint PAGE_EXECUTE_READ = 0x20;
private const uint PAGE_EXECUTE_READWRITE = 0x40;
static string? FindClaudeExe()
{
// Check same directory as patcher
string appDir = Path.GetDirectoryName(Environment.ProcessPath) ?? "";
string localPath = Path.Combine(appDir, "claude.exe");
if (File.Exists(localPath))
return localPath;
// Check user's .local/bin directory
string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
string userLocalPath = Path.Combine(userProfile, ".local", "bin", "claude.exe");
if (File.Exists(userLocalPath))
return userLocalPath;
return null;
}
static void Main(string[] args)
{
string? claudeExe = FindClaudeExe();
if (claudeExe == null)
{
Console.WriteLine("Error: claude.exe not found");
Console.WriteLine("Searched locations:");
Console.WriteLine($" - {Path.GetDirectoryName(Environment.ProcessPath)}\\claude.exe");
string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
Console.WriteLine($" - {Path.Combine(userProfile, ".local", "bin", "claude.exe")}");
Environment.Exit(1);
}
try
{
STARTUPINFO si = new STARTUPINFO();
si.cb = Marshal.SizeOf(si);
PROCESS_INFORMATION pi;
string cmdLine = claudeExe + (args.Length > 0 ? " " + string.Join(" ", args) : "");
if (!CreateProcess(null, cmdLine, IntPtr.Zero, IntPtr.Zero, false,
CREATE_SUSPENDED, IntPtr.Zero, null, ref si, out pi))
{
Console.WriteLine($"Failed to create process. Error: {Marshal.GetLastWin32Error()}");
Environment.Exit(1);
}
StringBuilder diagnostics = new StringBuilder();
int patchCount = SearchAndPatch(pi.hProcess, diagnostics);
ResumeThread(pi.hThread);
WaitForSingleObject(pi.hProcess, INFINITE);
uint exitCode;
GetExitCodeProcess(pi.hProcess, out exitCode);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
// Only print diagnostics if patching failed
if (patchCount < 1)
{
Console.WriteLine($"Warning: No patches applied");
Console.WriteLine(diagnostics.ToString());
}
Environment.Exit((int)exitCode);
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
Environment.Exit(1);
}
}
static int SearchAndPatch(IntPtr hProcess, StringBuilder diagnostics)
{
// Full patterns to search and replace
byte[] oldBytes = Encoding.ASCII.GetBytes(".normalize().replaceAll(`\\r\n`,`\n`)");
byte[] newBytes = Encoding.ASCII.GetBytes(".normalize().replaceAll(`\\v\n`,`\n`)");
if (oldBytes.Length != newBytes.Length)
{
diagnostics.AppendLine("Error: Search and replace patterns must be same length");
return 0;
}
int patchCount = 0;
IntPtr address = IntPtr.Zero;
MEMORY_BASIC_INFORMATION mbi = new MEMORY_BASIC_INFORMATION();
uint mbiSize = (uint)Marshal.SizeOf(typeof(MEMORY_BASIC_INFORMATION));
while (VirtualQueryEx(hProcess, address, out mbi, mbiSize))
{
// Advance to next region
long nextAddress = mbi.BaseAddress.ToInt64() + mbi.RegionSize.ToInt64();
if (nextAddress <= 0 || nextAddress > 0x7FFFFFFFFFFF)
break;
address = new IntPtr(nextAddress);
// Skip non-committed memory
if (mbi.State != MEM_COMMIT)
continue;
// Skip non-readable memory
if (mbi.Protect != PAGE_READONLY &&
mbi.Protect != PAGE_READWRITE &&
mbi.Protect != PAGE_EXECUTE_READ &&
mbi.Protect != PAGE_EXECUTE_READWRITE)
continue;
long regionSize = mbi.RegionSize.ToInt64();
// Skip invalid or excessively large regions (> 500MB)
if (regionSize <= 0 || regionSize >= 500 * 1024 * 1024)
continue;
byte[] buffer = new byte[regionSize];
if (!ReadProcessMemory(hProcess, mbi.BaseAddress, buffer,
(int)regionSize, out IntPtr bytesRead))
continue;
// Search for pattern in this region
for (long i = 0; i <= bytesRead.ToInt64() - oldBytes.Length; i++)
{
bool match = true;
for (int j = 0; j < oldBytes.Length; j++)
{
if (buffer[i + j] != oldBytes[j])
{
match = false;
break;
}
}
if (!match)
continue;
IntPtr patchAddress = IntPtr.Add(mbi.BaseAddress, (int)i);
diagnostics.AppendLine($"Found pattern at 0x{patchAddress.ToString("X")}");
uint oldProtect;
if (!VirtualProtectEx(hProcess, patchAddress, (UIntPtr)newBytes.Length,
PAGE_EXECUTE_READWRITE, out oldProtect))
{
diagnostics.AppendLine($" Failed to change protection (Error: {Marshal.GetLastWin32Error()})");
continue;
}
if (WriteProcessMemory(hProcess, patchAddress,
newBytes, newBytes.Length, out IntPtr written))
{
diagnostics.AppendLine($" Patched successfully");
patchCount++;
}
else
{
diagnostics.AppendLine($" Failed to write (Error: {Marshal.GetLastWin32Error()})");
}
VirtualProtectEx(hProcess, patchAddress, (UIntPtr)newBytes.Length,
oldProtect, out oldProtect);
}
}
return patchCount;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment