From eddff0d637a395512e5e736b8fa584f0e83566fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 08:42:38 +0000 Subject: [PATCH] Open the password login on double-click without breaking gf launches Replace the three Client Creator entry modes with one patch: the argc check's JL now targets the body shared by every login mode instead of the exit path. A launch without arguments reaches it with the Steam and GF flags zero, which is the password login; launches with an argument never take the jump, so gf keeps the auth-code login. The old default mode jumped over Copy(ParamStr(1), 1, 20) after its result pointer had been pushed. Copy pops it (ret 4), so the stack was left 4 bytes off and the program's outer try/finally restored a bogus exception frame, which surfaced as EOSError 1400 on exit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014gSmkUhqTp5rCgTS36b9NU --- README.md | 4 +- src/NosCore.ClientTools/ClientPatcher.cs | 135 +++--------------- src/NosCore.DeveloperTools/Forms/MainForm.cs | 48 ++----- .../Models/AppSettings.cs | 4 - 4 files changed, 32 insertions(+), 159 deletions(-) diff --git a/README.md b/README.md index 5cac571..f41595e 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 ` (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 diff --git a/src/NosCore.ClientTools/ClientPatcher.cs b/src/NosCore.ClientTools/ClientPatcher.cs index ddcd336..ac1475d 100644 --- a/src/NosCore.ClientTools/ClientPatcher.cs +++ b/src/NosCore.ClientTools/ClientPatcher.cs @@ -5,17 +5,9 @@ namespace NosCore.ClientTools; /// /// In-place byte patches against the Gameforge NosTale client /// (NostaleClientX.exe): 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 gf / gftest GF-mode branches untouched. +/// address, sends no-argument launches to the password login and +/// points the Gameforge wrapper import at our stub. /// -public enum EntryPatchMode -{ - DefaultToEntwell, - OnlyEntwell, - None, -} - public static class ClientPatcher { public sealed record PatchResult(bool Success, string Log); @@ -111,126 +103,41 @@ public static PatchResult PatchServerAddress(byte[] bytes, string newAddress) } /// - /// 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 ParamCount - 1 < 0 (JL rel32). + /// 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 (EntwellNostaleClient) login. Launches with + /// an argument never take the jump and keep the original dispatch, so + /// gf <N> 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 calls 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 - /// EOSError 1400 / Invalid window handle on close. - /// - /// Instead, skip the whole compare block: overwrite bytes - /// [pattern_start, pattern_start+34) — pattern + trailing JNZ rel32 — - /// with a short JMP +32 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. - /// - 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."); - } - - /// - /// Force the client into the Entwell standalone body unconditionally, - /// regardless of command-line args. Replaces the early-entry argc - /// JL with an unconditional JMP straight to the Entwell - /// body (located via the pattern - /// + 34). Skips the gf / gftest / EntwellNostaleClient - /// compares entirely — so gf mode stops working — but avoids - /// the timing-sensitive shutdown crash that both NOP-and-JMP variants - /// of trigger when any of those - /// compares runs beforehand. + /// The shared body starts 34 bytes after the EntwellNostaleClient + /// compare anchor (Copy(ParamStr(1), 1, 20), compare, JNZ rel32). /// - 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."); - } - - /// - /// Neutralise the early-entry argc gate: the entry point calls - /// ParamCount(), decrements it, and on a negative result - /// JLs 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 JL rel32 at pattern offset 11 lets - /// execution flow into the arg dispatcher regardless of argc, which - /// combined with drops any - /// unknown arg into the Entwell body. gf / gftest - /// branches remain fully functional. - /// - 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); 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."); } /// diff --git a/src/NosCore.DeveloperTools/Forms/MainForm.cs b/src/NosCore.DeveloperTools/Forms/MainForm.cs index 4ae8004..eaa74b3 100644 --- a/src/NosCore.DeveloperTools/Forms/MainForm.cs +++ b/src/NosCore.DeveloperTools/Forms/MainForm.cs @@ -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 }; @@ -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, @@ -481,10 +471,6 @@ 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, @@ -492,10 +478,10 @@ private TabPage BuildClientCreatorTab() 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 += (_, _) => @@ -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); @@ -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); diff --git a/src/NosCore.DeveloperTools/Models/AppSettings.cs b/src/NosCore.DeveloperTools/Models/AppSettings.cs index 18152f5..ab9eda4 100644 --- a/src/NosCore.DeveloperTools/Models/AppSettings.cs +++ b/src/NosCore.DeveloperTools/Models/AppSettings.cs @@ -1,5 +1,3 @@ -using NosCore.ClientTools; - namespace NosCore.DeveloperTools.Models; public sealed class AppSettings @@ -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