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); }