diff --git a/Directory.Packages.props b/Directory.Packages.props
index 42066c4..4acab98 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -6,5 +6,7 @@
+
+
\ No newline at end of file
diff --git a/NosCore.DeveloperTools.sln b/NosCore.DeveloperTools.sln
index 3c77e87..c1c35d4 100644
--- a/NosCore.DeveloperTools.sln
+++ b/NosCore.DeveloperTools.sln
@@ -1,7 +1,7 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
-VisualStudioVersion = 18.5.11709.299 stable
+VisualStudioVersion = 18.5.11709.299
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NosCore.DeveloperTools", "src\NosCore.DeveloperTools\NosCore.DeveloperTools.csproj", "{C6B8F7A1-0001-0001-0001-000000000001}"
EndProject
@@ -11,6 +11,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NosCore.DeveloperTools.GfStub", "src\NosCore.DeveloperTools.GfStub\NosCore.DeveloperTools.GfStub.csproj", "{D3A3DE1B-5292-4AAB-A335-31D2F4885A3C}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NosCore.DeveloperTools.Cli", "src\NosCore.DeveloperTools.Cli\NosCore.DeveloperTools.Cli.csproj", "{73E0BCBB-53D4-4F2D-B794-9F37C7BEF963}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -57,6 +59,18 @@ Global
{D3A3DE1B-5292-4AAB-A335-31D2F4885A3C}.Release|x64.Build.0 = Release|Any CPU
{D3A3DE1B-5292-4AAB-A335-31D2F4885A3C}.Release|x86.ActiveCfg = Release|Any CPU
{D3A3DE1B-5292-4AAB-A335-31D2F4885A3C}.Release|x86.Build.0 = Release|Any CPU
+ {73E0BCBB-53D4-4F2D-B794-9F37C7BEF963}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {73E0BCBB-53D4-4F2D-B794-9F37C7BEF963}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {73E0BCBB-53D4-4F2D-B794-9F37C7BEF963}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {73E0BCBB-53D4-4F2D-B794-9F37C7BEF963}.Debug|x64.Build.0 = Debug|Any CPU
+ {73E0BCBB-53D4-4F2D-B794-9F37C7BEF963}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {73E0BCBB-53D4-4F2D-B794-9F37C7BEF963}.Debug|x86.Build.0 = Debug|Any CPU
+ {73E0BCBB-53D4-4F2D-B794-9F37C7BEF963}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {73E0BCBB-53D4-4F2D-B794-9F37C7BEF963}.Release|Any CPU.Build.0 = Release|Any CPU
+ {73E0BCBB-53D4-4F2D-B794-9F37C7BEF963}.Release|x64.ActiveCfg = Release|Any CPU
+ {73E0BCBB-53D4-4F2D-B794-9F37C7BEF963}.Release|x64.Build.0 = Release|Any CPU
+ {73E0BCBB-53D4-4F2D-B794-9F37C7BEF963}.Release|x86.ActiveCfg = Release|Any CPU
+ {73E0BCBB-53D4-4F2D-B794-9F37C7BEF963}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -65,5 +79,6 @@ Global
{C6B8F7A1-0001-0001-0001-000000000001} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{C6B8F7A1-0002-0001-0001-000000000001} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{D3A3DE1B-5292-4AAB-A335-31D2F4885A3C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {73E0BCBB-53D4-4F2D-B794-9F37C7BEF963} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal
diff --git a/README.md b/README.md
index 5cac571..5c7361e 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,45 @@ This is an independent and unofficial tool for educational use ONLY. Using the P
- Per-direction capture toggles, blacklist/whitelist filters (filtered packets are dropped at intake), Ctrl+A / Ctrl+C / right-click copy with and without tags, Clear button.
- Custom packet inject — send / receive synthetic packets through the client's own send/recv functions using a Delphi register-convention invoker thunk + a hand-rolled Delphi AnsiString.
+### Client Control
+
+Drives the character through the client's own routines rather than through synthetic packets, so the client's local state stays consistent with the server's.
+
+- **Walk to x/y** calls the client's movement routine. The client updates its own position, animates, and builds the outgoing `walk` packet itself — including the checksum byte, which we therefore never have to reproduce. Injecting a `walk` packet instead would move the character server-side only and desync every packet the client originates afterwards.
+- **Where am I?** reads the live character id and coordinates out of the client's player manager, which is the ground truth to assert against when comparing with what the server thinks.
+- **Hook diagnostics** reports which signatures resolved, whether the client thread is ticking, and whether the character is in-world — the first thing to check when a client patch drifts a signature.
+
+All client calls are marshalled onto the client's own thread via a per-frame periodic detour. The client keeps its game state under no synchronisation, so calling a routine straight from the pipe thread races the frame loop. Packet injection now takes the same path, falling back to a direct call only if the periodic signature fails to resolve.
+
+Both movement and state reads need the character to be in-world; the player-manager slot is null until then, and the reply says so rather than guessing.
+
+### Headless driver
+
+`NosCore.DeveloperTools.Cli` runs the same attach and control flow without the GUI, holding the pipe session open and exposing it over `http://127.0.0.1:8787`. It exists so a change can be tested against a real client without anyone clicking through login: launch, attach, drive, assert.
+
+| Endpoint | Purpose |
+| --- | --- |
+| `POST /launch` `{password, hooks?}` | Authenticate against NosCore and start the patched client |
+| `POST /attach` `{pid?}` | Inject the hook and open the pipe |
+| `GET /diag` | Resolved signatures, tick count, in-world and character state |
+| `GET /pos` | Live character id and coordinates |
+| `POST /walk` `{x, y, un0, un1}` | Move through the client's own routine |
+| `POST /click` `{x, y}` | Real mouse input at a point in client coordinates |
+| `GET /screenshot?path=&mode=` | Capture the client window alone |
+| `POST /inject` `{payload, direction}` | Raw packet injection |
+| `GET /packets?since=N` | Captured traffic, cursor-paged |
+| `GET /log?since=N` | Hook status lines |
+
+It must run elevated, because the client inherits that elevation and UIPI discards window calls and injected input arriving from lower integrity.
+
+Two arguments to `/walk` are unnamed in every reference we have. `un0: 0, un1: 1` is what works; the invoker restores `ESP` from `EBP`, so a wrong guess is a no-op rather than a crash.
+
+`hooks` on `/launch` selects which detours to install — `send`, `recv`, `login-recv`, `periodic`, plus `no-bootstrap` to skip the thread-attach stub. Only useful for bisecting a client that misbehaves; leave it unset otherwise.
+
+**Screenshots.** `PrintWindow` first, so occlusion and off-screen area don't matter. Accelerated surfaces sometimes refuse to render into the device context and come back flat; that is detected (ignoring the title bar, which always paints) and retried as a screen grab with the window pulled to the origin.
+
+**Clicking.** The client reads the mouse below the window-message layer, so a posted `WM_LBUTTONDOWN` is never seen — `/click` drives the real cursor. Points are in client coordinates, which is what a screenshot gives you once you subtract the window chrome.
+
### Client Creator
Point it at a copy of `NostaleClientX.exe`, pick a new server address and output filename, hit **Patch**. The output binary gets three edits:
diff --git a/src/NosCore.DeveloperTools.Cli/ClientDriver.cs b/src/NosCore.DeveloperTools.Cli/ClientDriver.cs
new file mode 100644
index 0000000..2c5047a
--- /dev/null
+++ b/src/NosCore.DeveloperTools.Cli/ClientDriver.cs
@@ -0,0 +1,380 @@
+using System.Diagnostics;
+using NosCore.DeveloperTools.Models;
+using NosCore.DeveloperTools.Remote;
+using NosCore.DeveloperTools.Services;
+using NosCore.Shared.Enumerations;
+
+namespace NosCore.DeveloperTools.Cli;
+
+public sealed record LaunchResult(int ProcessId, string AuthCode);
+
+///
+/// Headless equivalent of the GUI's attach and control flow. Owns the one
+/// pipe session the hook allows, buffers everything that arrives on it,
+/// and turns the hook's fire-and-forget reply lines back into awaitable
+/// results so a caller can issue a command and get its answer.
+///
+public sealed class ClientDriver : IAsyncDisposable
+{
+ private const int PacketBufferCap = 20000;
+
+ private readonly RemoteAttachmentService _injection = new();
+ private readonly SettingsService _settings = new();
+ private readonly object _gate = new();
+ private readonly List _packets = new();
+ private readonly List _statuses = new();
+ private readonly List _waiters = new();
+
+ private Process? _client;
+
+ private sealed record Waiter(string Prefix, TaskCompletionSource Completion);
+
+ public ClientDriver()
+ {
+ _injection.PacketCaptured += (_, args) =>
+ {
+ lock (_gate)
+ {
+ _packets.Add(args.Packet);
+ if (_packets.Count > PacketBufferCap)
+ {
+ _packets.RemoveRange(0, _packets.Count - PacketBufferCap);
+ }
+ }
+ };
+ _injection.StatusChanged += (_, message) => Note(message);
+ _injection.ControlReplyReceived += (_, line) => Resolve(line);
+ }
+
+ public bool IsAttached => _injection.IsAttached;
+
+ public int? AttachedProcessId => _injection.AttachedProcessId;
+
+ public int? ClientProcessId => _client is { HasExited: false } ? _client.Id : null;
+
+ public IReadOnlyList Statuses(int since)
+ {
+ lock (_gate)
+ {
+ return since >= _statuses.Count
+ ? Array.Empty()
+ : _statuses.Skip(since).ToArray();
+ }
+ }
+
+ public (IReadOnlyList Packets, int Next) Packets(int since, string? contains)
+ {
+ lock (_gate)
+ {
+ var next = _packets.Count;
+ IEnumerable slice = since >= _packets.Count
+ ? Array.Empty()
+ : _packets.Skip(since);
+
+ if (!string.IsNullOrEmpty(contains))
+ {
+ slice = slice.Where(p => p.Raw.Contains(contains, StringComparison.OrdinalIgnoreCase));
+ }
+
+ return (slice.ToArray(), next);
+ }
+ }
+
+ ///
+ /// The current end cursor of the packet buffer — pass it to
+ /// so a wait only considers packets
+ /// that arrive after the action that should provoke them.
+ ///
+ public int PacketCursor { get { lock (_gate) { return _packets.Count; } } }
+
+ ///
+ /// Block until a captured packet at or after
+ /// matches (regex over the raw wire text),
+ /// or the timeout elapses. This is the assertion primitive a test loop
+ /// needs: inject/act, then wait for the client's observable reaction
+ /// instead of sleeping. Returns the matching packet, or null on timeout.
+ ///
+ public async Task WaitForPacketAsync(string pattern, int since, TimeSpan timeout)
+ {
+ var rx = new System.Text.RegularExpressions.Regex(pattern,
+ System.Text.RegularExpressions.RegexOptions.IgnoreCase);
+ var deadline = DateTime.UtcNow + timeout;
+ var cursor = since;
+ while (DateTime.UtcNow < deadline)
+ {
+ LoggedPacket? hit = null;
+ lock (_gate)
+ {
+ for (; cursor < _packets.Count; cursor++)
+ {
+ if (rx.IsMatch(_packets[cursor].Raw)) { hit = _packets[cursor]; cursor++; break; }
+ }
+ }
+ if (hit is not null) return hit;
+ await Task.Delay(50);
+ }
+ return null;
+ }
+
+ ///
+ /// Authenticate against NosCore and start the patched client. Values
+ /// left null fall back to whatever the GUI last saved, so the usual
+ /// call carries only a password.
+ ///
+ public async Task LaunchAsync(
+ string? serverUrl, string username, string password, string? clientExe, string? gfLang, string? locale, string? hooks,
+ CancellationToken ct)
+ {
+ var saved = _settings.Load().Auth;
+ serverUrl ??= saved.ServerUrl;
+ clientExe ??= saved.ClientExePath;
+ gfLang ??= saved.GfLang;
+ locale ??= saved.Locale;
+
+ if (string.IsNullOrWhiteSpace(clientExe) || !File.Exists(clientExe))
+ {
+ throw new FileNotFoundException($"Client executable not found: '{clientExe}'.");
+ }
+
+ using var auth = new NosCoreAuthClient(serverUrl, message => Note($"auth: {message}"));
+ var result = await auth.AuthenticateAsync(username, password, gfLang, locale, null, ct);
+ Note($"auth ok, code={result.AuthCode}");
+
+ var region = Enum.TryParse(gfLang, true, out var parsed) ? parsed : default;
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = clientExe,
+ // The client parses the second token as the numeric RegionType
+ // ordinal, not the language code.
+ Arguments = $"gf {(int)region}",
+ WorkingDirectory = Path.GetDirectoryName(clientExe) ?? Environment.CurrentDirectory,
+ UseShellExecute = false,
+ };
+ startInfo.EnvironmentVariables["_NC_AUTH_CODE"] = result.AuthCode;
+ if (!string.IsNullOrWhiteSpace(hooks))
+ {
+ // "no-bootstrap" is not a hook; it turns the thread-attach stub
+ // off so its effect can be measured against the same detours.
+ var requested = hooks.Split(',', StringSplitOptions.RemoveEmptyEntries)
+ .Select(h => h.Trim())
+ .ToList();
+
+ if (requested.RemoveAll(h => h.Equals("no-bootstrap", StringComparison.OrdinalIgnoreCase)) > 0)
+ {
+ startInfo.EnvironmentVariables["_NC_BOOTSTRAP"] = "0";
+ Note("runtime bootstrap disabled");
+ }
+
+ if (requested.Count > 0)
+ {
+ startInfo.EnvironmentVariables["_NC_HOOKS"] = string.Join(',', requested);
+ Note($"hooks limited to: {string.Join(',', requested)}");
+ }
+ }
+
+ _client = Process.Start(startInfo) ?? throw new InvalidOperationException("Client failed to start.");
+ Note($"client started pid={_client.Id}");
+ return new LaunchResult(_client.Id, result.AuthCode);
+ }
+
+ ///
+ /// Inject the hook and open the pipe. Waits for the client's window
+ /// first — injecting before the process has finished initialising can
+ /// leave the remote LoadLibrary thread hanging.
+ ///
+ public async Task AttachAsync(int? processId, CancellationToken ct)
+ {
+ var pid = processId ?? await WaitForClientAsync(ct);
+ await _injection.AttachAsync(pid, ct);
+ }
+
+ public async Task DiagnosticsAsync(TimeSpan timeout)
+ {
+ var waiter = Expect("DIAG");
+ if (!_injection.RequestDiagnostics()) throw new InvalidOperationException("Not attached.");
+ return await Await(waiter, timeout, "DIAG");
+ }
+
+ public async Task PositionAsync(TimeSpan timeout)
+ {
+ var waiter = Expect("POS");
+ if (!_injection.RequestPosition()) throw new InvalidOperationException("Not attached.");
+ return await Await(waiter, timeout, "POS");
+ }
+
+ public async Task WalkAsync(ushort x, ushort y, int? un0, int? un1, TimeSpan timeout)
+ {
+ var waiter = Expect("WALKRESULT");
+ if (!_injection.Walk(x, y, un0, un1)) throw new InvalidOperationException("Not attached.");
+ return await Await(waiter, timeout, "WALKRESULT");
+ }
+
+ public async Task ScanPlayerAsync(TimeSpan timeout)
+ {
+ var waiter = Expect("SCANPLAYER");
+ if (!_injection.RequestPlayerScan()) throw new InvalidOperationException("Not attached.");
+ return await Await(waiter, timeout, "SCANPLAYER");
+ }
+
+ public async Task PeekAsync(long address, int length, TimeSpan timeout)
+ {
+ var waiter = Expect("PEEK");
+ if (!_injection.RequestPeek(address, length)) throw new InvalidOperationException("Not attached.");
+ return await Await(waiter, timeout, "PEEK");
+ }
+
+ public async Task WindowAsync(string mode, TimeSpan timeout)
+ {
+ var waiter = Expect("WINDOW");
+ if (!_injection.RequestWindow(mode)) throw new InvalidOperationException("Not attached.");
+ return await Await(waiter, timeout, "WINDOW");
+ }
+
+ ///
+ /// The client's window handle, asked of the hook rather than taken
+ /// from Process.MainWindowHandle — the client owns several top-level
+ /// windows and MainWindowHandle picks a zero-size one, not the game.
+ ///
+ public async Task GetWindowHandleAsync(TimeSpan timeout)
+ {
+ // Looking at and clicking the client should not require the hook —
+ // it is also how we check whether the hook is what broke it.
+ if (!IsAttached && _client is { HasExited: false })
+ {
+ var direct = ProcessWindows.FindGameWindow(_client.Id);
+ if (direct != IntPtr.Zero) return direct;
+ }
+
+ var reply = await WindowAsync("describe", timeout);
+ var marker = reply.IndexOf("hwnd=0x", StringComparison.Ordinal);
+ if (marker < 0) throw new InvalidOperationException($"No client window: {reply}");
+
+ var start = marker + "hwnd=0x".Length;
+ var end = reply.IndexOf(' ', start);
+ var hex = end < 0 ? reply[start..] : reply[start..end];
+ return (IntPtr)Convert.ToInt64(hex, 16);
+ }
+
+ public async Task<(string Path, string Mode)> ScreenshotAsync(string path, string? mode, TimeSpan timeout)
+ {
+ var window = await GetWindowHandleAsync(timeout);
+
+ if (mode is "screen")
+ {
+ return (Screenshot.Capture(window, Screenshot.Mode.Screen, path), "screen");
+ }
+
+ Screenshot.Capture(window, Screenshot.Mode.Window, path);
+ if (mode is "window" || !Screenshot.LooksBlank(path))
+ {
+ return (path, "window");
+ }
+
+ // Accelerated surfaces often refuse to render into the DC and come
+ // back as a flat rectangle; fall back rather than return a blank.
+ return (Screenshot.Capture(window, Screenshot.Mode.Screen, path), "screen-fallback");
+ }
+
+ ///
+ /// Click at a point in client coordinates. Real system input by
+ /// default — the client does not observe posted window messages, so
+ /// the "post" mode is kept only for controls that do.
+ ///
+ public async Task ClickAsync(int x, int y, string? mode, TimeSpan timeout)
+ {
+ if (mode == "post")
+ {
+ var waiter = Expect("CLICK");
+ if (!_injection.RequestClick(x, y)) throw new InvalidOperationException("Not attached.");
+ return await Await(waiter, timeout, "CLICK");
+ }
+
+ var window = await GetWindowHandleAsync(timeout);
+ return Input.Click(window, x, y);
+ }
+
+ public async Task ConnectAsync(string host, int port, TimeSpan timeout)
+ {
+ var waiter = Expect("CONNECTRESULT");
+ if (!_injection.RequestConnect(host, port)) throw new InvalidOperationException("Not attached.");
+ return await Await(waiter, timeout, "CONNECTRESULT");
+ }
+
+ public bool Inject(PacketDirection direction, PacketConnection connection, string payload) =>
+ _injection.InjectPacket(direction, connection, payload);
+
+ public async ValueTask DisposeAsync()
+ {
+ await _injection.DetachAsync();
+ _injection.Dispose();
+ }
+
+ private async Task WaitForClientAsync(CancellationToken ct)
+ {
+ if (_client is null) throw new InvalidOperationException("No client launched; pass a process id.");
+
+ var deadline = DateTime.UtcNow.AddSeconds(90);
+ while (DateTime.UtcNow < deadline)
+ {
+ _client.Refresh();
+ if (_client.HasExited) throw new InvalidOperationException("Client exited before it could be attached.");
+
+ if (ProcessWindows.FindGameWindow(_client.Id) != IntPtr.Zero)
+ {
+ // The window appears a moment before the client has finished
+ // wiring itself up, and injecting into that gap kills it.
+ await Task.Delay(2000, ct);
+ Note("game window up, attaching");
+ return _client.Id;
+ }
+
+ await Task.Delay(250, ct);
+ }
+
+ throw new TimeoutException("Client never opened its game window.");
+ }
+
+ private Waiter Expect(string prefix)
+ {
+ var waiter = new Waiter(
+ prefix, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously));
+ lock (_gate)
+ {
+ _waiters.Add(waiter);
+ }
+
+ return waiter;
+ }
+
+ private static async Task Await(Waiter waiter, TimeSpan timeout, string what)
+ {
+ var completed = await Task.WhenAny(waiter.Completion.Task, Task.Delay(timeout));
+ if (completed != waiter.Completion.Task)
+ {
+ throw new TimeoutException($"No {what} reply within {timeout.TotalSeconds:0.#}s.");
+ }
+
+ return await waiter.Completion.Task;
+ }
+
+ private void Resolve(string line)
+ {
+ Waiter? match;
+ lock (_gate)
+ {
+ _statuses.Add($"{DateTime.Now:HH:mm:ss} {line}");
+ match = _waiters.FirstOrDefault(w => line.StartsWith(w.Prefix, StringComparison.Ordinal));
+ if (match is not null) _waiters.Remove(match);
+ }
+
+ match?.Completion.TrySetResult(line);
+ }
+
+ private void Note(string message)
+ {
+ lock (_gate)
+ {
+ _statuses.Add($"{DateTime.Now:HH:mm:ss} {message}");
+ }
+ }
+}
diff --git a/src/NosCore.DeveloperTools.Cli/ControlServer.cs b/src/NosCore.DeveloperTools.Cli/ControlServer.cs
new file mode 100644
index 0000000..226f911
--- /dev/null
+++ b/src/NosCore.DeveloperTools.Cli/ControlServer.cs
@@ -0,0 +1,235 @@
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using NosCore.DeveloperTools.Models;
+
+namespace NosCore.DeveloperTools.Cli;
+
+///
+/// Localhost HTTP surface over . Each command is
+/// a separate request so a caller can drive the client one step at a time
+/// while the process holds the pipe session open between calls.
+///
+public sealed class ControlServer
+{
+ private static readonly JsonSerializerOptions Json = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = true,
+ };
+
+ private readonly ClientDriver _driver;
+ private readonly HttpListener _listener = new();
+ private readonly CancellationTokenSource _shutdown = new();
+
+ public ControlServer(ClientDriver driver, int port)
+ {
+ _driver = driver;
+ _listener.Prefixes.Add($"http://127.0.0.1:{port}/");
+ }
+
+ public CancellationToken Shutdown => _shutdown.Token;
+
+ public async Task RunAsync()
+ {
+ _listener.Start();
+ while (!_shutdown.IsCancellationRequested)
+ {
+ HttpListenerContext context;
+ try
+ {
+ context = await _listener.GetContextAsync();
+ }
+ catch when (_shutdown.IsCancellationRequested)
+ {
+ break;
+ }
+
+ _ = Task.Run(() => HandleAsync(context));
+ }
+
+ _listener.Close();
+ }
+
+ private async Task HandleAsync(HttpListenerContext context)
+ {
+ try
+ {
+ var path = context.Request.Url?.AbsolutePath.TrimEnd('/').ToLowerInvariant() ?? "/";
+ var query = context.Request.QueryString;
+ var body = await ReadBodyAsync(context.Request);
+
+ object payload = path switch
+ {
+ "" or "/" or "/status" => new
+ {
+ attached = _driver.IsAttached,
+ attachedPid = _driver.AttachedProcessId,
+ clientPid = _driver.ClientProcessId,
+ },
+ "/launch" => await _driver.LaunchAsync(
+ Str(body, "serverUrl"), Str(body, "username") ?? "admin", Str(body, "password") ?? "test",
+ Str(body, "clientExe"), Str(body, "gfLang"), Str(body, "locale"), Str(body, "hooks"), CancellationToken.None),
+ "/attach" => await AttachAsync(body),
+ "/diag" => new { reply = await _driver.DiagnosticsAsync(Timeout(query)) },
+ "/pos" => Position(await _driver.PositionAsync(Timeout(query))),
+ "/walk" => new { reply = await WalkAsync(body, Timeout(query)) },
+ "/window" => new { reply = await _driver.WindowAsync(query["mode"] ?? "show", Timeout(query)) },
+ "/click" => new
+ {
+ reply = await _driver.ClickAsync(
+ Int(body, "x") ?? throw new InvalidOperationException("click requires 'x'."),
+ Int(body, "y") ?? throw new InvalidOperationException("click requires 'y'."),
+ Str(body, "mode") ?? query["mode"],
+ Timeout(query)),
+ },
+ "/connect" => new
+ {
+ reply = await _driver.ConnectAsync(
+ Str(body, "host") ?? "127.0.0.1", Int(body, "port") ?? 1337, Timeout(query)),
+ },
+ "/screenshot" => await ScreenshotAsync(query),
+ "/scanplayer" => new { reply = await _driver.ScanPlayerAsync(Timeout(query)) },
+ "/peek" => new
+ {
+ reply = await _driver.PeekAsync(
+ Convert.ToInt64(query["addr"] ?? throw new InvalidOperationException("peek requires 'addr'."), 16),
+ Int(query, "len") ?? 64,
+ Timeout(query)),
+ },
+ "/inject" => new { sent = InjectPacket(body) },
+ "/packets" => Packets(query),
+ "/log" => new { lines = _driver.Statuses(Int(query, "since") ?? 0) },
+ "/quit" => Quit(),
+ _ => throw new InvalidOperationException($"Unknown endpoint '{path}'."),
+ };
+
+ await WriteAsync(context, HttpStatusCode.OK, payload);
+ }
+ catch (Exception ex)
+ {
+ await WriteAsync(context, HttpStatusCode.BadRequest, new { error = ex.Message, type = ex.GetType().Name });
+ }
+ }
+
+ private async Task ScreenshotAsync(System.Collections.Specialized.NameValueCollection query)
+ {
+ var path = query["path"] ?? Path.Combine(Path.GetTempPath(), "noscore-client.png");
+ var (saved, mode) = await _driver.ScreenshotAsync(path, query["mode"], Timeout(query));
+ var info = new FileInfo(saved);
+ return new { path = saved, mode, bytes = info.Length };
+ }
+
+ private async Task AttachAsync(JsonElement? body)
+ {
+ await _driver.AttachAsync(Int(body, "pid"), CancellationToken.None);
+ return new { attached = _driver.IsAttached, attachedPid = _driver.AttachedProcessId };
+ }
+
+ private async Task WalkAsync(JsonElement? body, TimeSpan timeout)
+ {
+ var x = Int(body, "x") ?? throw new InvalidOperationException("walk requires 'x'.");
+ var y = Int(body, "y") ?? throw new InvalidOperationException("walk requires 'y'.");
+ return await _driver.WalkAsync((ushort)x, (ushort)y, Int(body, "un0"), Int(body, "un1"), timeout);
+ }
+
+ private bool InjectPacket(JsonElement? body)
+ {
+ var payload = Str(body, "payload") ?? throw new InvalidOperationException("inject requires 'payload'.");
+ var direction = (Str(body, "direction") ?? "send").StartsWith('r')
+ ? PacketDirection.Receive
+ : PacketDirection.Send;
+ var connection = (Str(body, "connection") ?? "world").StartsWith('l')
+ ? PacketConnection.Login
+ : PacketConnection.World;
+ return _driver.Inject(direction, connection, payload);
+ }
+
+ private object Packets(System.Collections.Specialized.NameValueCollection query)
+ {
+ var (packets, next) = _driver.Packets(Int(query, "since") ?? 0, query["contains"]);
+ return new
+ {
+ next,
+ packets = packets.Select(p => new
+ {
+ time = p.Timestamp.ToString("HH:mm:ss.fff"),
+ connection = p.Connection.ToString(),
+ source = p.Direction == PacketDirection.Send ? "client" : "server",
+ raw = p.Raw,
+ }),
+ };
+ }
+
+ ///
+ /// Splits "POS id x y" out into fields so a caller can assert on
+ /// coordinates without reparsing the wire line.
+ ///
+ private static object Position(string reply)
+ {
+ var parts = reply.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length != 4 || !int.TryParse(parts[1], out var id)
+ || !int.TryParse(parts[2], out var x) || !int.TryParse(parts[3], out var y))
+ {
+ return new { reply, available = false };
+ }
+
+ return new { reply, available = true, entityId = id, x, y };
+ }
+
+ private object Quit()
+ {
+ _shutdown.Cancel();
+ // Cancelling alone leaves the accept loop parked in GetContextAsync;
+ // stopping the listener is what actually unblocks it.
+ _ = Task.Run(async () =>
+ {
+ await Task.Delay(250);
+ _listener.Stop();
+ });
+ return new { stopping = true };
+ }
+
+ private static TimeSpan Timeout(System.Collections.Specialized.NameValueCollection query) =>
+ TimeSpan.FromMilliseconds(Int(query, "timeoutMs") ?? 8000);
+
+ private static async Task ReadBodyAsync(HttpListenerRequest request)
+ {
+ if (!request.HasEntityBody) return null;
+ using var reader = new StreamReader(request.InputStream, Encoding.UTF8);
+ var text = await reader.ReadToEndAsync();
+ if (string.IsNullOrWhiteSpace(text)) return null;
+ return JsonDocument.Parse(text).RootElement.Clone();
+ }
+
+ private static string? Str(JsonElement? body, string name) =>
+ body is { } b && b.ValueKind == JsonValueKind.Object && b.TryGetProperty(name, out var value)
+ && value.ValueKind == JsonValueKind.String
+ ? value.GetString()
+ : null;
+
+ private static int? Int(JsonElement? body, string name)
+ {
+ if (body is not { } b || b.ValueKind != JsonValueKind.Object) return null;
+ if (!b.TryGetProperty(name, out var value)) return null;
+ return value.ValueKind switch
+ {
+ JsonValueKind.Number => value.GetInt32(),
+ JsonValueKind.String when int.TryParse(value.GetString(), out var parsed) => parsed,
+ _ => null,
+ };
+ }
+
+ private static int? Int(System.Collections.Specialized.NameValueCollection query, string name) =>
+ int.TryParse(query[name], out var value) ? value : null;
+
+ private static async Task WriteAsync(HttpListenerContext context, HttpStatusCode status, object payload)
+ {
+ var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload, Json));
+ context.Response.StatusCode = (int)status;
+ context.Response.ContentType = "application/json";
+ context.Response.ContentLength64 = bytes.Length;
+ await context.Response.OutputStream.WriteAsync(bytes);
+ context.Response.Close();
+ }
+}
diff --git a/src/NosCore.DeveloperTools.Cli/Input.cs b/src/NosCore.DeveloperTools.Cli/Input.cs
new file mode 100644
index 0000000..d30e487
--- /dev/null
+++ b/src/NosCore.DeveloperTools.Cli/Input.cs
@@ -0,0 +1,121 @@
+using System.Runtime.InteropServices;
+
+namespace NosCore.DeveloperTools.Cli;
+
+///
+/// Real mouse input for the client.
+///
+/// Posting WM_LBUTTONDOWN to the window does nothing here: the client
+/// reads the mouse below the window-message layer, so a synthetic
+/// message is simply never seen. Driving the actual system cursor is,
+/// as far as the game is concerned, indistinguishable from a person.
+///
+/// This has to run at the client's integrity level, which the elevated
+/// driver satisfies; from a normal process the injected input is
+/// discarded by UIPI.
+///
+internal static class Input
+{
+ private const uint MouseEventLeftDown = 0x0002;
+ private const uint MouseEventLeftUp = 0x0004;
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct Point
+ {
+ public int X;
+ public int Y;
+ }
+
+ [DllImport("user32.dll")]
+ private static extern bool ClientToScreen(IntPtr window, ref Point point);
+
+ [DllImport("user32.dll")]
+ private static extern bool SetForegroundWindow(IntPtr window);
+
+ [DllImport("user32.dll")]
+ private static extern bool SetCursorPos(int x, int y);
+
+ [DllImport("user32.dll")]
+ private static extern bool GetCursorPos(out Point point);
+
+ [DllImport("user32.dll")]
+ private static extern void mouse_event(uint flags, int dx, int dy, uint data, IntPtr extraInfo);
+
+ [DllImport("user32.dll")]
+ private static extern bool SetWindowPos(IntPtr window, IntPtr after, int x, int y, int cx, int cy, uint flags);
+
+ private const uint SwpNoSize = 0x0001;
+ private const uint SwpNoMove = 0x0002;
+ private const uint SwpNoZOrder = 0x0004;
+ private const uint SwpShowWindow = 0x0040;
+
+ private static readonly IntPtr HwndTopmost = -1;
+ private static readonly IntPtr HwndNoTopmost = -2;
+
+ public static string Click(IntPtr window, int clientX, int clientY, bool restoreCursor = true)
+ {
+ if (window == IntPtr.Zero) throw new InvalidOperationException("No client window.");
+
+ var point = new Point { X = clientX, Y = clientY };
+ if (!ClientToScreen(window, ref point))
+ {
+ throw new InvalidOperationException("ClientToScreen failed.");
+ }
+
+ // The game window is taller than the desktop and sits at a positive
+ // offset, so lower controls map to screen coordinates past the
+ // bottom edge. SetCursorPos clamps to the desktop, which silently
+ // puts the click on whatever else is there — so pull the window to
+ // the origin and recompute rather than clicking the wrong thing.
+ var screen = System.Windows.Forms.SystemInformation.VirtualScreen;
+ if (!screen.Contains(point.X, point.Y))
+ {
+ SetWindowPos(window, IntPtr.Zero, 0, 0, 0, 0, SwpNoSize | SwpNoZOrder);
+ Thread.Sleep(250);
+
+ point = new Point { X = clientX, Y = clientY };
+ if (!ClientToScreen(window, ref point))
+ {
+ throw new InvalidOperationException("ClientToScreen failed after move.");
+ }
+
+ if (!screen.Contains(point.X, point.Y))
+ {
+ throw new InvalidOperationException(
+ $"Client point {clientX},{clientY} is off-screen at {point.X},{point.Y} even at the origin.");
+ }
+ }
+
+ GetCursorPos(out var previous);
+
+ // SetForegroundWindow alone is not enough: Windows refuses
+ // foreground changes requested by a process that does not own it,
+ // so the client stayed behind whatever was maximised and the click
+ // — which goes to whatever is topmost at that point — landed on
+ // the wrong application entirely. Forcing topmost is not subject
+ // to that restriction.
+ SetWindowPos(window, HwndTopmost, 0, 0, 0, 0, SwpNoMove | SwpNoSize | SwpShowWindow);
+ SetForegroundWindow(window);
+ Thread.Sleep(200);
+ SetCursorPos(point.X, point.Y);
+ // The client tracks hover state, and a click that arrives in the
+ // same tick as the move can land before the control is highlighted.
+ Thread.Sleep(150);
+
+ mouse_event(MouseEventLeftDown, 0, 0, 0, IntPtr.Zero);
+ Thread.Sleep(80);
+ mouse_event(MouseEventLeftUp, 0, 0, 0, IntPtr.Zero);
+
+ // Drop back out of topmost so the client does not sit permanently
+ // over everything else on the desktop.
+ SetWindowPos(window, HwndNoTopmost, 0, 0, 0, 0, SwpNoMove | SwpNoSize);
+
+ if (restoreCursor)
+ {
+ Thread.Sleep(150);
+ SetCursorPos(previous.X, previous.Y);
+ }
+
+ return $"clicked client {clientX},{clientY} (screen {point.X},{point.Y})";
+ }
+}
diff --git a/src/NosCore.DeveloperTools.Cli/NosCore.DeveloperTools.Cli.csproj b/src/NosCore.DeveloperTools.Cli/NosCore.DeveloperTools.Cli.csproj
new file mode 100644
index 0000000..f7e5050
--- /dev/null
+++ b/src/NosCore.DeveloperTools.Cli/NosCore.DeveloperTools.Cli.csproj
@@ -0,0 +1,32 @@
+
+
+
+ Exe
+ net10.0-windows
+ true
+ NosCore.DeveloperTools.Cli
+ NosCore.DeveloperTools.Cli
+ app.manifest
+ Headless driver for the NosTale client: launch, attach, control over HTTP.
+
+
+
+
+ x86
+
+
+
+
+ true
+ win-x86
+ embedded
+
+
+
+
+
+
+
+
+
diff --git a/src/NosCore.DeveloperTools.Cli/NosTaleTools.cs b/src/NosCore.DeveloperTools.Cli/NosTaleTools.cs
new file mode 100644
index 0000000..49c9598
--- /dev/null
+++ b/src/NosCore.DeveloperTools.Cli/NosTaleTools.cs
@@ -0,0 +1,112 @@
+using System.ComponentModel;
+using System.Text.Json;
+using ModelContextProtocol.Server;
+using NosCore.DeveloperTools.Models;
+
+namespace NosCore.DeveloperTools.Cli;
+
+///
+/// MCP tools for driving the NosTale client, backed by a single shared
+/// (one process, one pipe session, state kept
+/// across calls). Every tool returns a compact JSON string so the model
+/// gets structured results.
+///
+[McpServerToolType]
+public static class NosTaleTools
+{
+ private static readonly JsonSerializerOptions Json = new() { WriteIndented = false };
+ private static string J(object o) => JsonSerializer.Serialize(o, Json);
+ private static TimeSpan Secs(double s) => TimeSpan.FromSeconds(s <= 0 ? 10 : s);
+
+ [McpServerTool(Name = "nostale_launch")]
+ [Description("Authenticate against the local NosCore server and start the patched NosTale client. Only the password is usually needed; username/serverUrl/clientExe fall back to the GUI's saved settings.")]
+ public static async Task Launch(
+ ClientDriver driver,
+ [Description("Account password")] string password,
+ [Description("Account username (default: saved 'admin')")] string? username = null,
+ [Description("NosCore auth server URL, e.g. https://localhost:7001 (default: saved)")] string? serverUrl = null,
+ [Description("Path to the patched client exe (default: saved)")] string? clientExe = null)
+ {
+ var r = await driver.LaunchAsync(serverUrl, username ?? "admin", password, clientExe, null, null, null, CancellationToken.None);
+ return J(new { r.ProcessId, r.AuthCode });
+ }
+
+ [McpServerTool(Name = "nostale_attach")]
+ [Description("Inject the capture/control hook into the running client and open its pipe. Waits for the game window first. Pass a pid to attach to a specific client, otherwise attaches to the one just launched.")]
+ public static async Task Attach(ClientDriver driver, [Description("Target process id (optional)")] int? pid = null)
+ {
+ await driver.AttachAsync(pid, CancellationToken.None);
+ return J(new { attached = driver.IsAttached, driver.AttachedProcessId });
+ }
+
+ [McpServerTool(Name = "nostale_status")]
+ [Description("Report hook diagnostics: which signatures resolved, tick count, in-world state, whether a character is loaded, and the connect/manager/walk addresses.")]
+ public static async Task Status(ClientDriver driver)
+ => J(new { attached = driver.IsAttached, clientPid = driver.ClientProcessId, diag = await driver.DiagnosticsAsync(Secs(10)) });
+
+ [McpServerTool(Name = "nostale_position")]
+ [Description("Read the live character id and map coordinates (x,y). Requires a character loaded in-world.")]
+ public static async Task Position(ClientDriver driver)
+ => J(new { reply = await driver.PositionAsync(Secs(10)) });
+
+ [McpServerTool(Name = "nostale_walk")]
+ [Description("Move the character to map cell (x,y) by calling the client's own walk routine, so the client updates its position and emits the walk packet itself (checksum included). Requires a character loaded in-world.")]
+ public static async Task Walk(ClientDriver driver, [Description("target X")] int x, [Description("target Y")] int y)
+ => J(new { reply = await driver.WalkAsync((ushort)x, (ushort)y, 0, 1, Secs(15)) });
+
+ [McpServerTool(Name = "nostale_click")]
+ [Description("Send a real left-click at a point in client-window coordinates (drives the actual cursor; the client ignores posted messages). Use with a screenshot to locate UI elements — e.g. to click through server/channel/character selection.")]
+ public static async Task Click(ClientDriver driver, [Description("client X")] int x, [Description("client Y")] int y)
+ => J(new { reply = await driver.ClickAsync(x, y, null, Secs(15)) });
+
+ [McpServerTool(Name = "nostale_screenshot")]
+ [Description("Capture just the client window to a PNG and return its path (Read the path to view it). Uses PrintWindow, falling back to a screen grab if the accelerated surface renders blank.")]
+ public static async Task Screenshot(ClientDriver driver, [Description("Output PNG path (optional)")] string? path = null)
+ {
+ var outPath = path ?? Path.Combine(Path.GetTempPath(), $"nostale-{DateTime.Now:HHmmss}.png");
+ var (saved, mode) = await driver.ScreenshotAsync(outPath, null, Secs(30));
+ return J(new { path = saved, mode });
+ }
+
+ [McpServerTool(Name = "nostale_inject")]
+ [Description("Send a raw packet through the client's own send/recv functions. direction: 'send' (client->server) or 'recv' (server->client, injected into the client). connection: 'world' (default) or 'login'. Useful for driving GM commands, e.g. inject send '$Teleport 1 100 100'.")]
+ public static string Inject(
+ ClientDriver driver,
+ [Description("Raw packet text")] string payload,
+ [Description("'send' or 'recv'")] string direction = "send",
+ [Description("'world' or 'login'")] string connection = "world")
+ {
+ var dir = direction.StartsWith('r') ? PacketDirection.Receive : PacketDirection.Send;
+ var conn = connection.StartsWith('l') ? PacketConnection.Login : PacketConnection.World;
+ return J(new { sent = driver.Inject(dir, conn, payload) });
+ }
+
+ [McpServerTool(Name = "nostale_packets")]
+ [Description("Return captured packets since a cursor (0 for all), optionally filtered by a substring. Returns the packets and the next cursor to poll from.")]
+ public static string Packets(
+ ClientDriver driver,
+ [Description("Cursor to read from (0 = beginning)")] int since = 0,
+ [Description("Only packets whose raw text contains this (optional)")] string? contains = null)
+ {
+ var (packets, next) = driver.Packets(since, contains);
+ return J(new { next, packets = packets.Select(p => new { time = p.Timestamp.ToString("HH:mm:ss.fff"), connection = p.Connection.ToString(), source = p.Direction == PacketDirection.Send ? "client" : "server", raw = p.Raw }) });
+ }
+
+ [McpServerTool(Name = "nostale_packet_cursor")]
+ [Description("Return the current end cursor of the packet buffer. Capture this BEFORE an action, then pass it to nostale_wait_for_packet so the wait only sees packets provoked by the action.")]
+ public static string PacketCursor(ClientDriver driver) => J(new { cursor = driver.PacketCursor });
+
+ [McpServerTool(Name = "nostale_wait_for_packet")]
+ [Description("Block until a captured packet at/after 'since' matches the regex 'pattern' (over the raw wire text), or the timeout elapses. The assertion primitive for tests: act, then wait for the client's observable reaction instead of sleeping. Returns the matching packet or {matched:false} on timeout.")]
+ public static async Task WaitForPacket(
+ ClientDriver driver,
+ [Description("Regex to match against raw packet text")] string pattern,
+ [Description("Cursor from nostale_packet_cursor (0 = from beginning)")] int since = 0,
+ [Description("Timeout in seconds (default 10)")] double timeoutSeconds = 10)
+ {
+ var p = await driver.WaitForPacketAsync(pattern, since, Secs(timeoutSeconds));
+ return p is null
+ ? J(new { matched = false })
+ : J(new { matched = true, time = p.Timestamp.ToString("HH:mm:ss.fff"), source = p.Direction == PacketDirection.Send ? "client" : "server", raw = p.Raw });
+ }
+}
diff --git a/src/NosCore.DeveloperTools.Cli/ProcessWindows.cs b/src/NosCore.DeveloperTools.Cli/ProcessWindows.cs
new file mode 100644
index 0000000..55394b8
--- /dev/null
+++ b/src/NosCore.DeveloperTools.Cli/ProcessWindows.cs
@@ -0,0 +1,78 @@
+using System.Runtime.InteropServices;
+using System.Text;
+
+namespace NosCore.DeveloperTools.Cli;
+
+///
+/// Finds the client's actual game window from outside the process.
+///
+/// Process.MainWindowHandle is not usable here: the client owns several
+/// top-level windows and the one it reports is a zero-size helper that
+/// exists almost immediately at startup. Treating that as "the client is
+/// ready" meant injecting into a process that had barely initialised,
+/// which killed it.
+///
+internal static class ProcessWindows
+{
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ private static extern IntPtr FindWindowExW(IntPtr parent, IntPtr after, string? className, string? windowName);
+
+ [DllImport("user32.dll")]
+ private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId);
+
+ [DllImport("user32.dll")]
+ private static extern bool GetWindowRect(IntPtr window, out Rect rect);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ private static extern int GetClassNameW(IntPtr window, StringBuilder className, int count);
+
+ private const string NosTaleWindowClass = "TNosTaleMainF";
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct Rect
+ {
+ public int Left;
+ public int Top;
+ public int Right;
+ public int Bottom;
+ }
+
+ ///
+ /// The process's game window, matched by class, or zero while it has
+ /// not created one yet. Size is only a fallback — the caption is never
+ /// read, because that sends WM_GETTEXT and blocks on a busy client.
+ ///
+ public static IntPtr FindGameWindow(int processId, int minWidth = 640, int minHeight = 480)
+ {
+ var fallback = IntPtr.Zero;
+ var fallbackArea = 0;
+ var className = new StringBuilder(64);
+
+ var window = IntPtr.Zero;
+ while ((window = FindWindowExW(IntPtr.Zero, window, null, null)) != IntPtr.Zero)
+ {
+ GetWindowThreadProcessId(window, out var owner);
+ if (owner != (uint)processId) continue;
+
+ className.Clear();
+ if (GetClassNameW(window, className, className.Capacity) > 0
+ && className.ToString() == NosTaleWindowClass)
+ {
+ return window;
+ }
+
+ GetWindowRect(window, out var rect);
+ var width = rect.Right - rect.Left;
+ var height = rect.Bottom - rect.Top;
+ if (width < minWidth || height < minHeight) continue;
+
+ var area = width * height;
+ if (area <= fallbackArea) continue;
+
+ fallbackArea = area;
+ fallback = window;
+ }
+
+ return fallback;
+ }
+}
diff --git a/src/NosCore.DeveloperTools.Cli/Program.cs b/src/NosCore.DeveloperTools.Cli/Program.cs
new file mode 100644
index 0000000..17231a0
--- /dev/null
+++ b/src/NosCore.DeveloperTools.Cli/Program.cs
@@ -0,0 +1,85 @@
+using System.Runtime.InteropServices;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace NosCore.DeveloperTools.Cli;
+
+internal static class Program
+{
+ private const int DefaultPort = 8787;
+
+ // DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2
+ private static readonly IntPtr PerMonitorAwareV2 = -4;
+
+ [DllImport("user32.dll")]
+ private static extern bool SetProcessDpiAwarenessContext(IntPtr context);
+
+ [STAThread]
+ private static async Task Main(string[] args)
+ {
+ // The client is DPI-aware: window rects and captured pixels are
+ // physical. Match it, or clicks aimed from a screenshot miss on a
+ // scaled display.
+ try { SetProcessDpiAwarenessContext(PerMonitorAwareV2); } catch { }
+
+ if (args.Contains("--mcp"))
+ {
+ return await RunMcpAsync(args);
+ }
+
+ return await RunHttpAsync(args);
+ }
+
+ ///
+ /// MCP stdio server. Exposes the client-control tools to an MCP host
+ /// (e.g. Claude Code). One process = one persistent ,
+ /// so the hook session and launched client survive across tool calls.
+ /// Must be started elevated (injection needs it) — run the MCP host
+ /// itself as admin so this child inherits elevation without a UAC prompt.
+ ///
+ private static async Task RunMcpAsync(string[] args)
+ {
+ var builder = Host.CreateApplicationBuilder(args);
+ // stdout is the MCP transport — every log line MUST go to stderr.
+ builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
+ builder.Services.AddSingleton();
+ builder.Services.AddMcpServer()
+ .WithStdioServerTransport()
+ .WithToolsFromAssembly();
+ await builder.Build().RunAsync();
+ return 0;
+ }
+
+ private static async Task RunHttpAsync(string[] args)
+ {
+ var port = ParsePort(args) ?? DefaultPort;
+ await using var driver = new ClientDriver();
+ var server = new ControlServer(driver, port);
+
+ Console.WriteLine($"NosCore client driver listening on http://127.0.0.1:{port}");
+ Console.WriteLine(" (run with --mcp to expose the same control as an MCP stdio server instead)");
+
+ try
+ {
+ await server.RunAsync();
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Driver stopped: {ex.Message}");
+ return 1;
+ }
+
+ return 0;
+ }
+
+ private static int? ParsePort(string[] args)
+ {
+ for (var i = 0; i < args.Length - 1; i++)
+ {
+ if (args[i] is "--port" or "-p" && int.TryParse(args[i + 1], out var port)) return port;
+ }
+
+ return null;
+ }
+}
diff --git a/src/NosCore.DeveloperTools.Cli/Screenshot.cs b/src/NosCore.DeveloperTools.Cli/Screenshot.cs
new file mode 100644
index 0000000..bd6e55c
--- /dev/null
+++ b/src/NosCore.DeveloperTools.Cli/Screenshot.cs
@@ -0,0 +1,137 @@
+using System.Drawing;
+using System.Drawing.Imaging;
+using System.Runtime.InteropServices;
+
+namespace NosCore.DeveloperTools.Cli;
+
+///
+/// Captures the client window on its own.
+///
+/// Screen-scraping the desktop is not good enough here: the game window
+/// is usually behind an editor, and it is larger than the desktop, so a
+/// screen grab returns whatever happens to be on top plus a cropped
+/// game. asks the window to render itself
+/// instead, which ignores z-order and off-screen area entirely.
+///
+/// The driver is elevated to match the client, so it may read the
+/// window; a normal-integrity process would be refused.
+///
+internal static class Screenshot
+{
+ private const uint PwRenderFullContent = 0x00000002;
+
+ public enum Mode
+ {
+ /// Ask the window to paint itself; survives occlusion.
+ Window,
+
+ /// Grab the desktop where the window sits. Needed when a
+ /// window renders through an overlay that PrintWindow misses.
+ Screen,
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct Rect
+ {
+ public int Left;
+ public int Top;
+ public int Right;
+ public int Bottom;
+ }
+
+ [DllImport("user32.dll")]
+ private static extern bool GetWindowRect(IntPtr window, out Rect rect);
+
+ [DllImport("user32.dll")]
+ private static extern bool PrintWindow(IntPtr window, IntPtr deviceContext, uint flags);
+
+ [DllImport("user32.dll")]
+ private static extern bool SetForegroundWindow(IntPtr window);
+
+ [DllImport("user32.dll")]
+ private static extern bool SetWindowPos(IntPtr window, IntPtr after, int x, int y, int cx, int cy, uint flags);
+
+ private const uint SwpNoSize = 0x0001;
+ private const uint SwpNoZOrder = 0x0004;
+ private const uint SwpShowWindow = 0x0040;
+
+ public static string Capture(IntPtr window, Mode mode, string path)
+ {
+ if (window == IntPtr.Zero) throw new InvalidOperationException("No client window.");
+ if (!GetWindowRect(window, out var rect)) throw new InvalidOperationException("GetWindowRect failed.");
+
+ var width = rect.Right - rect.Left;
+ var height = rect.Bottom - rect.Top;
+ if (width <= 0 || height <= 0)
+ {
+ throw new InvalidOperationException($"Client window has no area ({width}x{height}).");
+ }
+
+ Directory.CreateDirectory(Path.GetDirectoryName(path) ?? ".");
+
+ using var bitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb);
+ using (var graphics = Graphics.FromImage(bitmap))
+ {
+ if (mode == Mode.Window)
+ {
+ var dc = graphics.GetHdc();
+ try
+ {
+ if (!PrintWindow(window, dc, PwRenderFullContent))
+ {
+ throw new InvalidOperationException("PrintWindow failed.");
+ }
+ }
+ finally
+ {
+ graphics.ReleaseHdc(dc);
+ }
+ }
+ else
+ {
+ // The game window is larger than the desktop and sits at a
+ // negative-bottom offset, so a screen grab of its rect would
+ // be part desktop. Pull it to the origin and raise it first.
+ SetWindowPos(window, IntPtr.Zero, 0, 0, 0, 0, SwpNoSize | SwpNoZOrder | SwpShowWindow);
+ SetForegroundWindow(window);
+ Thread.Sleep(600);
+ GetWindowRect(window, out var moved);
+ graphics.CopyFromScreen(moved.Left, moved.Top, 0, 0, bitmap.Size);
+ }
+ }
+
+ bitmap.Save(path, ImageFormat.Png);
+ return path;
+ }
+
+ ///
+ /// True when the capture came back essentially uniform — the usual
+ /// sign that a hardware-accelerated surface did not render into the
+ /// device context, and that the screen mode should be tried instead.
+ ///
+ public static bool LooksBlank(string path)
+ {
+ using var bitmap = new Bitmap(path);
+
+ // Skip the title bar: it always paints, so including it would make
+ // an otherwise-black capture look like it had content.
+ var top = Math.Min(bitmap.Height - 1, 48);
+ var first = bitmap.GetPixel(0, top);
+
+ for (var y = top; y < bitmap.Height; y += Math.Max(1, bitmap.Height / 40))
+ {
+ for (var x = 0; x < bitmap.Width; x += Math.Max(1, bitmap.Width / 40))
+ {
+ var pixel = bitmap.GetPixel(x, y);
+ if (Math.Abs(pixel.R - first.R) > 8
+ || Math.Abs(pixel.G - first.G) > 8
+ || Math.Abs(pixel.B - first.B) > 8)
+ {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/NosCore.DeveloperTools.Cli/app.manifest b/src/NosCore.DeveloperTools.Cli/app.manifest
new file mode 100644
index 0000000..bd67ddd
--- /dev/null
+++ b/src/NosCore.DeveloperTools.Cli/app.manifest
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/NosCore.DeveloperTools.Hook/ClientInvoker.cs b/src/NosCore.DeveloperTools.Hook/ClientInvoker.cs
index 95c44bf..39fcb78 100644
--- a/src/NosCore.DeveloperTools.Hook/ClientInvoker.cs
+++ b/src/NosCore.DeveloperTools.Hook/ClientInvoker.cs
@@ -73,6 +73,119 @@ public static IntPtr BuildRegisterInvoker(IntPtr target)
return thunk;
}
+ ///
+ /// Build a cdecl-callable invoker for a Delphi register-convention
+ /// function taking exactly three arguments in EAX, EDX and ECX, with
+ /// nothing on the stack. Cast the result to
+ /// delegate* unmanaged[Cdecl]<IntPtr, IntPtr, int, void> .
+ ///
+ /// 55 push ebp
+ /// 8B EC mov ebp, esp
+ /// 8B 45 08 mov eax, [ebp+0x08]
+ /// 8B 55 0C mov edx, [ebp+0x0C]
+ /// 8B 4D 10 mov ecx, [ebp+0x10]
+ /// E8 rel32 call target
+ /// 8B E5 mov esp, ebp
+ /// 5D pop ebp
+ /// C3 ret
+ ///
+ public static IntPtr BuildRegisterInvoker3(IntPtr target)
+ {
+ const int Size = 21;
+ const int CallOpcodeOffset = 12;
+
+ var thunk = VirtualAlloc(IntPtr.Zero, (UIntPtr)Size,
+ AllocationType.Commit | AllocationType.Reserve, MemoryProtection.ReadWrite);
+ if (thunk == IntPtr.Zero) return IntPtr.Zero;
+
+ var t = (byte*)thunk;
+ t[0] = 0x55;
+ t[1] = 0x8B; t[2] = 0xEC;
+ t[3] = 0x8B; t[4] = 0x45; t[5] = 0x08;
+ t[6] = 0x8B; t[7] = 0x55; t[8] = 0x0C;
+ t[9] = 0x8B; t[10] = 0x4D; t[11] = 0x10;
+
+ t[CallOpcodeOffset] = 0xE8;
+ var afterCall = (long)thunk + CallOpcodeOffset + 5;
+ var rel = (int)((long)target - afterCall);
+ t[13] = (byte)rel; t[14] = (byte)(rel >> 8);
+ t[15] = (byte)(rel >> 16); t[16] = (byte)(rel >> 24);
+
+ t[17] = 0x8B; t[18] = 0xE5;
+ t[19] = 0x5D;
+ t[20] = 0xC3;
+
+ if (!VirtualProtect(thunk, (UIntPtr)Size, MemoryProtection.ExecuteRead, out _))
+ return IntPtr.Zero;
+ FlushInstructionCache(GetCurrentProcess(), thunk, (UIntPtr)Size);
+ return thunk;
+ }
+
+ ///
+ /// Build a cdecl-callable invoker for a Delphi register-convention
+ /// function taking four arguments: EAX, EDX, ECX and one stack
+ /// dword. Cast the result to
+ /// delegate* unmanaged[Cdecl]<IntPtr, int, int, int, void> .
+ ///
+ /// Unlike the two-argument thunk this one builds a real EBP frame
+ /// and restores ESP from it rather than popping. Delphi's register
+ /// convention makes the callee clean stack arguments, but a function
+ /// that turns out to take only its register arguments cleans
+ /// nothing — leaving ESP 4 bytes low and the return address off by
+ /// one slot. Restoring from EBP is correct either way, so an
+ /// argument-count guess that is wrong yields a no-op instead of a
+ /// crash.
+ ///
+ /// 55 push ebp
+ /// 8B EC mov ebp, esp
+ /// 53 push ebx
+ /// 8B 5D 14 mov ebx, [ebp+0x14] ; arg4
+ /// 8B 45 08 mov eax, [ebp+0x08] ; arg1
+ /// 8B 55 0C mov edx, [ebp+0x0C] ; arg2
+ /// 8B 4D 10 mov ecx, [ebp+0x10] ; arg3
+ /// 53 push ebx ; arg4 on the stack
+ /// E8 rel32 call target
+ /// 8D 65 FC lea esp, [ebp-4]
+ /// 5B pop ebx
+ /// 5D pop ebp
+ /// C3 ret
+ ///
+ public static IntPtr BuildRegisterInvoker4(IntPtr target)
+ {
+ const int Size = 28;
+ const int CallOpcodeOffset = 17;
+
+ var thunk = VirtualAlloc(IntPtr.Zero, (UIntPtr)Size,
+ AllocationType.Commit | AllocationType.Reserve, MemoryProtection.ReadWrite);
+ if (thunk == IntPtr.Zero) return IntPtr.Zero;
+
+ var t = (byte*)thunk;
+ t[0] = 0x55;
+ t[1] = 0x8B; t[2] = 0xEC;
+ t[3] = 0x53;
+ t[4] = 0x8B; t[5] = 0x5D; t[6] = 0x14;
+ t[7] = 0x8B; t[8] = 0x45; t[9] = 0x08;
+ t[10] = 0x8B; t[11] = 0x55; t[12] = 0x0C;
+ t[13] = 0x8B; t[14] = 0x4D; t[15] = 0x10;
+ t[16] = 0x53;
+
+ t[CallOpcodeOffset] = 0xE8;
+ var afterCall = (long)thunk + CallOpcodeOffset + 5;
+ var rel = (int)((long)target - afterCall);
+ t[18] = (byte)rel; t[19] = (byte)(rel >> 8);
+ t[20] = (byte)(rel >> 16); t[21] = (byte)(rel >> 24);
+
+ t[22] = 0x8D; t[23] = 0x65; t[24] = 0xFC;
+ t[25] = 0x5B;
+ t[26] = 0x5D;
+ t[27] = 0xC3;
+
+ if (!VirtualProtect(thunk, (UIntPtr)Size, MemoryProtection.ExecuteRead, out _))
+ return IntPtr.Zero;
+ FlushInstructionCache(GetCurrentProcess(), thunk, (UIntPtr)Size);
+ return thunk;
+ }
+
///
/// Allocates a Delphi AnsiString the client can consume. Full header
/// layout (Delphi 2009+):
diff --git a/src/NosCore.DeveloperTools.Hook/ClientWindow.cs b/src/NosCore.DeveloperTools.Hook/ClientWindow.cs
new file mode 100644
index 0000000..b1401ff
--- /dev/null
+++ b/src/NosCore.DeveloperTools.Hook/ClientWindow.cs
@@ -0,0 +1,185 @@
+using System.Runtime.InteropServices;
+using System.Text;
+
+namespace NosCore.DeveloperTools.Hook;
+
+///
+/// Window and input control for the client, performed from inside it.
+///
+/// Doing this from the outside does not work: the client inherits the
+/// launcher's elevation, so a normal-integrity process is refused by
+/// UIPI — ShowWindow and SetWindowPos silently do nothing and posted
+/// input is dropped. Running in-process sidesteps that entirely, since
+/// a process is always allowed to drive its own windows.
+///
+internal static class ClientWindow
+{
+ private const int SwRestore = 9;
+ private const int SwShow = 5;
+ private const uint WmLButtonDown = 0x0201;
+ private const uint WmLButtonUp = 0x0202;
+ private const uint WmMouseMove = 0x0200;
+ private const int MkLButton = 0x0001;
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct Rect
+ {
+ public int Left;
+ public int Top;
+ public int Right;
+ public int Bottom;
+ }
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ private static extern IntPtr FindWindowExW(IntPtr parent, IntPtr after, string? className, string? windowName);
+
+ [DllImport("user32.dll")]
+ private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId);
+
+ [DllImport("user32.dll")]
+ private static extern bool IsWindowVisible(IntPtr window);
+
+ [DllImport("user32.dll")]
+ private static extern bool IsIconic(IntPtr window);
+
+ [DllImport("user32.dll")]
+ private static extern bool ShowWindow(IntPtr window, int command);
+
+ [DllImport("user32.dll")]
+ private static extern bool SetForegroundWindow(IntPtr window);
+
+ [DllImport("user32.dll")]
+ private static extern bool GetWindowRect(IntPtr window, out Rect rect);
+
+ [DllImport("user32.dll")]
+ private static extern bool GetClientRect(IntPtr window, out Rect rect);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ private static extern int GetWindowTextW(IntPtr window, StringBuilder text, int count);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ private static extern int GetClassNameW(IntPtr window, StringBuilder className, int count);
+
+ [DllImport("user32.dll")]
+ private static extern bool PostMessageW(IntPtr window, uint message, IntPtr wParam, IntPtr lParam);
+
+ /// The client's main window class.
+ private const string NosTaleWindowClass = "TNosTaleMainF";
+
+ ///
+ /// Walks the top-level window list with FindWindowEx rather than
+ /// EnumWindows: the latter needs a managed callback marshalled back
+ /// into native code, which does not survive NativeAOT here and made
+ /// the scan return nothing at all.
+ ///
+ /// Identifies the window by class, never by caption. Reading a
+ /// caption sends WM_GETTEXT, and we run on the pipe thread — so
+ /// asking our own window for its title blocks until the client's UI
+ /// thread is free to answer, which is exactly when we most want to
+ /// look at it. GetClassNameW reads the class directly and never
+ /// messages anyone.
+ ///
+ public static IntPtr Find()
+ {
+ var pid = (uint)Environment.ProcessId;
+ var fallback = IntPtr.Zero;
+ var fallbackArea = -1;
+ var className = new StringBuilder(64);
+
+ var window = IntPtr.Zero;
+ while ((window = FindWindowExW(IntPtr.Zero, window, null, null)) != IntPtr.Zero)
+ {
+ GetWindowThreadProcessId(window, out var owner);
+ if (owner != pid) continue;
+
+ className.Clear();
+ if (GetClassNameW(window, className, className.Capacity) > 0
+ && className.ToString() == NosTaleWindowClass)
+ {
+ return window;
+ }
+
+ GetWindowRect(window, out var rect);
+ var area = Math.Max(0, rect.Right - rect.Left) * Math.Max(0, rect.Bottom - rect.Top);
+ if (area <= fallbackArea) continue;
+
+ fallbackArea = area;
+ fallback = window;
+ }
+
+ return fallback;
+ }
+
+ /// Every top-level window this process owns, for diagnosis.
+ public static string List()
+ {
+ var pid = (uint)Environment.ProcessId;
+ var found = new List();
+
+ var window = IntPtr.Zero;
+ while ((window = FindWindowExW(IntPtr.Zero, window, null, null)) != IntPtr.Zero)
+ {
+ GetWindowThreadProcessId(window, out var owner);
+ if (owner != pid) continue;
+
+ var className = new StringBuilder(64);
+ GetClassNameW(window, className, className.Capacity);
+ GetWindowRect(window, out var rect);
+ found.Add($"0x{window.ToInt64():X}:{className}:{rect.Right - rect.Left}x{rect.Bottom - rect.Top}" +
+ $":visible={IsWindowVisible(window)}:iconic={IsIconic(window)}");
+ }
+
+ return found.Count == 0 ? "none" : string.Join(" ", found);
+ }
+
+ public static string Show()
+ {
+ var window = Find();
+ if (window == IntPtr.Zero) return "no-window";
+
+ if (IsIconic(window))
+ {
+ ShowWindow(window, SwRestore);
+ }
+
+ ShowWindow(window, SwShow);
+ SetForegroundWindow(window);
+ return Describe(window);
+ }
+
+ public static string Describe()
+ {
+ var window = Find();
+ return window == IntPtr.Zero ? "no-window" : Describe(window);
+ }
+
+ private static string Describe(IntPtr window)
+ {
+ GetWindowRect(window, out var rect);
+ GetClientRect(window, out var client);
+ var className = new StringBuilder(64);
+ GetClassNameW(window, className, className.Capacity);
+
+ return $"hwnd=0x{window.ToInt64():X} class={className} " +
+ $"rect={rect.Left},{rect.Top},{rect.Right},{rect.Bottom} " +
+ $"client={client.Right - client.Left}x{client.Bottom - client.Top} " +
+ $"iconic={IsIconic(window)}";
+ }
+
+ ///
+ /// Post a left click at a point in client coordinates. Posted rather
+ /// than sent so the click lands on the client's own message loop
+ /// instead of re-entering it from our thread.
+ ///
+ public static string Click(int x, int y)
+ {
+ var window = Find();
+ if (window == IntPtr.Zero) return "no-window";
+
+ var point = (IntPtr)((y << 16) | (x & 0xFFFF));
+ PostMessageW(window, WmMouseMove, IntPtr.Zero, point);
+ PostMessageW(window, WmLButtonDown, (IntPtr)MkLButton, point);
+ PostMessageW(window, WmLButtonUp, IntPtr.Zero, point);
+ return $"clicked {x},{y}";
+ }
+}
diff --git a/src/NosCore.DeveloperTools.Hook/DelphiString.cs b/src/NosCore.DeveloperTools.Hook/DelphiString.cs
index f9cd991..49a91cf 100644
--- a/src/NosCore.DeveloperTools.Hook/DelphiString.cs
+++ b/src/NosCore.DeveloperTools.Hook/DelphiString.cs
@@ -14,11 +14,18 @@ internal static unsafe class DelphiString
{
if (payload == IntPtr.Zero) return null;
+ // The try/catch below cannot save us here: an access violation is
+ // an SEH fault, which NativeAOT does not turn into a catchable
+ // exception, so an unchecked read of a wrong pointer takes the
+ // client down instead of returning null. Validate first.
+ if (!SafeMemory.IsReadable(payload - 4, 4)) return null;
+
try
{
var p = (byte*)payload;
var length = *(int*)(p - 4);
if (length <= 0 || length > 65536) return null;
+ if (!SafeMemory.IsReadable(payload, length)) return null;
return Encoding.UTF8.GetString(p, length);
}
catch
diff --git a/src/NosCore.DeveloperTools.Hook/Detour.cs b/src/NosCore.DeveloperTools.Hook/Detour.cs
index 490d5a3..f3ba631 100644
--- a/src/NosCore.DeveloperTools.Hook/Detour.cs
+++ b/src/NosCore.DeveloperTools.Hook/Detour.cs
@@ -56,12 +56,15 @@ private enum MemoryProtection : uint
/// — 2-arg hook receiving (EAX, EDX);
/// useful for send handlers where we want both the Delphi "self"
/// context and the packet pointer.
+ /// — 0-arg hook, for detours placed purely to
+ /// borrow the target's thread rather than to read its arguments.
///
public enum HookArg : byte
{
Edx,
Ebp,
EaxThenEdx,
+ None,
}
public static IntPtr Install(IntPtr target, IntPtr hook, int prologueSize = 6, HookArg arg = HookArg.Edx)
@@ -69,9 +72,19 @@ public static IntPtr Install(IntPtr target, IntPtr hook, int prologueSize = 6, H
if (target == IntPtr.Zero || hook == IntPtr.Zero) return IntPtr.Zero;
if (prologueSize < 5) return IntPtr.Zero;
- // Args section size: PUSH EDX / PUSH EBP = 1 byte; PUSH EDX + PUSH EAX = 2 bytes.
- var argsSize = arg == HookArg.EaxThenEdx ? 2 : 1;
- var trampolineSize = 1 + 1 + argsSize + 5 + 1 + 1 + prologueSize + 5;
+ var argCount = arg switch
+ {
+ HookArg.EaxThenEdx => 2,
+ HookArg.None => 0,
+ _ => 1,
+ };
+
+ // Arguments are read back out of the PUSHAD frame rather than from
+ // live registers, because the bootstrap call below runs first and
+ // clobbers them. Each such push is 4 bytes.
+ var bootstrap = RuntimeBootstrap.Stub;
+ var bootstrapSize = bootstrap == IntPtr.Zero ? 0 : 13;
+ var trampolineSize = 1 + 1 + bootstrapSize + (argCount * 4) + 5 + 1 + 1 + prologueSize + 5;
var trampoline = VirtualAlloc(IntPtr.Zero, (UIntPtr)trampolineSize,
AllocationType.Commit | AllocationType.Reserve, MemoryProtection.ReadWrite);
if (trampoline == IntPtr.Zero) return IntPtr.Zero;
@@ -84,25 +97,51 @@ public static IntPtr Install(IntPtr target, IntPtr hook, int prologueSize = 6, H
var pos = 0;
t[pos++] = 0x60; // PUSHAD
t[pos++] = 0x9C; // PUSHFD
+
+ var skipHookRel = -1;
+ if (bootstrap != IntPtr.Zero)
+ {
+ // Attach this thread to the managed runtime, and skip the hook
+ // entirely if that fails — entering managed code on a thread the
+ // AOT image has never seen kills the process.
+ t[pos++] = 0xB8; // MOV EAX, bootstrap
+ WriteInt32(t + pos, (int)bootstrap);
+ pos += 4;
+ t[pos++] = 0xFF; t[pos++] = 0xD0; // CALL EAX
+ t[pos++] = 0x85; t[pos++] = 0xC0; // TEST EAX, EAX
+ t[pos++] = 0x0F; t[pos++] = 0x84; // JZ rel32 -> past the hook call
+ skipHookRel = pos;
+ WriteInt32(t + pos, 0);
+ pos += 4;
+ }
+
switch (arg)
{
case HookArg.Edx:
- t[pos++] = 0x52; // PUSH EDX
+ PushSavedRegister(t, ref pos, 0x18);
break;
case HookArg.Ebp:
- t[pos++] = 0x55; // PUSH EBP
+ PushSavedRegister(t, ref pos, 0x0C);
+ break;
+ case HookArg.None:
break;
case HookArg.EaxThenEdx:
// stdcall pushes args right-to-left; arg1=EAX must be topmost,
- // so push EDX first, then EAX.
- t[pos++] = 0x52; // PUSH EDX
- t[pos++] = 0x50; // PUSH EAX
+ // so push EDX first, then EAX — whose slot has moved by 4.
+ PushSavedRegister(t, ref pos, 0x18);
+ PushSavedRegister(t, ref pos, 0x24);
break;
}
t[pos++] = 0xE8; // CALL hook (rel32)
var callOpAddr = (IntPtr)(t + pos - 1);
WriteInt32(t + pos, (int)((long)hook - (long)callOpAddr - 5));
pos += 4;
+
+ if (skipHookRel >= 0)
+ {
+ WriteInt32(t + skipHookRel, pos - (skipHookRel + 4));
+ }
+
t[pos++] = 0x9D; // POPFD
t[pos++] = 0x61; // POPAD
for (var i = 0; i < prologueSize; i++) t[pos++] = saved[i];
@@ -129,6 +168,19 @@ public static IntPtr Install(IntPtr target, IntPtr hook, int prologueSize = 6, H
return trampoline;
}
+ ///
+ /// Push one register out of the PUSHAD frame. After PUSHAD then PUSHFD
+ /// the saved registers sit at, from ESP: flags, EDI 0x04, ESI 0x08,
+ /// EBP 0x0C, ESP 0x10, EBX 0x14, EDX 0x18, ECX 0x1C, EAX 0x20.
+ ///
+ private static void PushSavedRegister(byte* dst, ref int pos, byte savedRegisterOffset)
+ {
+ dst[pos++] = 0xFF;
+ dst[pos++] = 0x74;
+ dst[pos++] = 0x24;
+ dst[pos++] = savedRegisterOffset;
+ }
+
private static void WriteInt32(byte* dst, int value)
{
dst[0] = (byte)value;
diff --git a/src/NosCore.DeveloperTools.Hook/HookEntry.cs b/src/NosCore.DeveloperTools.Hook/HookEntry.cs
index 421802e..e8fb4df 100644
--- a/src/NosCore.DeveloperTools.Hook/HookEntry.cs
+++ b/src/NosCore.DeveloperTools.Hook/HookEntry.cs
@@ -64,6 +64,9 @@ private static void InstallHooks()
var result = Hooks.Install();
PipeServer.Announce(
$"hooks: send={Fmt(result.SendAddress, result.SendHooked)} recv={Fmt(result.RecvAddress, result.RecvHooked)} login-recv={Fmt(result.LoginRecvAddress, result.LoginRecvHooked)}");
+ PipeServer.Announce(
+ $"control: periodic={Fmt(result.PeriodicAddress, result.PeriodicHooked)} walk={Fmt(result.WalkAddress, true)} manager-slot={Fmt(result.PlayerManagerStaticAddress, true)}");
+ PipeServer.Announce($"runtime-bootstrap: {result.BootstrapStatus}");
Hooks.StartNosMallPoller();
}
catch (Exception ex)
diff --git a/src/NosCore.DeveloperTools.Hook/Hooks.cs b/src/NosCore.DeveloperTools.Hook/Hooks.cs
index 4d9500c..339dbf3 100644
--- a/src/NosCore.DeveloperTools.Hook/Hooks.cs
+++ b/src/NosCore.DeveloperTools.Hook/Hooks.cs
@@ -24,11 +24,20 @@ internal static unsafe class Hooks
private const int QueueCap = 4096;
public static readonly ConcurrentQueue Queue = new();
+
+ ///
+ /// Command replies, kept apart from captured traffic. In-world the
+ /// packet queue runs thousands of entries deep, and a reply sharing
+ /// it arrived tens of seconds after the command that asked for it.
+ ///
+ public static readonly ConcurrentQueue Replies = new();
public static int QueueDropped;
+ public static InstallResult LastInstall;
private static IntPtr _sendTrampoline;
private static IntPtr _recvTrampoline;
private static IntPtr _loginRecvTrampoline;
+ private static IntPtr _periodicTrampoline;
// Invoker thunks for re-entering the client's own send/recv functions
// (Delphi register convention). Cached after scanning.
@@ -39,13 +48,38 @@ internal static unsafe class Hooks
private static volatile IntPtr _worldSendContext;
private static volatile IntPtr _worldRecvContext;
+ ///
+ /// Which detours to install, from _NC_HOOKS in the client's
+ /// environment (comma-separated: send, recv, login-recv, periodic).
+ /// Unset means all of them. A detour that destabilises the client
+ /// can only be identified by leaving it out, and the launcher owns
+ /// the client's environment, so this is the one setting available
+ /// before any code runs.
+ ///
+ private static bool Enabled(string name)
+ {
+ var configured = Environment.GetEnvironmentVariable("_NC_HOOKS");
+ if (string.IsNullOrWhiteSpace(configured)) return true;
+
+ foreach (var part in configured.Split(',', StringSplitOptions.RemoveEmptyEntries))
+ {
+ if (part.Trim().Equals(name, StringComparison.OrdinalIgnoreCase)) return true;
+ }
+
+ return false;
+ }
+
public static InstallResult Install()
{
var result = new InstallResult();
- var sendAddr = PatternScanner.ScanMainModule(Signatures.Send);
- var recvAddr = PatternScanner.ScanMainModule(Signatures.Recv);
- var loginRecvAddr = PatternScanner.ScanMainModule(Signatures.LoginRecv);
+ // Must precede every detour: trampolines bake in the stub address.
+ RuntimeBootstrap.Initialize();
+ result.BootstrapStatus = RuntimeBootstrap.Status;
+
+ var sendAddr = Enabled("send") ? PatternScanner.ScanMainModule(Signatures.Send) : IntPtr.Zero;
+ var recvAddr = Enabled("recv") ? PatternScanner.ScanMainModule(Signatures.Recv) : IntPtr.Zero;
+ var loginRecvAddr = Enabled("login-recv") ? PatternScanner.ScanMainModule(Signatures.LoginRecv) : IntPtr.Zero;
result.SendAddress = sendAddr;
result.RecvAddress = recvAddr;
@@ -78,9 +112,63 @@ public static InstallResult Install()
result.LoginRecvHooked = _loginRecvTrampoline != IntPtr.Zero;
}
+ var periodicAddr = Enabled("periodic") ? PatternScanner.ScanMainModule(Signatures.Periodic) : IntPtr.Zero;
+ result.PeriodicAddress = periodicAddr;
+ if (periodicAddr != IntPtr.Zero)
+ {
+ delegate* unmanaged[Stdcall] periodicHook = &HookedPeriodic;
+ _periodicTrampoline = Detour.Install(periodicAddr, (IntPtr)periodicHook,
+ prologueSize: Signatures.PeriodicPrologueSize, arg: Detour.HookArg.None);
+ result.PeriodicHooked = _periodicTrampoline != IntPtr.Zero;
+ if (result.PeriodicHooked)
+ {
+ NosThreadSynchronizer.MarkInstalled();
+ }
+ }
+
+ if (Enabled("connect"))
+ {
+ result.ConnectHooked = WorldConnection.Install();
+ result.ConnectAddress = WorldConnection.ConnectAddress;
+ }
+
+ PlayerManager.Resolve();
+ result.PlayerManagerStaticAddress = PlayerManager.StaticAddress;
+ result.WalkAddress = PlayerManager.WalkAddress;
+
+ LastInstall = result;
return result;
}
+ [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvStdcall) })]
+ private static void HookedPeriodic() => NosThreadSynchronizer.Tick();
+
+ public static WalkResult Walk(ushort x, ushort y, (int Un0, int Un1)? extraArgs) =>
+ PlayerManager.Walk(x, y, extraArgs);
+
+ ///
+ /// Reads the character's position on the client thread where
+ /// possible, so a position sampled mid-move can't be a torn read of
+ /// coordinates the frame loop is writing.
+ ///
+ public static bool TryGetPosition(out int id, out ushort x, out ushort y)
+ {
+ var readId = 0;
+ ushort readX = 0;
+ ushort readY = 0;
+ var read = false;
+
+ if (NosThreadSynchronizer.Invoke(() => read = PlayerManager.TryGetPosition(out readId, out readX, out readY)))
+ {
+ id = readId;
+ x = readX;
+ y = readY;
+ return read;
+ }
+
+ return PlayerManager.TryGetPosition(out id, out x, out y);
+ }
+
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvStdcall) })]
private static void HookedSend(IntPtr eax, IntPtr edx)
{
@@ -151,8 +239,17 @@ private static bool InjectViaInvoker(string packet, IntPtr invokerPtr, IntPtr ct
try
{
var ansi = ClientInvoker.AllocAnsiString(packet);
- var invoker = (delegate* unmanaged[Cdecl])invokerPtr;
- invoker(ctx, ansi);
+
+ // Prefer the client's own thread. The direct call is kept as a
+ // fallback so injection still works if the periodic signature
+ // drifts — it races the frame loop, which is survivable for
+ // send/recv but is why movement never takes this path.
+ if (NosThreadSynchronizer.Invoke(() => CallInvoker(invokerPtr, ctx, ansi)))
+ {
+ return true;
+ }
+
+ CallInvoker(invokerPtr, ctx, ansi);
return true;
}
catch
@@ -161,6 +258,12 @@ private static bool InjectViaInvoker(string packet, IntPtr invokerPtr, IntPtr ct
}
}
+ private static void CallInvoker(IntPtr invokerPtr, IntPtr ctx, IntPtr ansi)
+ {
+ var invoker = (delegate* unmanaged[Cdecl])invokerPtr;
+ invoker(ctx, ansi);
+ }
+
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvStdcall) })]
private static void HookedRecv(IntPtr eax, IntPtr edx)
{
@@ -198,6 +301,13 @@ private static void Capture(PacketDirection direction, PacketConnection connecti
internal struct InstallResult
{
+ public IntPtr ConnectAddress;
+ public bool ConnectHooked;
+ public string? BootstrapStatus;
+ public IntPtr PeriodicAddress;
+ public IntPtr PlayerManagerStaticAddress;
+ public IntPtr WalkAddress;
+ public bool PeriodicHooked;
public IntPtr SendAddress;
public IntPtr RecvAddress;
public IntPtr LoginRecvAddress;
diff --git a/src/NosCore.DeveloperTools.Hook/NosThreadSynchronizer.cs b/src/NosCore.DeveloperTools.Hook/NosThreadSynchronizer.cs
new file mode 100644
index 0000000..1efdd9d
--- /dev/null
+++ b/src/NosCore.DeveloperTools.Hook/NosThreadSynchronizer.cs
@@ -0,0 +1,116 @@
+using System.Collections.Concurrent;
+
+namespace NosCore.DeveloperTools.Hook;
+
+///
+/// Marshals work onto the client's own thread.
+///
+/// The client keeps its game state — scene graph, entity list, the
+/// player's position — under no synchronisation at all, because only
+/// one thread was ever meant to touch it. Our pipe reader is a
+/// different thread, so calling a client routine straight from a pipe
+/// command races the frame loop and corrupts that state.
+///
+/// The periodic detour calls once per frame from the
+/// right thread; commands queue work here and block until it has run
+/// there.
+///
+internal static class NosThreadSynchronizer
+{
+ private const int MaxWorkPerTick = 8;
+ private const int DefaultTimeoutMs = 3000;
+
+ private static readonly ConcurrentQueue Pending = new();
+ private static long _ticks;
+ private static volatile bool _installed;
+
+ [ThreadStatic]
+ private static bool _insideTick;
+
+ public static long Ticks => Interlocked.Read(ref _ticks);
+
+ ///
+ /// True once the periodic detour is installed and has actually
+ /// fired. Both halves matter: a signature can match a function that
+ /// is never called, and queueing onto a tick that never comes would
+ /// hang every command until it times out.
+ ///
+ public static bool IsRunning => _installed && Interlocked.Read(ref _ticks) > 0;
+
+ public static void MarkInstalled() => _installed = true;
+
+ public static void Tick()
+ {
+ Interlocked.Increment(ref _ticks);
+
+ _insideTick = true;
+ try
+ {
+ for (var i = 0; i < MaxWorkPerTick; i++)
+ {
+ if (!Pending.TryDequeue(out var work)) return;
+ try
+ {
+ work();
+ }
+ catch
+ {
+ // Never throw out of the frame loop — the client would die.
+ }
+ }
+ }
+ finally
+ {
+ _insideTick = false;
+ }
+ }
+
+ ///
+ /// Run on the client thread and wait for it.
+ /// False means it never ran: either no tick is available, or the
+ /// client stopped ticking (minimised, frozen, shutting down).
+ ///
+ public static bool Invoke(Action work, int timeoutMs = DefaultTimeoutMs)
+ {
+ // Already on the client thread: queueing would wait for a tick
+ // that cannot start until we return.
+ if (_insideTick)
+ {
+ try
+ {
+ work();
+ }
+ catch
+ {
+ }
+
+ return true;
+ }
+
+ if (!IsRunning) return false;
+
+ var done = new ManualResetEventSlim(false);
+ Pending.Enqueue(() =>
+ {
+ try
+ {
+ work();
+ }
+ finally
+ {
+ try { done.Set(); } catch { }
+ }
+ });
+
+ var completed = done.Wait(timeoutMs);
+ if (completed)
+ {
+ done.Dispose();
+ }
+
+ // On timeout the item stays queued and will Set() a later tick,
+ // so the event is deliberately left undisposed rather than
+ // racing a disposal against the client thread.
+ return completed;
+ }
+}
diff --git a/src/NosCore.DeveloperTools.Hook/PipeServer.cs b/src/NosCore.DeveloperTools.Hook/PipeServer.cs
index 11677a2..2d18025 100644
--- a/src/NosCore.DeveloperTools.Hook/PipeServer.cs
+++ b/src/NosCore.DeveloperTools.Hook/PipeServer.cs
@@ -31,6 +31,15 @@ public static void Announce(string message)
Hooks.Queue.Enqueue(new CapturedPacket(PacketDirection.Status, PacketConnection.World, "STATUS " + message));
}
+ ///
+ /// Emit a line verbatim, for command replies that carry their own
+ /// prefix and are meant to be parsed rather than shown as status.
+ ///
+ private static void Reply(string line)
+ {
+ Hooks.Replies.Enqueue(line);
+ }
+
public static void Run()
{
var pipeName = $"NosCore.DeveloperTools.{Environment.ProcessId}";
@@ -59,7 +68,13 @@ public static void Run()
{
FlushDrops(pipe);
- if (Hooks.Queue.TryDequeue(out var packet))
+ // Replies first: a caller is blocked waiting on one,
+ // while captured packets are only ever read after.
+ if (Hooks.Replies.TryDequeue(out var reply))
+ {
+ WriteLine(pipe, reply);
+ }
+ else if (Hooks.Queue.TryDequeue(out var packet))
{
WritePacket(pipe, packet);
}
@@ -110,6 +125,89 @@ private static void HandleCommand(string line)
return;
}
+ if (line.StartsWith("WALK ", StringComparison.Ordinal))
+ {
+ HandleWalk(line[5..]);
+ return;
+ }
+
+ if (line.StartsWith("POS", StringComparison.Ordinal))
+ {
+ HandlePosition();
+ return;
+ }
+
+ if (line.StartsWith("DIAG", StringComparison.Ordinal))
+ {
+ HandleDiagnostics();
+ return;
+ }
+
+ if (line.StartsWith("SCANPLAYER", StringComparison.Ordinal))
+ {
+ var scan = "not-run";
+ if (!NosThreadSynchronizer.Invoke(() => scan = PlayerManager.ScanForPlayerObject()))
+ {
+ scan = "client-thread-unavailable";
+ }
+
+ Reply("SCANPLAYER " + scan);
+ return;
+ }
+
+ if (line.StartsWith("PEEK ", StringComparison.Ordinal))
+ {
+ HandlePeek(line[5..]);
+ return;
+ }
+
+ if (line.StartsWith("CONNECT ", StringComparison.Ordinal))
+ {
+ var parts = line[8..].Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length != 2 || !int.TryParse(parts[1], out var port))
+ {
+ Reply("CONNECTRESULT bad-arguments");
+ return;
+ }
+
+ var outcome = WorldConnection.Connect(parts[0], port) switch
+ {
+ ConnectResult.Ok => "ok",
+ ConnectResult.NoConnectFunction => "connect-signature-not-found",
+ ConnectResult.NoContext => "no-connection-object-observed-yet",
+ ConnectResult.NoClientThread => "client-thread-unavailable",
+ _ => "unknown",
+ };
+ Reply("CONNECTRESULT " + outcome);
+ return;
+ }
+
+ if (line.StartsWith("WINDOW", StringComparison.Ordinal))
+ {
+ if (line.Contains("list", StringComparison.OrdinalIgnoreCase))
+ {
+ Reply("WINDOW " + ClientWindow.List());
+ return;
+ }
+
+ var show = line.Contains("show", StringComparison.OrdinalIgnoreCase);
+ Reply("WINDOW " + (show ? ClientWindow.Show() : ClientWindow.Describe()));
+ return;
+ }
+
+ if (line.StartsWith("CLICK ", StringComparison.Ordinal))
+ {
+ var parts = line[6..].Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length != 2 || !int.TryParse(parts[0], out var cx) || !int.TryParse(parts[1], out var cy))
+ {
+ Reply("CLICK bad-arguments");
+ return;
+ }
+
+ Reply("CLICK " + ClientWindow.Click(cx, cy));
+ return;
+ }
+
// "INJECT " — 11 chars minimum before payload.
if (!line.StartsWith("INJECT ", StringComparison.Ordinal) || line.Length < 12) return;
@@ -151,6 +249,90 @@ private static void HandleCommand(string line)
}
}
+ private static void HandleWalk(string args)
+ {
+ var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length is not (2 or 4)
+ || !ushort.TryParse(parts[0], out var x)
+ || !ushort.TryParse(parts[1], out var y))
+ {
+ Reply("WALKRESULT bad-arguments");
+ return;
+ }
+
+ (int Un0, int Un1)? extra = null;
+ if (parts.Length == 4)
+ {
+ if (!int.TryParse(parts[2], out var un0) || !int.TryParse(parts[3], out var un1))
+ {
+ Reply("WALKRESULT bad-arguments");
+ return;
+ }
+
+ extra = (un0, un1);
+ }
+
+ var reason = Hooks.Walk(x, y, extra) switch
+ {
+ WalkResult.Ok => "ok",
+ WalkResult.NoWalkFunction => "walk-signature-not-found",
+ WalkResult.NoPlayerManager => "player-manager-signature-not-found",
+ WalkResult.NotInWorld => "not-in-world",
+ WalkResult.NoCharacterLoaded => "no-character-loaded",
+ WalkResult.NoClientThread => "client-thread-unavailable",
+ _ => "unknown",
+ };
+ Reply("WALKRESULT " + reason);
+ }
+
+ private static void HandlePeek(string args)
+ {
+ var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length != 2
+ || !long.TryParse(parts[0], System.Globalization.NumberStyles.HexNumber, null, out var address)
+ || !int.TryParse(parts[1], out var length))
+ {
+ Reply("PEEK bad-arguments");
+ return;
+ }
+
+ var dump = "not-run";
+ if (!NosThreadSynchronizer.Invoke(() => dump = PlayerManager.Peek((IntPtr)address, length)))
+ {
+ dump = "client-thread-unavailable";
+ }
+
+ Reply("PEEK " + dump);
+ }
+
+ private static void HandlePosition()
+ {
+ if (!Hooks.TryGetPosition(out var id, out var x, out var y))
+ {
+ Reply("POS unavailable");
+ return;
+ }
+
+ Reply($"POS {id} {x} {y}");
+ }
+
+ private static void HandleDiagnostics()
+ {
+ var install = Hooks.LastInstall;
+ var inWorld = PlayerManager.TryGetManager(out var manager);
+ var loaded = PlayerManager.TryGetPlayer(out var player, out var playerId);
+ var periodic = install.PeriodicHooked ? Fmt(install.PeriodicAddress) : Fmt(install.PeriodicAddress) + "(not-hooked)";
+
+ Reply($"DIAG ticks={NosThreadSynchronizer.Ticks} periodic={periodic} " +
+ $"manager-slot={Fmt(install.PlayerManagerStaticAddress)} manager={Fmt(manager)} " +
+ $"walk={Fmt(install.WalkAddress)} in-world={inWorld} " +
+ $"player={Fmt(player)} player-id={playerId} character-loaded={loaded} " +
+ $"connect={Fmt(install.ConnectAddress)} connect-object={Fmt(WorldConnection.Context)} " +
+ $"connects-seen={WorldConnection.Observed}");
+ }
+
+ private static string Fmt(IntPtr address) => address == IntPtr.Zero ? "none" : $"0x{address.ToInt64():X}";
+
private static void HandleNosMallUrl()
{
// Offload to a ThreadPool worker — the pipe reader thread must not
diff --git a/src/NosCore.DeveloperTools.Hook/PlayerManager.cs b/src/NosCore.DeveloperTools.Hook/PlayerManager.cs
new file mode 100644
index 0000000..14f768d
--- /dev/null
+++ b/src/NosCore.DeveloperTools.Hook/PlayerManager.cs
@@ -0,0 +1,186 @@
+namespace NosCore.DeveloperTools.Hook;
+
+internal enum WalkResult
+{
+ Ok,
+ NoWalkFunction,
+ NoPlayerManager,
+ NotInWorld,
+ NoCharacterLoaded,
+ NoClientThread,
+}
+
+///
+/// Reads the local character's live state and drives its movement
+/// through the client's own walk routine.
+///
+/// Injecting a walk packet instead would move the character
+/// server-side only: the client keeps rendering the old position and
+/// every subsequent packet it originates carries stale coordinates.
+/// Calling the client's routine updates local state, animates, and lets
+/// the client build the outgoing packet itself — including the checksum
+/// byte, which we therefore never have to reproduce.
+///
+internal static unsafe class PlayerManager
+{
+ private const int PlayerObjectOffset = 0x20;
+ private const int PlayerIdOffset = 0x24;
+ private const int ObjectIdOffset = 0x08;
+ private const int ObjectXOffset = 0x0C;
+ private const int ObjectYOffset = 0x0E;
+
+ ///
+ /// The manager exists as soon as the client reaches its game scene,
+ /// several seconds before a character is loaded into it — at that
+ /// point the player slot is null and the id reads -1. Movement
+ /// dereferences the player, so a non-null manager is not enough to
+ /// make the call safe.
+ ///
+ public static bool TryGetPlayer(out IntPtr player, out int playerId)
+ {
+ player = IntPtr.Zero;
+ playerId = -1;
+
+ if (!TryGetManager(out var manager)) return false;
+ if (!SafeMemory.TryReadIntPtr(manager + PlayerObjectOffset, out var candidate)) return false;
+ if (candidate == IntPtr.Zero) return false;
+ if (!SafeMemory.TryReadInt32(manager + PlayerIdOffset, out playerId)) return false;
+ if (playerId == -1) return false;
+
+ player = candidate;
+ return true;
+ }
+
+ public static IntPtr StaticAddress { get; private set; }
+
+ public static IntPtr WalkAddress { get; private set; }
+
+ private static IntPtr _walkInvoker2;
+ private static IntPtr _walkInvoker4;
+
+ public static void Resolve()
+ {
+ var managerSite = PatternScanner.ScanMainModule(Signatures.PlayerManager);
+ if (managerSite != IntPtr.Zero &&
+ SafeMemory.TryReadIntPtr(managerSite + Signatures.PlayerManagerStaticOperandOffset, out var slot))
+ {
+ StaticAddress = slot;
+ }
+
+ var walk = PatternScanner.ScanMainModule(Signatures.PlayerWalk);
+ if (walk == IntPtr.Zero) return;
+
+ WalkAddress = walk;
+ _walkInvoker2 = ClientInvoker.BuildRegisterInvoker(walk);
+ _walkInvoker4 = ClientInvoker.BuildRegisterInvoker4(walk);
+ }
+
+ ///
+ /// The manager slot is populated when the character enters the
+ /// world and nulled on the way out, so a null here means "not
+ /// in-world yet", not "signature wrong".
+ ///
+ public static bool TryGetManager(out IntPtr manager)
+ {
+ manager = IntPtr.Zero;
+ if (StaticAddress == IntPtr.Zero) return false;
+ if (!SafeMemory.TryReadIntPtr(StaticAddress, out var resolved)) return false;
+ if (resolved == IntPtr.Zero) return false;
+
+ manager = resolved;
+ return true;
+ }
+
+ public static bool TryGetPosition(out int id, out ushort x, out ushort y)
+ {
+ id = 0;
+ x = 0;
+ y = 0;
+
+ if (!TryGetPlayer(out var playerObject, out _)) return false;
+
+ return SafeMemory.TryReadInt32(playerObject + ObjectIdOffset, out id)
+ && SafeMemory.TryReadUInt16(playerObject + ObjectXOffset, out x)
+ && SafeMemory.TryReadUInt16(playerObject + ObjectYOffset, out y);
+ }
+
+ ///
+ /// Hex dump of client memory, for working out a struct layout when a
+ /// borrowed offset does not match this build.
+ ///
+ public static string Peek(IntPtr address, int length)
+ {
+ length = Math.Clamp(length, 1, 512);
+ if (!SafeMemory.IsReadable(address, length)) return "unreadable";
+
+ var bytes = (byte*)address;
+ var text = new System.Text.StringBuilder(length * 2);
+ for (var i = 0; i < length; i++)
+ {
+ text.Append(bytes[i].ToString("X2"));
+ }
+
+ return text.ToString();
+ }
+
+ ///
+ /// Walks the manager's fields looking for a pointer that leads to
+ /// something shaped like a map object — a non-zero id and a
+ /// coordinate pair inside map bounds. Reports every candidate rather
+ /// than picking one, since several offsets can look plausible and
+ /// only a comparison against the server's view settles it.
+ ///
+ public static string ScanForPlayerObject()
+ {
+ if (!TryGetManager(out var manager)) return "not-in-world";
+
+ var found = new List();
+ for (var offset = 0; offset <= 0x100; offset += 4)
+ {
+ if (!SafeMemory.TryReadIntPtr(manager + offset, out var candidate)) continue;
+ if (candidate == IntPtr.Zero) continue;
+ if (!SafeMemory.TryReadInt32(candidate + ObjectIdOffset, out var id)) continue;
+ if (!SafeMemory.TryReadUInt16(candidate + ObjectXOffset, out var x)) continue;
+ if (!SafeMemory.TryReadUInt16(candidate + ObjectYOffset, out var y)) continue;
+ if (id == 0 || x == 0 || y == 0 || x > 300 || y > 300) continue;
+
+ found.Add($"+0x{offset:X2}=>0x{candidate.ToInt64():X}:id={id},x={x},y={y}");
+ }
+
+ return found.Count == 0 ? "no-candidates" : string.Join(" ", found);
+ }
+
+ ///
+ /// Walk to a map cell. selects the call
+ /// shape: null uses the two-register form (manager, position), which
+ /// is what the client's own call sites appear to use; supplying a
+ /// pair passes them as the third register and one stack argument so
+ /// the four-argument form can be tried against a live client without
+ /// rebuilding the DLL.
+ ///
+ public static WalkResult Walk(ushort x, ushort y, (int Un0, int Un1)? extraArgs)
+ {
+ if (WalkAddress == IntPtr.Zero || _walkInvoker2 == IntPtr.Zero) return WalkResult.NoWalkFunction;
+ if (StaticAddress == IntPtr.Zero) return WalkResult.NoPlayerManager;
+ if (!TryGetManager(out var manager)) return WalkResult.NotInWorld;
+ if (!TryGetPlayer(out _, out _)) return WalkResult.NoCharacterLoaded;
+
+ var position = (y << 16) | x;
+
+ var invoked = NosThreadSynchronizer.Invoke(() =>
+ {
+ if (extraArgs is { } extra)
+ {
+ var walk4 = (delegate* unmanaged[Cdecl])_walkInvoker4;
+ walk4(manager, position, extra.Un0, extra.Un1);
+ }
+ else
+ {
+ var walk2 = (delegate* unmanaged[Cdecl])_walkInvoker2;
+ walk2(manager, position);
+ }
+ });
+
+ return invoked ? WalkResult.Ok : WalkResult.NoClientThread;
+ }
+}
diff --git a/src/NosCore.DeveloperTools.Hook/RuntimeBootstrap.cs b/src/NosCore.DeveloperTools.Hook/RuntimeBootstrap.cs
new file mode 100644
index 0000000..1ed9ca6
--- /dev/null
+++ b/src/NosCore.DeveloperTools.Hook/RuntimeBootstrap.cs
@@ -0,0 +1,287 @@
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+namespace NosCore.DeveloperTools.Hook;
+
+///
+/// Attaches an arbitrary client thread to this NativeAOT image before a
+/// detour enters managed code.
+///
+/// Every hook body is a reverse P/Invoke, and AOT's prologue expects a
+/// per-thread context in this module's static TLS slot. The loader only
+/// populates that slot for threads created after the DLL is loaded — and
+/// we inject into a running client, so every thread that matters already
+/// existed. A detour that fires on such a thread reads an empty slot and
+/// takes the client down with it.
+///
+/// Two detours were needed to see it: alone, each happened to fire only
+/// on threads that were fine; together, one of them reached a thread that
+/// was not, roughly twenty seconds in.
+///
+/// The stub below performs, in hand-written x86, what
+/// DLL_THREAD_ATTACH would have done: allocate this thread's TLS
+/// block, seed it from the image's template, publish it in the TEB, then
+/// run the image's TLS callbacks and entry point. It returns 1 when the
+/// thread is safe to enter and 0 when it is not, and the trampoline
+/// skips the managed hook entirely on 0 — dropping a packet is always
+/// better than killing the client.
+///
+internal static unsafe class RuntimeBootstrap
+{
+ private const uint TebThreadLocalStoragePointer = 0x2C;
+ private const uint HeapZeroMemory = 0x08;
+ private const uint DllThreadAttach = 2;
+ private const int MaxCallbacks = 8;
+
+ private const uint GetModuleHandleFromAddress = 0x00000004;
+ private const uint GetModuleHandleUnchangedRefcount = 0x00000002;
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern bool GetModuleHandleExW(uint flags, IntPtr address, out IntPtr module);
+
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Ansi)]
+ private static extern IntPtr GetModuleHandleA(string moduleName);
+
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Ansi)]
+ private static extern IntPtr GetProcAddress(IntPtr module, string functionName);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern IntPtr VirtualAlloc(IntPtr address, UIntPtr size, uint type, uint protect);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern bool VirtualProtect(IntPtr address, UIntPtr size, uint newProtect, out uint oldProtect);
+
+ [DllImport("kernel32.dll")]
+ private static extern IntPtr GetCurrentProcess();
+
+ [DllImport("kernel32.dll")]
+ private static extern bool FlushInstructionCache(IntPtr process, IntPtr address, UIntPtr size);
+
+ public static IntPtr Stub { get; private set; }
+
+ public static string Status { get; private set; } = "not-initialised";
+
+ /// Anchor used only to locate this image's base address.
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ private static int Anchor() => 0;
+
+ public static void Initialize()
+ {
+ if (Stub != IntPtr.Zero) return;
+
+ // The stub re-runs the image's TLS callbacks and DLL_THREAD_ATTACH
+ // by hand. That is right when the image was manual-mapped and the
+ // loader never did it, but we arrive via LoadLibrary, where the
+ // loader does it for every thread created after the load — so on
+ // those threads this would attach a second time. Kept switchable
+ // until that is proven harmless here.
+ if (Environment.GetEnvironmentVariable("_NC_BOOTSTRAP") == "0")
+ {
+ Status = "disabled";
+ return;
+ }
+
+ try
+ {
+ Status = Build();
+ }
+ catch (Exception ex)
+ {
+ Status = $"failed: {ex.Message}";
+ }
+ }
+
+ private static string Build()
+ {
+ delegate* anchor = &Anchor;
+ if (!GetModuleHandleExW(
+ GetModuleHandleFromAddress | GetModuleHandleUnchangedRefcount, (IntPtr)anchor, out var module)
+ || module == IntPtr.Zero)
+ {
+ return "no-module-handle";
+ }
+
+ var image = (byte*)module;
+ if (*(ushort*)image != 0x5A4D) return "not-a-pe";
+
+ var nt = image + *(int*)(image + 0x3C);
+ if (*(uint*)nt != 0x00004550) return "bad-nt-header";
+
+ var optional = nt + 0x18;
+ if (*(ushort*)optional != 0x010B) return "not-pe32";
+
+ var entryPointRva = *(uint*)(optional + 0x10);
+
+ // DataDirectory[9] is IMAGE_DIRECTORY_ENTRY_TLS; PE32 puts the
+ // directory array at OptionalHeader + 0x60.
+ var tlsDirectory = (uint*)(optional + 0x60 + (9 * 8));
+ if (tlsDirectory[0] == 0) return "no-tls-directory";
+
+ var tls = (uint*)(image + tlsDirectory[0]);
+ var rawDataStart = tls[0];
+ var rawDataEnd = tls[1];
+ var addressOfIndex = tls[2];
+ var addressOfCallbacks = tls[3];
+ var zeroFillSize = tls[4];
+
+ if (addressOfIndex == 0) return "no-tls-index";
+ var staticTlsIndex = *(uint*)addressOfIndex;
+ var rawDataSize = rawDataEnd > rawDataStart ? rawDataEnd - rawDataStart : 0;
+
+ var callbacks = stackalloc uint[MaxCallbacks];
+ var callbackCount = 0u;
+ if (addressOfCallbacks != 0)
+ {
+ var entry = (uint*)addressOfCallbacks;
+ while (callbackCount < MaxCallbacks && entry[callbackCount] != 0)
+ {
+ callbacks[callbackCount] = entry[callbackCount];
+ callbackCount++;
+ }
+ }
+
+ var kernel32 = GetModuleHandleA("kernel32.dll");
+ if (kernel32 == IntPtr.Zero) return "no-kernel32";
+ var getProcessHeap = GetProcAddress(kernel32, "GetProcessHeap");
+ var heapAlloc = GetProcAddress(kernel32, "HeapAlloc");
+ if (getProcessHeap == IntPtr.Zero || heapAlloc == IntPtr.Zero) return "no-heap-exports";
+
+ var code = Emit(
+ (uint)module, entryPointRva, staticTlsIndex, addressOfIndex, rawDataStart, rawDataSize, zeroFillSize,
+ callbacks, callbackCount, (uint)getProcessHeap, (uint)heapAlloc);
+
+ var stub = VirtualAlloc(IntPtr.Zero, (UIntPtr)(uint)code.Length, 0x1000 | 0x2000, 0x04);
+ if (stub == IntPtr.Zero) return "stub-alloc-failed";
+
+ Marshal.Copy(code, 0, stub, code.Length);
+ if (!VirtualProtect(stub, (UIntPtr)(uint)code.Length, 0x20, out _)) return "stub-protect-failed";
+ FlushInstructionCache(GetCurrentProcess(), stub, (UIntPtr)(uint)code.Length);
+
+ Stub = stub;
+ return $"ok tls-index={staticTlsIndex} callbacks={callbackCount} raw={rawDataSize} zero={zeroFillSize}";
+ }
+
+ private static byte[] Emit(
+ uint imageBase, uint entryPointRva, uint staticTlsIndex, uint indexValueAddr, uint rawDataAddr,
+ uint rawDataSize, uint zeroFillSize, uint* callbacks, uint callbackCount, uint getProcessHeap, uint heapAlloc)
+ {
+ var code = new List(256);
+
+ void U32(uint value) => code.AddRange(BitConverter.GetBytes(value));
+ void MovEax(uint value) { code.Add(0xB8); U32(value); }
+ void MovEcx(uint value) { code.Add(0xB9); U32(value); }
+ void MovEsi(uint value) { code.Add(0xBE); U32(value); }
+ void Push(uint value) { code.Add(0x68); U32(value); }
+ void CallEax() { code.Add(0xFF); code.Add(0xD0); }
+ void AddEax(byte value) { code.Add(0x83); code.Add(0xC0); code.Add(value); }
+
+ int JzNear() { code.Add(0x0F); code.Add(0x84); var at = code.Count; U32(0); return at; }
+ int JnzNear() { code.Add(0x0F); code.Add(0x85); var at = code.Count; U32(0); return at; }
+ void Patch(int at, int target)
+ {
+ var bytes = BitConverter.GetBytes(target - (at + 4));
+ for (var i = 0; i < bytes.Length; i++) code[at + i] = bytes[i];
+ }
+
+ code.Add(0x53);
+ code.Add(0x56);
+ code.Add(0x57);
+
+ var staticSlotOffset = staticTlsIndex * 4;
+ var blockSize = Math.Max(rawDataSize + zeroFillSize, 4u);
+
+ code.Add(0xC7); code.Add(0x05); U32(indexValueAddr); U32(staticTlsIndex);
+ code.Add(0x64); code.Add(0x8B); code.Add(0x35); U32(TebThreadLocalStoragePointer);
+ code.Add(0x85); code.Add(0xF6);
+ var noInitialTlsArray = JzNear();
+ code.Add(0x8B); code.Add(0x96); U32(staticSlotOffset);
+ code.Add(0x85); code.Add(0xD2);
+ var alreadyAttached = JnzNear();
+
+ Patch(noInitialTlsArray, code.Count);
+ MovEax(getProcessHeap);
+ CallEax();
+ Push(blockSize + 4);
+ Push(HeapZeroMemory);
+ code.Add(0x50);
+ MovEax(heapAlloc);
+ CallEax();
+ code.Add(0x85); code.Add(0xC0);
+ var failAlloc = JzNear();
+ // ntdll frees the TLS block by its allocation base, so keep it in
+ // the first dword and hand out the memory after it.
+ code.Add(0x89); code.Add(0x00);
+ AddEax(4);
+ code.Add(0x8B); code.Add(0xD0);
+
+ if (rawDataSize != 0)
+ {
+ code.Add(0x8B); code.Add(0xFA);
+ MovEsi(rawDataAddr);
+ MovEcx(rawDataSize);
+ code.Add(0xFC);
+ code.Add(0xF3); code.Add(0xA4);
+ }
+
+ code.Add(0x64); code.Add(0x8B); code.Add(0x35); U32(TebThreadLocalStoragePointer);
+ code.Add(0x85); code.Add(0xF6);
+ var hasTlsArray = JnzNear();
+ MovEax(getProcessHeap);
+ CallEax();
+ Push(8 + ((staticTlsIndex + 1) * 4));
+ Push(HeapZeroMemory);
+ code.Add(0x50);
+ MovEax(heapAlloc);
+ CallEax();
+ code.Add(0x85); code.Add(0xC0);
+ var failTlsArrayAlloc = JzNear();
+ code.Add(0xC7); code.Add(0x00); U32(staticTlsIndex + 1);
+ AddEax(8);
+ code.Add(0x8B); code.Add(0xF0);
+ code.Add(0x64); code.Add(0x89); code.Add(0x35); U32(TebThreadLocalStoragePointer);
+
+ Patch(hasTlsArray, code.Count);
+ code.Add(0x89); code.Add(0x96); U32(staticSlotOffset);
+ code.Add(0x8D); code.Add(0x72); code.Add(0x08);
+ code.Add(0xC7); code.Add(0x46); code.Add(0x30); U32(0xFFFFFFFF);
+
+ for (var i = 0; i < callbackCount; i++)
+ {
+ Push(0);
+ Push(DllThreadAttach);
+ Push(imageBase);
+ MovEax(callbacks[i]);
+ CallEax();
+ }
+
+ if (entryPointRva != 0)
+ {
+ Push(0);
+ Push(DllThreadAttach);
+ Push(imageBase);
+ MovEax(imageBase + entryPointRva);
+ CallEax();
+ }
+
+ // Deliberately do NOT rewrite the TLS slot here: the callbacks above
+ // stored AOT's own per-thread context in it, and that is exactly what
+ // the reverse P/Invoke prologue looks for on every later hook fire.
+ Patch(alreadyAttached, code.Count);
+ MovEax(1);
+ code.Add(0x5F);
+ code.Add(0x5E);
+ code.Add(0x5B);
+ code.Add(0xC3);
+
+ var fail = code.Count;
+ Patch(failAlloc, fail);
+ Patch(failTlsArrayAlloc, fail);
+ code.Add(0x33); code.Add(0xC0);
+ code.Add(0x5F);
+ code.Add(0x5E);
+ code.Add(0x5B);
+ code.Add(0xC3);
+
+ return code.ToArray();
+ }
+}
diff --git a/src/NosCore.DeveloperTools.Hook/SafeMemory.cs b/src/NosCore.DeveloperTools.Hook/SafeMemory.cs
new file mode 100644
index 0000000..3f3614c
--- /dev/null
+++ b/src/NosCore.DeveloperTools.Hook/SafeMemory.cs
@@ -0,0 +1,76 @@
+using System.Runtime.InteropServices;
+
+namespace NosCore.DeveloperTools.Hook;
+
+///
+/// Validity-checked reads of client memory. A signature that drifts
+/// resolves to a plausible-looking but wrong pointer, and dereferencing
+/// that inside the target raises an SEH access violation — which
+/// NativeAOT does not surface as a catchable .NET exception, so the
+/// client simply dies. Every pointer we derive from a scan therefore
+/// goes through first, turning a stale
+/// signature into an error message instead of a crash.
+///
+internal static unsafe class SafeMemory
+{
+ private const uint MemCommit = 0x1000;
+ private const uint PageNoAccess = 0x01;
+ private const uint PageGuard = 0x100;
+ private const uint ReadableMask = 0x02 | 0x04 | 0x08 | 0x20 | 0x40 | 0x80;
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct MemoryBasicInformation
+ {
+ public IntPtr BaseAddress;
+ public IntPtr AllocationBase;
+ public uint AllocationProtect;
+ public UIntPtr RegionSize;
+ public uint State;
+ public uint Protect;
+ public uint Type;
+ }
+
+ [DllImport("kernel32.dll")]
+ private static extern UIntPtr VirtualQuery(IntPtr address, out MemoryBasicInformation buffer, UIntPtr length);
+
+ public static bool IsReadable(IntPtr address, int size)
+ {
+ if (address == IntPtr.Zero || size <= 0) return false;
+
+ var queried = VirtualQuery(address, out var info, (UIntPtr)(uint)sizeof(MemoryBasicInformation));
+ if (queried == UIntPtr.Zero) return false;
+ if (info.State != MemCommit) return false;
+ if ((info.Protect & PageGuard) != 0) return false;
+ if ((info.Protect & PageNoAccess) != 0) return false;
+ if ((info.Protect & ReadableMask) == 0) return false;
+
+ // The requested span must not run past the end of this region —
+ // the next one up may be uncommitted.
+ var regionEnd = (ulong)info.BaseAddress + (ulong)info.RegionSize;
+ return (ulong)address + (ulong)size <= regionEnd;
+ }
+
+ public static bool TryReadIntPtr(IntPtr address, out IntPtr value)
+ {
+ value = IntPtr.Zero;
+ if (!IsReadable(address, sizeof(IntPtr))) return false;
+ value = *(IntPtr*)address;
+ return true;
+ }
+
+ public static bool TryReadInt32(IntPtr address, out int value)
+ {
+ value = 0;
+ if (!IsReadable(address, sizeof(int))) return false;
+ value = *(int*)address;
+ return true;
+ }
+
+ public static bool TryReadUInt16(IntPtr address, out ushort value)
+ {
+ value = 0;
+ if (!IsReadable(address, sizeof(ushort))) return false;
+ value = *(ushort*)address;
+ return true;
+ }
+}
diff --git a/src/NosCore.DeveloperTools.Hook/Signatures.cs b/src/NosCore.DeveloperTools.Hook/Signatures.cs
index fb600bc..e81185f 100644
--- a/src/NosCore.DeveloperTools.Hook/Signatures.cs
+++ b/src/NosCore.DeveloperTools.Hook/Signatures.cs
@@ -39,4 +39,78 @@ internal static class Signatures
// managed side can dereference [EBP-0x08] itself.
public const string LoginRecv =
"8D 45 F4 50 8D 55 F8 B1 20 8B 45 F8";
+
+ // Periodic: a function the client calls every frame from its main
+ // thread. We hook it purely as a scheduling tick — the detour body
+ // drains work queued by our pipe thread so client functions always
+ // execute on the thread that owns the game state.
+ //
+ // push ebp ; 55
+ // mov ebp, esp ; 8B EC
+ // push ebx ; 53
+ // push esi ; 56
+ // add esp, ; 83 C4 ??
+ //
+ // Only the first 5 bytes are displaced: `add esp, imm8` is 3 bytes,
+ // so a 6-byte detour would split it and the trampoline would return
+ // into the middle of an instruction.
+ public const string Periodic = "55 8B EC 53 56 83 C4";
+
+ public const int PeriodicPrologueSize = 5;
+
+ // PlayerManager: locates a code site that loads the manager's static
+ // slot, rather than the slot itself — the slot holds no distinctive
+ // bytes to scan for.
+ //
+ // xor ecx, ecx ; 33 C9
+ // mov edx, [ebp-0x04] ; 8B 55 FC
+ // mov eax, [] ; A1 ?? ?? ?? ??
+ // call <...> ; E8 ?? ?? ?? ??
+ //
+ // The A1 opcode sits at match+5, so its imm32 operand — the static
+ // address — is at match+6. That slot in turn holds the live manager
+ // pointer, which is null until the character is in-world.
+ public const string PlayerManager = "33 C9 8B 55 FC A1 ?? ?? ?? ?? E8 ?? ?? ?? ??";
+
+ public const int PlayerManagerStaticOperandOffset = 6;
+
+ // Walk: the client's own movement routine. Calling it (rather than
+ // emitting a `walk` packet ourselves) makes the client update its
+ // local position, run its animation, and compute the packet's
+ // checksum itself — so what reaches the server is byte-identical to
+ // a real player's movement.
+ //
+ // push ebp ; 55
+ // mov ebp, esp ; 8B EC
+ // add esp, -0x14 ; 83 C4 EC
+ // push ebx ; 53
+ // push esi ; 56
+ // push edi ; 57
+ // mov [ebp-0x06], cx ; 66 89 4D FA
+ //
+ // Delphi register convention: EAX = manager, EDX = packed position
+ // ((y << 16) | x). Never detoured, only called.
+ public const string PlayerWalk = "55 8B EC 83 C4 EC 53 56 57 66 89 4D FA";
+
+ // WorldConnect: opens the connection to a world server. This is the
+ // step behind the channel button on the selection screen — the client
+ // does not open its world socket until then, which is why nothing can
+ // be driven in-game before someone clicks.
+ //
+ // push ebx / esi / edi ; 53 56 57
+ // mov edi, ecx ; 8B F9 port
+ // mov esi, edx ; 8B F2 host string
+ // mov ebx, eax ; 8B D8 connection object
+ // mov eax, esi ; 8B C6
+ // call ; E8 ?? ?? ?? ??
+ // push eax ; 50
+ // call ; E8 ?? ?? ?? ??
+ //
+ // Delphi register convention: EAX = connection object, EDX = host as
+ // a Delphi AnsiString, ECX = port. Found by breaking on ws2_32
+ // connect and walking the call stack back into the client; the worker
+ // it tail-calls writes CX straight into the sockaddr as the port,
+ // which is what pins the argument order down.
+ public const string WorldConnect =
+ "53 56 57 8B F9 8B F2 8B D8 8B C6 E8 ?? ?? ?? ?? 50 E8 ?? ?? ?? ??";
}
diff --git a/src/NosCore.DeveloperTools.Hook/WorldConnection.cs b/src/NosCore.DeveloperTools.Hook/WorldConnection.cs
new file mode 100644
index 0000000..c6b44d6
--- /dev/null
+++ b/src/NosCore.DeveloperTools.Hook/WorldConnection.cs
@@ -0,0 +1,101 @@
+namespace NosCore.DeveloperTools.Hook;
+
+internal enum ConnectResult
+{
+ Ok,
+ NoConnectFunction,
+ NoContext,
+ NoClientThread,
+}
+
+///
+/// Opens the world connection the way the channel button does.
+///
+/// Until that button is pressed the client has no world socket at all,
+/// so none of the in-game control works — which made logging in the one
+/// step that still needed synthetic mouse clicks, with all the fragility
+/// that carries.
+///
+/// The routine takes the connection object in EAX, and that object is
+/// reached through the client's own UI state rather than a static slot.
+/// Rather than chase the pointer chain, the routine is detoured purely
+/// to record EAX the first time the client connects normally; from then
+/// on the same object can be reused to reconnect without touching the
+/// UI.
+///
+internal static unsafe class WorldConnection
+{
+ public static IntPtr ConnectAddress { get; private set; }
+
+ private static IntPtr _connectInvoker;
+ private static IntPtr _trampoline;
+
+ private static volatile IntPtr _context;
+ private static int _observed;
+
+ public static IntPtr Context => _context;
+
+ public static int Observed => System.Threading.Volatile.Read(ref _observed);
+
+ public static bool Install()
+ {
+ var address = PatternScanner.ScanMainModule(Signatures.WorldConnect);
+ if (address == IntPtr.Zero) return false;
+
+ ConnectAddress = address;
+ _connectInvoker = ClientInvoker.BuildRegisterInvoker3(address);
+
+ // Displace 5 bytes, not the default 6. The prologue is
+ // push ebx / push esi / push edi / mov edi,ecx — exactly five —
+ // and the next instruction is the two-byte mov esi,edx. Taking
+ // six would cut that in half, leaving an orphaned operand byte
+ // that the trampoline returns into; the routine then fails and
+ // the client retries it forever.
+ delegate* unmanaged[Stdcall] hook = &OnConnect;
+ _trampoline = Detour.Install(address, (IntPtr)hook, prologueSize: 5, arg: Detour.HookArg.EaxThenEdx);
+ return _trampoline != IntPtr.Zero;
+ }
+
+ [System.Runtime.InteropServices.UnmanagedCallersOnly(
+ CallConvs = new[] { typeof(System.Runtime.CompilerServices.CallConvStdcall) })]
+ private static void OnConnect(IntPtr eax, IntPtr edx)
+ {
+ _context = eax;
+ var seen = System.Threading.Interlocked.Increment(ref _observed);
+
+ try
+ {
+ // Announce only the first few. If a detour ever corrupts this
+ // routine the client retries it thousands of times a second,
+ // and logging each one buries everything else.
+ if (seen <= 3)
+ {
+ var host = DelphiString.Read(edx);
+ PipeServer.Announce($"world connect observed: object=0x{eax.ToInt64():X} host={host}");
+ }
+ }
+ catch
+ {
+ // Never throw out of a hook — the target would crash.
+ }
+ }
+
+ public static ConnectResult Connect(string host, int port)
+ {
+ if (ConnectAddress == IntPtr.Zero || _connectInvoker == IntPtr.Zero) return ConnectResult.NoConnectFunction;
+
+ var context = _context;
+ if (context == IntPtr.Zero) return ConnectResult.NoContext;
+
+ var hostString = ClientInvoker.AllocAnsiString(host);
+ var invoker = _connectInvoker;
+
+ var invoked = NosThreadSynchronizer.Invoke(() =>
+ {
+ var connect = (delegate* unmanaged[Cdecl])invoker;
+ connect(context, hostString, port);
+ });
+
+ return invoked ? ConnectResult.Ok : ConnectResult.NoClientThread;
+ }
+}
diff --git a/src/NosCore.DeveloperTools/Forms/MainForm.cs b/src/NosCore.DeveloperTools/Forms/MainForm.cs
index 820bbb6..515ac45 100644
--- a/src/NosCore.DeveloperTools/Forms/MainForm.cs
+++ b/src/NosCore.DeveloperTools/Forms/MainForm.cs
@@ -42,6 +42,20 @@ public sealed class MainForm : Form
private readonly Button _injectSendButton = new() { Text = "Send", AutoSize = true };
private readonly Button _injectRecvButton = new() { Text = "Recv", AutoSize = true };
+ private readonly TextBox _walkXBox = new() { Width = 52, PlaceholderText = "x" };
+ private readonly TextBox _walkYBox = new() { Width = 52, PlaceholderText = "y" };
+ private readonly Button _walkButton = new() { Text = "Walk", AutoSize = true };
+ private readonly Button _positionButton = new() { Text = "Where am I?", AutoSize = true };
+ private readonly Button _diagButton = new() { Text = "Hook diagnostics", AutoSize = true };
+ private readonly TextBox _controlReplyBox = new()
+ {
+ Dock = DockStyle.Fill,
+ Multiline = true,
+ ReadOnly = true,
+ ScrollBars = ScrollBars.Vertical,
+ Font = new Font(FontFamily.GenericMonospace, 8.25f),
+ };
+
public MainForm(
SettingsService settingsService,
ProcessService processService,
@@ -196,6 +210,7 @@ private TabPage BuildPacketsTab()
toolbar.Controls.Add(_clearButton);
var injectBar = BuildInjectBar();
+ var controlBar = BuildControlBar();
var subTabs = new TabControl { Dock = DockStyle.Fill };
var logPage = new TabPage("Log");
@@ -209,10 +224,78 @@ private TabPage BuildPacketsTab()
page.Controls.Add(subTabs);
page.Controls.Add(injectBar);
+ page.Controls.Add(controlBar);
page.Controls.Add(toolbar);
return page;
}
+ ///
+ /// Client control: movement and state read through the client's own
+ /// routines, as opposed to the inject bar above which pushes raw
+ /// packets past them.
+ ///
+ private Control BuildControlBar()
+ {
+ var panel = new TableLayoutPanel
+ {
+ Dock = DockStyle.Bottom,
+ AutoSize = true,
+ AutoSizeMode = AutoSizeMode.GrowAndShrink,
+ ColumnCount = 1,
+ RowCount = 2,
+ Padding = new Padding(4),
+ };
+ panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
+ panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 58));
+
+ var buttons = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill };
+ buttons.Controls.Add(new Label { Text = "Walk to", AutoSize = true, Padding = new Padding(0, 6, 0, 0) });
+ buttons.Controls.Add(_walkXBox);
+ buttons.Controls.Add(_walkYBox);
+ buttons.Controls.Add(_walkButton);
+ buttons.Controls.Add(_positionButton);
+ buttons.Controls.Add(_diagButton);
+
+ _walkButton.Click += (_, _) => Walk();
+ _positionButton.Click += (_, _) => SendControlCommand(_injection.RequestPosition());
+ _diagButton.Click += (_, _) => SendControlCommand(_injection.RequestDiagnostics());
+ _walkYBox.KeyDown += (_, e) =>
+ {
+ if (e.KeyCode != Keys.Enter) return;
+ _walkButton.PerformClick();
+ e.SuppressKeyPress = true;
+ };
+
+ _controlReplyBox.Dock = DockStyle.Fill;
+ panel.Controls.Add(buttons, 0, 0);
+ panel.Controls.Add(_controlReplyBox, 0, 1);
+ return panel;
+ }
+
+ private void Walk()
+ {
+ if (!ushort.TryParse(_walkXBox.Text.Trim(), out var x) || !ushort.TryParse(_walkYBox.Text.Trim(), out var y))
+ {
+ AppendControlReply("walk needs numeric x and y");
+ return;
+ }
+
+ SendControlCommand(_injection.Walk(x, y));
+ }
+
+ private void SendControlCommand(bool sent)
+ {
+ if (!sent)
+ {
+ AppendControlReply("not attached — no hook session");
+ }
+ }
+
+ private void AppendControlReply(string line)
+ {
+ _controlReplyBox.AppendText(line + Environment.NewLine);
+ }
+
private Control BuildInjectBar()
{
var row = new TableLayoutPanel
@@ -883,6 +966,7 @@ private void WireEvents()
_log.Add(args.Packet);
};
_injection.StatusChanged += (_, msg) => BeginInvoke(() => _statusLabel.Text = msg);
+ _injection.ControlReplyReceived += (_, line) => BeginInvoke(() => AppendControlReply(line));
_flushTimer.Tick += (_, _) => FlushPendingPackets();
_flushTimer.Start();
diff --git a/src/NosCore.DeveloperTools/Remote/RemoteAttachmentService.cs b/src/NosCore.DeveloperTools/Remote/RemoteAttachmentService.cs
index 5aac69b..37be44a 100644
--- a/src/NosCore.DeveloperTools/Remote/RemoteAttachmentService.cs
+++ b/src/NosCore.DeveloperTools/Remote/RemoteAttachmentService.cs
@@ -22,6 +22,8 @@ public sealed class RemoteAttachmentService : IInjectionService
public event EventHandler? NosMallUrlReceived;
+ public event EventHandler? ControlReplyReceived;
+
public bool IsAttached => _process is not null;
public int? AttachedProcessId => _process?.ProcessId;
@@ -95,6 +97,55 @@ public bool RequestNosMallUrl()
return _session.SendCommand("NOSMALLURL");
}
+ public bool Walk(ushort x, ushort y, int? un0 = null, int? un1 = null)
+ {
+ if (_session is null) return false;
+ var command = un0 is { } a && un1 is { } b ? $"WALK {x} {y} {a} {b}" : $"WALK {x} {y}";
+ return _session.SendCommand(command);
+ }
+
+ public bool RequestPosition()
+ {
+ if (_session is null) return false;
+ return _session.SendCommand("POS");
+ }
+
+ public bool RequestDiagnostics()
+ {
+ if (_session is null) return false;
+ return _session.SendCommand("DIAG");
+ }
+
+ public bool RequestPlayerScan()
+ {
+ if (_session is null) return false;
+ return _session.SendCommand("SCANPLAYER");
+ }
+
+ public bool RequestPeek(long address, int length)
+ {
+ if (_session is null) return false;
+ return _session.SendCommand($"PEEK {address:X} {length}");
+ }
+
+ public bool RequestWindow(string mode)
+ {
+ if (_session is null) return false;
+ return _session.SendCommand(string.IsNullOrWhiteSpace(mode) ? "WINDOW" : $"WINDOW {mode}");
+ }
+
+ public bool RequestClick(int x, int y)
+ {
+ if (_session is null) return false;
+ return _session.SendCommand($"CLICK {x} {y}");
+ }
+
+ public bool RequestConnect(string host, int port)
+ {
+ if (_session is null) return false;
+ return _session.SendCommand($"CONNECT {host} {port}");
+ }
+
public async Task DetachAsync()
{
await DetachInternalAsync();
@@ -146,6 +197,19 @@ private void OnPipeLine(string line)
return;
}
+ if (line.StartsWith("POS", StringComparison.Ordinal)
+ || line.StartsWith("WALKRESULT ", StringComparison.Ordinal)
+ || line.StartsWith("DIAG ", StringComparison.Ordinal)
+ || line.StartsWith("SCANPLAYER ", StringComparison.Ordinal)
+ || line.StartsWith("PEEK ", StringComparison.Ordinal)
+ || line.StartsWith("WINDOW ", StringComparison.Ordinal)
+ || line.StartsWith("CLICK ", StringComparison.Ordinal)
+ || line.StartsWith("CONNECTRESULT ", StringComparison.Ordinal))
+ {
+ ControlReplyReceived?.Invoke(this, line);
+ return;
+ }
+
// "PACKET " — 11 chars minimum (inclusive of one payload char).
if (line.StartsWith("PACKET ", StringComparison.Ordinal) && line.Length >= 12)
{
diff --git a/src/NosCore.DeveloperTools/Services/InjectionService.cs b/src/NosCore.DeveloperTools/Services/InjectionService.cs
index f1c4855..a845f2b 100644
--- a/src/NosCore.DeveloperTools/Services/InjectionService.cs
+++ b/src/NosCore.DeveloperTools/Services/InjectionService.cs
@@ -20,6 +20,13 @@ public interface IInjectionService : IDisposable
event EventHandler? NosMallUrlReceived;
+ ///
+ /// Replies to client-control commands (position, walk outcome,
+ /// hook diagnostics), delivered verbatim so they can be read or
+ /// parsed rather than being folded into the status line.
+ ///
+ event EventHandler? ControlReplyReceived;
+
bool IsAttached { get; }
int? AttachedProcessId { get; }
@@ -42,4 +49,59 @@ public interface IInjectionService : IDisposable
/// sent (a pipe session is active); false otherwise.
///
bool RequestNosMallUrl();
+
+ ///
+ /// Move the character by calling the client's own walk routine, so
+ /// the client updates its local position and builds the outgoing
+ /// packet itself. Outcome arrives via
+ /// as a WALKRESULT line.
+ ///
+ /// / switch the call
+ /// to the four-argument form; the client's own call sites appear to
+ /// use only the two register arguments, so leaving them null is the
+ /// normal path.
+ ///
+ bool Walk(ushort x, ushort y, int? un0 = null, int? un1 = null);
+
+ ///
+ /// Ask for the character's live position. Answer arrives via
+ /// as POS id x y , or
+ /// POS unavailable when not in-world.
+ ///
+ bool RequestPosition();
+
+ ///
+ /// Ask the hook which signatures resolved and whether the client
+ /// thread is ticking. Answer arrives as a DIAG line.
+ ///
+ bool RequestDiagnostics();
+
+ ///
+ /// Ask the hook which of the player manager's fields lead to
+ /// something shaped like a map object. Used to recover the player
+ /// object's offset when a borrowed one does not fit this build.
+ ///
+ bool RequestPlayerScan();
+
+ ///
+ /// Hex dump of client memory, for reading a struct layout directly.
+ ///
+ bool RequestPeek(long address, int length);
+
+ ///
+ /// Restore and focus the client window, or report its geometry.
+ /// Runs inside the client because the launcher elevates it, and
+ /// UIPI drops window calls that come from lower integrity.
+ ///
+ bool RequestWindow(string mode);
+
+ /// Post a left click in client coordinates.
+ bool RequestClick(int x, int y);
+
+ ///
+ /// Open the world connection the way the channel button does,
+ /// reusing the connection object recorded the first time the
+ /// client connected on its own.
+ ///
+ bool RequestConnect(string host, int port);
}