Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ This is an independent and unofficial tool for educational use ONLY. Using the P
Point it at a copy of `NostaleClientX.exe`, pick a new server address and output filename, hit **Patch**. The output binary gets three edits:

1. **Server address** — the client stores the login server as a Delphi constant AnsiString in its `.rdata`. We auto-detect the slot by shape (refcount-`-1` header + IP-looking payload, no text search), overwrite the payload, and rewrite the length prefix at offset `-4`.
2. **Allow no-arg launches** — the entry point calls `ParamCount`, decrements, and `JL`s to the exit path on a negative result. That gate aborts double-click launches. We NOP the 6-byte `JL rel32` so execution flows into the arg dispatcher regardless of argc.
3. **Default to Entwell mode** — the arg dispatcher compares `argv[1]` against `"gf"` / `"gftest"` / `"EntwellNostaleClient"` (in that order) and `JNZ`s to exit when none match. We NOP that last `JNZ`, so any non-`gf` / non-`gftest` launch (including empty) falls into the Entwell standalone body. The `gf` / `gftest` branches are untouched — passing `gf <N>` still routes through GF mode as the real Gameforge launcher would trigger.
2. **Double-click opens the password login** — the entry point calls `ParamCount`, decrements, and `JL`s to the exit path on a negative result. We retarget that `JL` to the body every login mode shares, so a launch without arguments reaches it with the Steam and GF flags still zero, which is the `EntwellNostaleClient` password login. Launches with an argument never take the jump, so `gf <N>` (what the Auth tab's **Sign in & launch** passes) still goes through the auth-code login.
3. **Gameforge wrapper stub** — the `gf_wrapper.dll` import is renamed to `noscore_gf.dll`, which is written next to the patched exe and hands the client the `_NC_AUTH_CODE` from the Auth tab.

## ⚠️ Windows Defender

Expand Down
135 changes: 21 additions & 114 deletions src/NosCore.ClientTools/ClientPatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,9 @@ namespace NosCore.ClientTools;
/// <summary>
/// In-place byte patches against the Gameforge NosTale client
/// (<c>NostaleClientX.exe</c>): rewrites the embedded login-server
/// address, neutralises the argc gate so no-arg launches don't exit,
/// and defaults unknown args into the Entwell standalone body while
/// leaving the <c>gf</c> / <c>gftest</c> GF-mode branches untouched.
/// address, sends no-argument launches to the password login and
/// points the Gameforge wrapper import at our stub.
/// </summary>
public enum EntryPatchMode
{
DefaultToEntwell,
OnlyEntwell,
None,
}

public static class ClientPatcher
{
public sealed record PatchResult(bool Success, string Log);
Expand Down Expand Up @@ -111,126 +103,41 @@ public static PatchResult PatchServerAddress(byte[] bytes, string newAddress)
}

/// <summary>
/// Make the client default to its "EntwellNostaleClient" standalone
/// path regardless of the command-line argument. Leaves the `gf` /
/// `gftest` branches intact so those still route to GF mode when the
/// real Gameforge launcher drives the exe.
///
/// Anchor signature (30 bytes, 3 absolute addresses wildcarded):
/// The entry point exits when <c>ParamCount - 1 &lt; 0</c> (<c>JL rel32</c>).
/// Retarget that jump to the body shared by every login mode, so a launch
/// without arguments reaches it with the Steam and GF flags still zero,
/// which is the password (<c>EntwellNostaleClient</c>) login. Launches with
/// an argument never take the jump and keep the original dispatch, so
/// <c>gf &lt;N&gt;</c> still works. Nothing is skipped half way, so the stack
/// stays balanced and the client exits cleanly.
///
/// B9 14 00 00 00 BA 01 00 00 00 E8 ?? ?? ?? ?? ; mov ecx,14 / mov edx,1 / call
/// 8B 45 CC BA ?? ?? ?? ?? E8 ?? ?? ?? ?? ; load arg / load "EntwellNostaleClient" / call cmp
/// 0F 85 ; JNZ rel32 (6 bytes with the rel32 following)
///
/// An earlier version NOPped just the JNZ so mismatches fell through
/// into the Entwell body, but the two <c>call</c>s before the JNZ have
/// side effects (ref-counted AnsiString setup + case-folded compare)
/// that the match-case naturally balances at shutdown and the
/// fall-through case doesn't — producing a timing-sensitive
/// <c>EOSError 1400 / Invalid window handle</c> on close.
///
/// Instead, skip the whole compare block: overwrite bytes
/// [pattern_start, pattern_start+34) — pattern + trailing JNZ rel32 —
/// with a short <c>JMP +32</c> and NOP padding, landing exactly on the
/// first instruction of the Entwell body. The side-effectful calls
/// never run on our path, so shutdown balances cleanly.
/// </summary>
public static PatchResult PatchDefaultToEntwell(byte[] bytes)
{
const string pattern =
"B9 14 00 00 00 BA 01 00 00 00 E8 ? ? ? ? 8B 45 CC BA ? ? ? ? E8 ? ? ? ? 0F 85";

var offset = FindPattern(bytes, pattern, 0);
if (offset < 0)
{
return new PatchResult(false, "Default-to-Entwell pattern not found.");
}

// EB 20 = short JMP, rel8 = +32, so next_ip (offset+2) + 32 = offset+34
// which is the first byte after the original JNZ rel32 = Entwell body entry.
bytes[offset + 0] = 0xEB;
bytes[offset + 1] = 0x20;
for (var i = 2; i < 34; i++) bytes[offset + i] = 0x90;

return new PatchResult(true,
$"Default-to-Entwell: JMP +32 over compare block at 0x{offset:X} — any non-gf/gftest launch skips straight into the Entwell body.");
}

/// <summary>
/// Force the client into the Entwell standalone body unconditionally,
/// regardless of command-line args. Replaces the early-entry argc
/// <c>JL</c> with an unconditional <c>JMP</c> straight to the Entwell
/// body (located via the <see cref="PatchDefaultToEntwell"/> pattern
/// + 34). Skips the <c>gf</c> / <c>gftest</c> / <c>EntwellNostaleClient</c>
/// compares entirely — so <c>gf</c> mode stops working — but avoids
/// the timing-sensitive shutdown crash that both NOP-and-JMP variants
/// of <see cref="PatchDefaultToEntwell"/> trigger when any of those
/// compares runs beforehand.
/// The shared body starts 34 bytes after the <c>EntwellNostaleClient</c>
/// compare anchor (<c>Copy(ParamStr(1), 1, 20)</c>, compare, <c>JNZ rel32</c>).
/// </summary>
public static PatchResult PatchForceEntwell(byte[] bytes)
public static PatchResult PatchNoArgToPasswordLogin(byte[] bytes)
{
const string entwellPattern =
const string loginBodyPattern =
"B9 14 00 00 00 BA 01 00 00 00 E8 ? ? ? ? 8B 45 CC BA ? ? ? ? E8 ? ? ? ? 0F 85";
const string argcPattern = "E8 ? ? ? ? E8 ? ? ? ? 48 0F 8C";

var entwellBlock = FindPattern(bytes, entwellPattern, 0);
if (entwellBlock < 0)
var compareBlock = FindPattern(bytes, loginBodyPattern, 0);
if (compareBlock < 0)
{
return new PatchResult(false, "Force-Entwell: default-to-Entwell anchor pattern not found.");
return new PatchResult(false, "No-arg login: login mode compare pattern not found.");
}
var argcBlock = FindPattern(bytes, argcPattern, 0);
if (argcBlock < 0)
{
return new PatchResult(false, "Force-Entwell: argc-gate anchor pattern not found.");
return new PatchResult(false, "No-arg login: argc check pattern not found.");
}

var entwellBody = entwellBlock + 34;
var loginBody = compareBlock + 34;
var jlOffset = argcBlock + 11;
// Overwrite `0F 8C rel32` (6 bytes) with `E9 rel32 90` (JMP rel32 + NOP).
var rel32 = entwellBody - (jlOffset + 5);
bytes[jlOffset + 0] = 0xE9;
bytes[jlOffset + 1] = (byte)rel32;
bytes[jlOffset + 2] = (byte)(rel32 >> 8);
bytes[jlOffset + 3] = (byte)(rel32 >> 16);
bytes[jlOffset + 4] = (byte)(rel32 >> 24);
bytes[jlOffset + 5] = 0x90;

return new PatchResult(true,
$"Force-Entwell: JMP at 0x{jlOffset:X} → Entwell body 0x{entwellBody:X} (rel32=0x{rel32:X}). `gf` mode will no longer work.");
}

/// <summary>
/// Neutralise the early-entry argc gate: the entry point calls
/// <c>ParamCount()</c>, decrements it, and on a negative result
/// <c>JL</c>s to the cleanup/exit path. Without a patch this means
/// double-click (no-arg) launches bail out.
///
/// Signature (13 bytes, 2 calls wildcarded):
/// E8 ?? ?? ?? ?? ; call ParamStr-ish helper
/// E8 ?? ?? ?? ?? ; call ParamCount
/// 48 ; dec eax
/// 0F 8C ; JL — we overwrite these + the rel32 below
///
/// NOPping the 6-byte <c>JL rel32</c> at pattern offset 11 lets
/// execution flow into the arg dispatcher regardless of argc, which
/// combined with <see cref="PatchDefaultToEntwell"/> drops any
/// unknown arg into the Entwell body. <c>gf</c> / <c>gftest</c>
/// branches remain fully functional.
/// </summary>
public static PatchResult PatchAllowNoArg(byte[] bytes)
{
const string pattern = "E8 ? ? ? ? E8 ? ? ? ? 48 0F 8C";
var offset = FindPattern(bytes, pattern, 0);
if (offset < 0)
{
return new PatchResult(false, "Allow-no-arg pattern not found.");
}

var jlOffset = offset + 11;
for (var i = 0; i < 6; i++) bytes[jlOffset + i] = 0x90;
var rel32 = loginBody - (jlOffset + 6);
WriteInt32LittleEndian(bytes, jlOffset + 2, rel32);
Comment thread
erwan-joly marked this conversation as resolved.
Resolved

return new PatchResult(true,
$"Allow-no-arg: NOPped JL at 0x{jlOffset:X} — the argc gate no longer aborts on zero-arg launches.");
$"No-arg login: JL at 0x{jlOffset:X} now targets the login body at 0x{loginBody:X}; launches without arguments open the password login, gf launches are unchanged.");
}

/// <summary>
Expand Down
48 changes: 9 additions & 39 deletions src/NosCore.DeveloperTools/Forms/MainForm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -434,13 +434,13 @@ private TabPage BuildClientCreatorTab()
{
Dock = DockStyle.Fill,
ColumnCount = 3,
RowCount = 6,
RowCount = 5,
Padding = new Padding(10),
};
layout.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
layout.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
for (var i = 0; i < 6; i++) layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
for (var i = 0; i < 5; i++) layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));

var cc = _settings.ClientCreator;
var newAddressBox = new TextBox { Dock = DockStyle.Fill, PlaceholderText = "e.g. 192.168.1.50", Text = cc.NewAddress };
Expand All @@ -449,16 +449,6 @@ private TabPage BuildClientCreatorTab()
var browseButton = new Button { Text = "Browse…", AutoSize = true };
var patchButton = new Button { Text = "Patch", AutoSize = true };

var modeOptions = new (EntryPatchMode Mode, string Label)[]
{
(EntryPatchMode.DefaultToEntwell, "Default to EntwellNostaleClient (gf still works, but exits with EOSError 1400)"),
(EntryPatchMode.OnlyEntwell, "Only EntwellNostaleClient (force Entwell always, gf no longer works, clean exit)"),
(EntryPatchMode.None, "No parameter patch (double-click does nothing; must pass argv manually)"),
};
var modeCombo = new ComboBox { Dock = DockStyle.Fill, DropDownStyle = ComboBoxStyle.DropDownList };
foreach (var (_, label) in modeOptions) modeCombo.Items.Add(label);
modeCombo.SelectedIndex = Array.FindIndex(modeOptions, o => o.Mode == cc.EntryPatchMode);
if (modeCombo.SelectedIndex < 0) modeCombo.SelectedIndex = 0;
var logBox = new TextBox
{
Multiline = true,
Expand All @@ -481,21 +471,17 @@ private TabPage BuildClientCreatorTab()
layout.Controls.Add(outputNameBox, 1, 2);
layout.SetColumnSpan(outputNameBox, 2);

layout.Controls.Add(new Label { Text = "Entry patch:", AutoSize = true, Anchor = AnchorStyles.Left }, 0, 3);
layout.Controls.Add(modeCombo, 1, 3);
layout.SetColumnSpan(modeCombo, 2);

var buttonRow = new FlowLayoutPanel
{
AutoSize = true,
FlowDirection = FlowDirection.RightToLeft,
Dock = DockStyle.Fill,
};
buttonRow.Controls.Add(patchButton);
layout.Controls.Add(buttonRow, 0, 4);
layout.Controls.Add(buttonRow, 0, 3);
layout.SetColumnSpan(buttonRow, 3);

layout.Controls.Add(logBox, 0, 5);
layout.Controls.Add(logBox, 0, 4);
layout.SetColumnSpan(logBox, 3);

browseButton.Click += (_, _) =>
Expand Down Expand Up @@ -524,24 +510,18 @@ void PersistCreator()
newAddressBox.TextChanged += (_, _) => PersistCreator();
exeBox.TextChanged += (_, _) => PersistCreator();
outputNameBox.TextChanged += (_, _) => PersistCreator();
modeCombo.SelectedIndexChanged += (_, _) =>
{
_settings.ClientCreator.EntryPatchMode = modeOptions[modeCombo.SelectedIndex].Mode;
Persist();
};

patchButton.Click += (_, _) =>
{
logBox.Clear();
var mode = modeOptions[modeCombo.SelectedIndex].Mode;
RunPatch(newAddressBox.Text.Trim(), exeBox.Text.Trim(), outputNameBox.Text.Trim(), mode, logBox);
RunPatch(newAddressBox.Text.Trim(), exeBox.Text.Trim(), outputNameBox.Text.Trim(), logBox);
};

page.Controls.Add(layout);
return page;
}

private static void RunPatch(string newAddress, string exePath, string outputName, EntryPatchMode mode, TextBox logBox)
private static void RunPatch(string newAddress, string exePath, string outputName, TextBox logBox)
{
void Log(string msg) => logBox.AppendText(msg + Environment.NewLine);

Expand Down Expand Up @@ -576,19 +556,9 @@ private static void RunPatch(string newAddress, string exePath, string outputNam
Log(ipResult.Log);
if (!ipResult.Success) return;

switch (mode)
{
case EntryPatchMode.DefaultToEntwell:
Log(ClientPatcher.PatchAllowNoArg(bytes).Log);
Log(ClientPatcher.PatchDefaultToEntwell(bytes).Log);
break;
case EntryPatchMode.OnlyEntwell:
Log(ClientPatcher.PatchForceEntwell(bytes).Log);
break;
case EntryPatchMode.None:
Log("No parameter patch: exe is unmodified on the entry path.");
break;
}
var loginResult = ClientPatcher.PatchNoArgToPasswordLogin(bytes);
Log(loginResult.Log);
if (!loginResult.Success) return;

var stubResult = ClientPatcher.PatchImportName(bytes);
Log(stubResult.Log);
Expand Down
4 changes: 0 additions & 4 deletions src/NosCore.DeveloperTools/Models/AppSettings.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using NosCore.ClientTools;

namespace NosCore.DeveloperTools.Models;

public sealed class AppSettings
Expand Down Expand Up @@ -46,8 +44,6 @@ public sealed class ClientCreatorSettings
public string ClientExePath { get; set; } = string.Empty;

public string OutputName { get; set; } = string.Empty;

public EntryPatchMode EntryPatchMode { get; set; } = EntryPatchMode.DefaultToEntwell;
}

public sealed class PacketFilters
Expand Down