From fa365159f5ae4ee605cf562eb2c862c93ddef16b Mon Sep 17 00:00:00 2001 From: erwan-joly Date: Thu, 10 Sep 2026 21:32:26 +1200 Subject: [PATCH 01/11] feat: drive the client's own walk routine from the hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Movement injected as a `walk` packet only moves the character server-side: the client keeps rendering the old position and every packet it originates afterwards carries stale coordinates. It also needs the checksum byte the client computes, which we cannot reproduce. Call the client's own walk routine instead. It updates local state, animates, and builds the packet itself, so what reaches the server is indistinguishable from a real player's movement. Client state is unsynchronised, so every client call is now marshalled onto the client's own thread through a per-frame periodic detour. Packet injection takes that path too — it was calling into the client from the pipe thread — and falls back to a direct call only when the periodic signature fails to resolve. Pointers derived from a signature scan go through a VirtualQuery guard first: a drifted signature raises an SEH access violation that NativeAOT will not surface as a catchable exception, so an unchecked dereference kills the client instead of reporting a stale pattern. Adds WALK / POS / DIAG pipe commands and wires them to the Packets tab. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 12 ++ .../ClientInvoker.cs | 65 ++++++++++ src/NosCore.DeveloperTools.Hook/Detour.cs | 12 +- src/NosCore.DeveloperTools.Hook/HookEntry.cs | 2 + src/NosCore.DeveloperTools.Hook/Hooks.cs | 73 ++++++++++- .../NosThreadSynchronizer.cs | 116 +++++++++++++++++ src/NosCore.DeveloperTools.Hook/PipeServer.cs | 86 +++++++++++++ .../PlayerManager.cs | 117 ++++++++++++++++++ src/NosCore.DeveloperTools.Hook/SafeMemory.cs | 76 ++++++++++++ src/NosCore.DeveloperTools.Hook/Signatures.cs | 52 ++++++++ src/NosCore.DeveloperTools/Forms/MainForm.cs | 84 +++++++++++++ .../Remote/RemoteAttachmentService.cs | 29 +++++ .../Services/InjectionService.cs | 33 +++++ 13 files changed, 754 insertions(+), 3 deletions(-) create mode 100644 src/NosCore.DeveloperTools.Hook/NosThreadSynchronizer.cs create mode 100644 src/NosCore.DeveloperTools.Hook/PlayerManager.cs create mode 100644 src/NosCore.DeveloperTools.Hook/SafeMemory.cs diff --git a/README.md b/README.md index 5cac571..819a1b6 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,18 @@ 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. + ### 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.Hook/ClientInvoker.cs b/src/NosCore.DeveloperTools.Hook/ClientInvoker.cs index 95c44bf..473d2b8 100644 --- a/src/NosCore.DeveloperTools.Hook/ClientInvoker.cs +++ b/src/NosCore.DeveloperTools.Hook/ClientInvoker.cs @@ -73,6 +73,71 @@ public static IntPtr BuildRegisterInvoker(IntPtr target) 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/Detour.cs b/src/NosCore.DeveloperTools.Hook/Detour.cs index 490d5a3..9e3a571 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) @@ -70,7 +73,12 @@ public static IntPtr Install(IntPtr target, IntPtr hook, int prologueSize = 6, H 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 argsSize = arg switch + { + HookArg.EaxThenEdx => 2, + HookArg.None => 0, + _ => 1, + }; var trampolineSize = 1 + 1 + argsSize + 5 + 1 + 1 + prologueSize + 5; var trampoline = VirtualAlloc(IntPtr.Zero, (UIntPtr)trampolineSize, AllocationType.Commit | AllocationType.Reserve, MemoryProtection.ReadWrite); @@ -92,6 +100,8 @@ public static IntPtr Install(IntPtr target, IntPtr hook, int prologueSize = 6, H case HookArg.Ebp: t[pos++] = 0x55; // PUSH EBP 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. diff --git a/src/NosCore.DeveloperTools.Hook/HookEntry.cs b/src/NosCore.DeveloperTools.Hook/HookEntry.cs index 421802e..e532695 100644 --- a/src/NosCore.DeveloperTools.Hook/HookEntry.cs +++ b/src/NosCore.DeveloperTools.Hook/HookEntry.cs @@ -64,6 +64,8 @@ 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)}"); Hooks.StartNosMallPoller(); } catch (Exception ex) diff --git a/src/NosCore.DeveloperTools.Hook/Hooks.cs b/src/NosCore.DeveloperTools.Hook/Hooks.cs index 4d9500c..3371181 100644 --- a/src/NosCore.DeveloperTools.Hook/Hooks.cs +++ b/src/NosCore.DeveloperTools.Hook/Hooks.cs @@ -25,10 +25,12 @@ internal static unsafe class Hooks public static readonly ConcurrentQueue Queue = 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. @@ -78,9 +80,57 @@ public static InstallResult Install() result.LoginRecvHooked = _loginRecvTrampoline != IntPtr.Zero; } + var periodicAddr = PatternScanner.ScanMainModule(Signatures.Periodic); + 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(); + } + } + + 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 +201,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 +220,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 +263,10 @@ private static void Capture(PacketDirection direction, PacketConnection connecti internal struct InstallResult { + 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..5c5e40d 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.Queue.Enqueue(new CapturedPacket(PacketDirection.Status, PacketConnection.World, line)); + } + public static void Run() { var pipeName = $"NosCore.DeveloperTools.{Environment.ProcessId}"; @@ -110,6 +119,24 @@ 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; + } + // "INJECT " — 11 chars minimum before payload. if (!line.StartsWith("INJECT ", StringComparison.Ordinal) || line.Length < 12) return; @@ -151,6 +178,65 @@ 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.NoClientThread => "client-thread-unavailable", + _ => "unknown", + }; + Reply("WALKRESULT " + reason); + } + + 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 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}"); + } + + 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..80878a1 --- /dev/null +++ b/src/NosCore.DeveloperTools.Hook/PlayerManager.cs @@ -0,0 +1,117 @@ +namespace NosCore.DeveloperTools.Hook; + +internal enum WalkResult +{ + Ok, + NoWalkFunction, + NoPlayerManager, + NotInWorld, + 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 ObjectIdOffset = 0x08; + private const int ObjectXOffset = 0x0C; + private const int ObjectYOffset = 0x0E; + + 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 (!TryGetManager(out var manager)) return false; + if (!SafeMemory.TryReadIntPtr(manager + PlayerObjectOffset, out var playerObject)) return false; + if (playerObject == IntPtr.Zero) return false; + + return SafeMemory.TryReadInt32(playerObject + ObjectIdOffset, out id) + && SafeMemory.TryReadUInt16(playerObject + ObjectXOffset, out x) + && SafeMemory.TryReadUInt16(playerObject + ObjectYOffset, out y); + } + + /// + /// 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; + + 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/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..ae51935 100644 --- a/src/NosCore.DeveloperTools.Hook/Signatures.cs +++ b/src/NosCore.DeveloperTools.Hook/Signatures.cs @@ -39,4 +39,56 @@ 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"; } 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..5d2c027 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,25 @@ 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 async Task DetachAsync() { await DetachInternalAsync(); @@ -146,6 +167,14 @@ private void OnPipeLine(string line) return; } + if (line.StartsWith("POS", StringComparison.Ordinal) + || line.StartsWith("WALKRESULT ", StringComparison.Ordinal) + || line.StartsWith("DIAG ", 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..e22649c 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,30 @@ 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(); } From 7813a9dc38720bc166e04f6547d8cdcc75371eeb Mon Sep 17 00:00:00 2001 From: erwan-joly Date: Fri, 11 Sep 2026 10:43:01 +1200 Subject: [PATCH 02/11] feat: headless HTTP driver for the client Testing a server change meant clicking through the GUI: sign in, pick a process, attach, log in, select a character, then read a reply box. That is the slow half of the loop and none of it needs a human. NosCore.DeveloperTools.Cli holds the pipe session open and exposes it over localhost HTTP, so the whole sequence runs from the command line: POST /launch auth against NosCore, start the patched client POST /attach inject the hook, open the pipe POST /inject raw packet injection (drives character select) GET /diag resolved signatures, tick count, in-world state GET /pos live character id and coordinates POST /walk movement through the client's own routine GET /packets captured traffic, cursor-paged GET /log hook status lines Command replies arrive on the pipe as unsolicited lines, so the driver registers a waiter before sending and turns each into an awaitable result rather than making callers poll the log. Also adds PEEK and SCANPLAYER to the hook. Both are for recovering a struct offset when a borrowed one does not fit the build in front of you, which is what happens with the player object: manager+0x20 is null on this client, so position reads report unavailable instead of crashing. Co-Authored-By: Claude Opus 5 (1M context) --- NosCore.DeveloperTools.sln | 17 +- .../ClientDriver.cs | 238 ++++++++++++++++++ .../ControlServer.cs | 212 ++++++++++++++++ .../NosCore.DeveloperTools.Cli.csproj | 30 +++ src/NosCore.DeveloperTools.Cli/Program.cs | 48 ++++ src/NosCore.DeveloperTools.Cli/app.manifest | 18 ++ src/NosCore.DeveloperTools.Hook/PipeServer.cs | 38 +++ .../PlayerManager.cs | 46 ++++ .../Remote/RemoteAttachmentService.cs | 16 +- .../Services/InjectionService.cs | 12 + 10 files changed, 673 insertions(+), 2 deletions(-) create mode 100644 src/NosCore.DeveloperTools.Cli/ClientDriver.cs create mode 100644 src/NosCore.DeveloperTools.Cli/ControlServer.cs create mode 100644 src/NosCore.DeveloperTools.Cli/NosCore.DeveloperTools.Cli.csproj create mode 100644 src/NosCore.DeveloperTools.Cli/Program.cs create mode 100644 src/NosCore.DeveloperTools.Cli/app.manifest 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/src/NosCore.DeveloperTools.Cli/ClientDriver.cs b/src/NosCore.DeveloperTools.Cli/ClientDriver.cs new file mode 100644 index 0000000..b5f7b62 --- /dev/null +++ b/src/NosCore.DeveloperTools.Cli/ClientDriver.cs @@ -0,0 +1,238 @@ +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); + } + } + + /// + /// 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, + 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; + + _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 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(60); + while (DateTime.UtcNow < deadline) + { + _client.Refresh(); + if (_client.HasExited) throw new InvalidOperationException("Client exited before it could be attached."); + if (_client.MainWindowHandle != IntPtr.Zero) return _client.Id; + await Task.Delay(250, ct); + } + + throw new TimeoutException("Client never opened a 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..d34a87d --- /dev/null +++ b/src/NosCore.DeveloperTools.Cli/ControlServer.cs @@ -0,0 +1,212 @@ +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"), 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)) }, + "/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 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/NosCore.DeveloperTools.Cli.csproj b/src/NosCore.DeveloperTools.Cli/NosCore.DeveloperTools.Cli.csproj new file mode 100644 index 0000000..46e972a --- /dev/null +++ b/src/NosCore.DeveloperTools.Cli/NosCore.DeveloperTools.Cli.csproj @@ -0,0 +1,30 @@ + + + + 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/Program.cs b/src/NosCore.DeveloperTools.Cli/Program.cs new file mode 100644 index 0000000..bed15a5 --- /dev/null +++ b/src/NosCore.DeveloperTools.Cli/Program.cs @@ -0,0 +1,48 @@ +namespace NosCore.DeveloperTools.Cli; + +internal static class Program +{ + private const int DefaultPort = 8787; + + [STAThread] + private static async Task Main(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(" POST /launch {password} auth + start the patched client"); + Console.WriteLine(" POST /attach {pid?} inject the hook, open the pipe"); + Console.WriteLine(" GET /diag resolved signatures, tick count, in-world"); + Console.WriteLine(" GET /pos live character id and coordinates"); + Console.WriteLine(" POST /walk {x, y} move via the client's own routine"); + Console.WriteLine(" POST /inject {payload, direction} raw packet injection"); + Console.WriteLine(" GET /packets?since=N&contains= captured traffic"); + Console.WriteLine(" GET /log?since=N hook status lines"); + Console.WriteLine(" GET /quit stop the driver"); + + 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/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/PipeServer.cs b/src/NosCore.DeveloperTools.Hook/PipeServer.cs index 5c5e40d..d23bfe5 100644 --- a/src/NosCore.DeveloperTools.Hook/PipeServer.cs +++ b/src/NosCore.DeveloperTools.Hook/PipeServer.cs @@ -137,6 +137,24 @@ private static void HandleCommand(string line) 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; + } + // "INJECT " — 11 chars minimum before payload. if (!line.StartsWith("INJECT ", StringComparison.Ordinal) || line.Length < 12) return; @@ -213,6 +231,26 @@ private static void HandleWalk(string args) 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)) diff --git a/src/NosCore.DeveloperTools.Hook/PlayerManager.cs b/src/NosCore.DeveloperTools.Hook/PlayerManager.cs index 80878a1..6b10ed6 100644 --- a/src/NosCore.DeveloperTools.Hook/PlayerManager.cs +++ b/src/NosCore.DeveloperTools.Hook/PlayerManager.cs @@ -82,6 +82,52 @@ public static bool TryGetPosition(out int id, out ushort x, out ushort y) && 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 diff --git a/src/NosCore.DeveloperTools/Remote/RemoteAttachmentService.cs b/src/NosCore.DeveloperTools/Remote/RemoteAttachmentService.cs index 5d2c027..66100b3 100644 --- a/src/NosCore.DeveloperTools/Remote/RemoteAttachmentService.cs +++ b/src/NosCore.DeveloperTools/Remote/RemoteAttachmentService.cs @@ -116,6 +116,18 @@ public bool RequestDiagnostics() 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 async Task DetachAsync() { await DetachInternalAsync(); @@ -169,7 +181,9 @@ private void OnPipeLine(string line) if (line.StartsWith("POS", StringComparison.Ordinal) || line.StartsWith("WALKRESULT ", StringComparison.Ordinal) - || line.StartsWith("DIAG ", StringComparison.Ordinal)) + || line.StartsWith("DIAG ", StringComparison.Ordinal) + || line.StartsWith("SCANPLAYER ", StringComparison.Ordinal) + || line.StartsWith("PEEK ", StringComparison.Ordinal)) { ControlReplyReceived?.Invoke(this, line); return; diff --git a/src/NosCore.DeveloperTools/Services/InjectionService.cs b/src/NosCore.DeveloperTools/Services/InjectionService.cs index e22649c..353621a 100644 --- a/src/NosCore.DeveloperTools/Services/InjectionService.cs +++ b/src/NosCore.DeveloperTools/Services/InjectionService.cs @@ -75,4 +75,16 @@ public interface IInjectionService : IDisposable /// 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); } From b082547668da3afd1f0d4097248d05017969450b Mon Sep 17 00:00:00 2001 From: erwan-joly Date: Fri, 11 Sep 2026 10:54:53 +1200 Subject: [PATCH 03/11] fix: refuse to walk before a character is loaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calling the walk routine with no character in the world killed the client outright. The manager pointer is non-null from the moment the client builds its game scene, which is well before any character exists, so "manager resolved" was never enough to make the call safe — at that point the player slot is null and the id reads -1, and the routine dereferences the player. Gate movement and position reads on the player object instead, and report player/player-id in DIAG so the distinction is visible before anything is called. The same walk that previously crashed the client now returns no-character-loaded and leaves it running. Co-Authored-By: Claude Opus 5 (1M context) --- src/NosCore.DeveloperTools.Hook/PipeServer.cs | 5 +++- .../PlayerManager.cs | 29 +++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/NosCore.DeveloperTools.Hook/PipeServer.cs b/src/NosCore.DeveloperTools.Hook/PipeServer.cs index d23bfe5..213a7aa 100644 --- a/src/NosCore.DeveloperTools.Hook/PipeServer.cs +++ b/src/NosCore.DeveloperTools.Hook/PipeServer.cs @@ -225,6 +225,7 @@ private static void HandleWalk(string args) 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", }; @@ -266,11 +267,13 @@ 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}"); + $"walk={Fmt(install.WalkAddress)} in-world={inWorld} " + + $"player={Fmt(player)} player-id={playerId} character-loaded={loaded}"); } private static string Fmt(IntPtr address) => address == IntPtr.Zero ? "none" : $"0x{address.ToInt64():X}"; diff --git a/src/NosCore.DeveloperTools.Hook/PlayerManager.cs b/src/NosCore.DeveloperTools.Hook/PlayerManager.cs index 6b10ed6..14f768d 100644 --- a/src/NosCore.DeveloperTools.Hook/PlayerManager.cs +++ b/src/NosCore.DeveloperTools.Hook/PlayerManager.cs @@ -6,6 +6,7 @@ internal enum WalkResult NoWalkFunction, NoPlayerManager, NotInWorld, + NoCharacterLoaded, NoClientThread, } @@ -23,10 +24,33 @@ internal enum WalkResult 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; } @@ -73,9 +97,7 @@ public static bool TryGetPosition(out int id, out ushort x, out ushort y) x = 0; y = 0; - if (!TryGetManager(out var manager)) return false; - if (!SafeMemory.TryReadIntPtr(manager + PlayerObjectOffset, out var playerObject)) return false; - if (playerObject == IntPtr.Zero) return false; + if (!TryGetPlayer(out var playerObject, out _)) return false; return SafeMemory.TryReadInt32(playerObject + ObjectIdOffset, out id) && SafeMemory.TryReadUInt16(playerObject + ObjectXOffset, out x) @@ -141,6 +163,7 @@ 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; From 3890f5f2a97363cfa9a9a746823ef6b418b74755 Mon Sep 17 00:00:00 2001 From: erwan-joly Date: Fri, 11 Sep 2026 11:38:53 +1200 Subject: [PATCH 04/11] feat: see and click the client, and stop killing it on attach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Getting a character in-world was blocked on the server-selection screen, which needs a click before the client ever opens its world connection. Nothing in the driver could see that screen or act on it. Three things were in the way, none of them the game: Attach timing. WaitForClientAsync returned as soon as the process had any window, and the client's first window is a zero-size helper that exists almost immediately — so we injected into a barely-initialised process and killed it. That is what left a trail of window-less clients. Wait for a real game-sized window instead. Window identity. Process.MainWindowHandle names that same zero-size window, not the 1686x1090 'NosTale' one, so every rect and screenshot was of the wrong thing. Pick the largest captioned top-level window the process owns. DPI. The client is DPI-aware and reports physical pixels; this process was not, so on a 150%-scaled display a point read off a screenshot was clicked a few hundred pixels away. Declare per-monitor v2. With those fixed, /screenshot captures the client window alone — PrintWindow first, falling back to a screen grab when an accelerated surface refuses to render into the DC — and /click drives the real cursor, since the client reads the mouse below the window-message layer and never sees a posted WM_LBUTTONDOWN. Both work without the hook attached, which is also how we test whether the hook is at fault. Co-Authored-By: Claude Opus 5 (1M context) --- .../ClientDriver.cs | 85 ++++++++- .../ControlServer.cs | 18 ++ src/NosCore.DeveloperTools.Cli/Input.cs | 75 ++++++++ .../ProcessWindows.cs | 70 ++++++++ src/NosCore.DeveloperTools.Cli/Program.cs | 23 +++ src/NosCore.DeveloperTools.Cli/Screenshot.cs | 137 ++++++++++++++ .../ClientWindow.cs | 170 ++++++++++++++++++ src/NosCore.DeveloperTools.Hook/PipeServer.cs | 26 +++ .../Remote/RemoteAttachmentService.cs | 16 +- .../Services/InjectionService.cs | 10 ++ 10 files changed, 626 insertions(+), 4 deletions(-) create mode 100644 src/NosCore.DeveloperTools.Cli/Input.cs create mode 100644 src/NosCore.DeveloperTools.Cli/ProcessWindows.cs create mode 100644 src/NosCore.DeveloperTools.Cli/Screenshot.cs create mode 100644 src/NosCore.DeveloperTools.Hook/ClientWindow.cs diff --git a/src/NosCore.DeveloperTools.Cli/ClientDriver.cs b/src/NosCore.DeveloperTools.Cli/ClientDriver.cs index b5f7b62..e5bd696 100644 --- a/src/NosCore.DeveloperTools.Cli/ClientDriver.cs +++ b/src/NosCore.DeveloperTools.Cli/ClientDriver.cs @@ -167,6 +167,76 @@ public async Task PeekAsync(long address, int length, TimeSpan timeout) 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 bool Inject(PacketDirection direction, PacketConnection connection, string payload) => _injection.InjectPacket(direction, connection, payload); @@ -180,16 +250,25 @@ 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(60); + 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 (_client.MainWindowHandle != IntPtr.Zero) return _client.Id; + + 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 a window."); + throw new TimeoutException("Client never opened its game window."); } private Waiter Expect(string prefix) diff --git a/src/NosCore.DeveloperTools.Cli/ControlServer.cs b/src/NosCore.DeveloperTools.Cli/ControlServer.cs index d34a87d..a2e20fa 100644 --- a/src/NosCore.DeveloperTools.Cli/ControlServer.cs +++ b/src/NosCore.DeveloperTools.Cli/ControlServer.cs @@ -74,6 +74,16 @@ private async Task HandleAsync(HttpListenerContext context) "/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)), + }, + "/screenshot" => await ScreenshotAsync(query), "/scanplayer" => new { reply = await _driver.ScanPlayerAsync(Timeout(query)) }, "/peek" => new { @@ -97,6 +107,14 @@ private async Task HandleAsync(HttpListenerContext context) } } + 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); diff --git a/src/NosCore.DeveloperTools.Cli/Input.cs b/src/NosCore.DeveloperTools.Cli/Input.cs new file mode 100644 index 0000000..f0442e6 --- /dev/null +++ b/src/NosCore.DeveloperTools.Cli/Input.cs @@ -0,0 +1,75 @@ +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); + + 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."); + } + + GetCursorPos(out var previous); + + SetForegroundWindow(window); + Thread.Sleep(150); + 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); + + 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/ProcessWindows.cs b/src/NosCore.DeveloperTools.Cli/ProcessWindows.cs new file mode 100644 index 0000000..be47b36 --- /dev/null +++ b/src/NosCore.DeveloperTools.Cli/ProcessWindows.cs @@ -0,0 +1,70 @@ +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 GetWindowTextW(IntPtr window, StringBuilder text, int count); + + [StructLayout(LayoutKind.Sequential)] + private struct Rect + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + /// + /// The largest captioned top-level window the process owns, or zero + /// while it has none big enough to be the game window yet. + /// + public static IntPtr FindGameWindow(int processId, int minWidth = 640, int minHeight = 480) + { + var best = IntPtr.Zero; + var bestArea = 0; + + var window = IntPtr.Zero; + while ((window = FindWindowExW(IntPtr.Zero, window, null, null)) != IntPtr.Zero) + { + GetWindowThreadProcessId(window, out var owner); + if (owner != (uint)processId) continue; + + var text = new StringBuilder(256); + if (GetWindowTextW(window, text, text.Capacity) == 0) continue; + + 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 <= bestArea) continue; + + bestArea = area; + best = window; + } + + return best; + } +} diff --git a/src/NosCore.DeveloperTools.Cli/Program.cs b/src/NosCore.DeveloperTools.Cli/Program.cs index bed15a5..7948cb8 100644 --- a/src/NosCore.DeveloperTools.Cli/Program.cs +++ b/src/NosCore.DeveloperTools.Cli/Program.cs @@ -1,12 +1,34 @@ +using System.Runtime.InteropServices; + 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, so every coordinate it reports — window + // rects, and the pixels in a capture — is physical. Left unaware, + // this process would read and write logical coordinates instead, + // and on a scaled display a click aimed from a screenshot lands + // somewhere else entirely. + try + { + SetProcessDpiAwarenessContext(PerMonitorAwareV2); + } + catch + { + // Pre-1703 hosts: coordinates stay logical, clicks need scaling. + } + var port = ParsePort(args) ?? DefaultPort; await using var driver = new ClientDriver(); @@ -21,6 +43,7 @@ private static async Task Main(string[] args) Console.WriteLine(" POST /inject {payload, direction} raw packet injection"); Console.WriteLine(" GET /packets?since=N&contains= captured traffic"); Console.WriteLine(" GET /log?since=N hook status lines"); + Console.WriteLine(" GET /screenshot?path=&mode= capture just the client window"); Console.WriteLine(" GET /quit stop the driver"); try 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.Hook/ClientWindow.cs b/src/NosCore.DeveloperTools.Hook/ClientWindow.cs new file mode 100644 index 0000000..9b88065 --- /dev/null +++ b/src/NosCore.DeveloperTools.Hook/ClientWindow.cs @@ -0,0 +1,170 @@ +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")] + private static extern bool PostMessageW(IntPtr window, uint message, IntPtr wParam, IntPtr lParam); + + /// + /// 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. + /// + /// Prefers the largest captioned window, since the client also owns + /// zero-size helper windows that would otherwise win. + /// + public static IntPtr Find() + { + var pid = (uint)Environment.ProcessId; + var best = IntPtr.Zero; + var bestArea = -1; + + var window = IntPtr.Zero; + while ((window = FindWindowExW(IntPtr.Zero, window, null, null)) != IntPtr.Zero) + { + GetWindowThreadProcessId(window, out var owner); + if (owner != pid) continue; + + var text = new StringBuilder(256); + if (GetWindowTextW(window, text, text.Capacity) == 0) continue; + + GetWindowRect(window, out var rect); + var area = Math.Max(0, rect.Right - rect.Left) * Math.Max(0, rect.Bottom - rect.Top); + if (area <= bestArea) continue; + + bestArea = area; + best = window; + } + + return best; + } + + /// 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 text = new StringBuilder(256); + GetWindowTextW(window, text, text.Capacity); + GetWindowRect(window, out var rect); + found.Add($"0x{window.ToInt64():X}:'{text}':{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 title = new StringBuilder(256); + GetWindowTextW(window, title, title.Capacity); + + return $"hwnd=0x{window.ToInt64():X} title='{title}' " + + $"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/PipeServer.cs b/src/NosCore.DeveloperTools.Hook/PipeServer.cs index 213a7aa..f089c92 100644 --- a/src/NosCore.DeveloperTools.Hook/PipeServer.cs +++ b/src/NosCore.DeveloperTools.Hook/PipeServer.cs @@ -155,6 +155,32 @@ private static void HandleCommand(string line) 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; diff --git a/src/NosCore.DeveloperTools/Remote/RemoteAttachmentService.cs b/src/NosCore.DeveloperTools/Remote/RemoteAttachmentService.cs index 66100b3..03b323e 100644 --- a/src/NosCore.DeveloperTools/Remote/RemoteAttachmentService.cs +++ b/src/NosCore.DeveloperTools/Remote/RemoteAttachmentService.cs @@ -128,6 +128,18 @@ public bool RequestPeek(long address, int length) 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 async Task DetachAsync() { await DetachInternalAsync(); @@ -183,7 +195,9 @@ private void OnPipeLine(string line) || line.StartsWith("WALKRESULT ", StringComparison.Ordinal) || line.StartsWith("DIAG ", StringComparison.Ordinal) || line.StartsWith("SCANPLAYER ", StringComparison.Ordinal) - || line.StartsWith("PEEK ", StringComparison.Ordinal)) + || line.StartsWith("PEEK ", StringComparison.Ordinal) + || line.StartsWith("WINDOW ", StringComparison.Ordinal) + || line.StartsWith("CLICK ", StringComparison.Ordinal)) { ControlReplyReceived?.Invoke(this, line); return; diff --git a/src/NosCore.DeveloperTools/Services/InjectionService.cs b/src/NosCore.DeveloperTools/Services/InjectionService.cs index 353621a..56468a9 100644 --- a/src/NosCore.DeveloperTools/Services/InjectionService.cs +++ b/src/NosCore.DeveloperTools/Services/InjectionService.cs @@ -87,4 +87,14 @@ public interface IInjectionService : IDisposable /// 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); } From 6e40637d169edf1f158f2a2a8a95b83330ab92fb Mon Sep 17 00:00:00 2001 From: erwan-joly Date: Fri, 11 Sep 2026 12:06:12 +1200 Subject: [PATCH 05/11] feat: per-hook toggles, and guard the packet read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client kept dying once it got near the world, and with every detour installed at once there was no way to tell which one did it. _NC_HOOKS in the client's environment now selects which detours to install (send, recv, login-recv, periodic), which is the only configuration available before the hook's own code runs. That bisects it: injecting with no detours is fine, send alone is fine, periodic alone is fine — send and periodic together kill the client after about twenty seconds with nothing driving it. So this is an interaction between the two detours, not a bad signature, and not the recv path that had been the obvious suspect for never capturing anything. Also guard DelphiString.Read. It dereferenced payload-4 inside a try/catch, which reads as safe and is not: an access violation is an SEH fault that NativeAOT will not surface as a catchable exception, so a wrong pointer took the client with it instead of returning null. Co-Authored-By: Claude Opus 5 (1M context) --- .../ClientDriver.cs | 7 ++++- .../ControlServer.cs | 2 +- .../DelphiString.cs | 7 +++++ src/NosCore.DeveloperTools.Hook/Hooks.cs | 29 ++++++++++++++++--- 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/NosCore.DeveloperTools.Cli/ClientDriver.cs b/src/NosCore.DeveloperTools.Cli/ClientDriver.cs index e5bd696..dd71e36 100644 --- a/src/NosCore.DeveloperTools.Cli/ClientDriver.cs +++ b/src/NosCore.DeveloperTools.Cli/ClientDriver.cs @@ -86,7 +86,7 @@ public IReadOnlyList Statuses(int since) /// call carries only a password. /// public async Task LaunchAsync( - string? serverUrl, string username, string password, string? clientExe, string? gfLang, string? locale, + string? serverUrl, string username, string password, string? clientExe, string? gfLang, string? locale, string? hooks, CancellationToken ct) { var saved = _settings.Load().Auth; @@ -115,6 +115,11 @@ public async Task LaunchAsync( UseShellExecute = false, }; startInfo.EnvironmentVariables["_NC_AUTH_CODE"] = result.AuthCode; + if (!string.IsNullOrWhiteSpace(hooks)) + { + startInfo.EnvironmentVariables["_NC_HOOKS"] = hooks; + Note($"hooks limited to: {hooks}"); + } _client = Process.Start(startInfo) ?? throw new InvalidOperationException("Client failed to start."); Note($"client started pid={_client.Id}"); diff --git a/src/NosCore.DeveloperTools.Cli/ControlServer.cs b/src/NosCore.DeveloperTools.Cli/ControlServer.cs index a2e20fa..7380c83 100644 --- a/src/NosCore.DeveloperTools.Cli/ControlServer.cs +++ b/src/NosCore.DeveloperTools.Cli/ControlServer.cs @@ -69,7 +69,7 @@ private async Task HandleAsync(HttpListenerContext context) }, "/launch" => await _driver.LaunchAsync( Str(body, "serverUrl"), Str(body, "username") ?? "admin", Str(body, "password") ?? "test", - Str(body, "clientExe"), Str(body, "gfLang"), Str(body, "locale"), CancellationToken.None), + 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))), 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/Hooks.cs b/src/NosCore.DeveloperTools.Hook/Hooks.cs index 3371181..a372270 100644 --- a/src/NosCore.DeveloperTools.Hook/Hooks.cs +++ b/src/NosCore.DeveloperTools.Hook/Hooks.cs @@ -41,13 +41,34 @@ 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); + 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; @@ -80,7 +101,7 @@ public static InstallResult Install() result.LoginRecvHooked = _loginRecvTrampoline != IntPtr.Zero; } - var periodicAddr = PatternScanner.ScanMainModule(Signatures.Periodic); + var periodicAddr = Enabled("periodic") ? PatternScanner.ScanMainModule(Signatures.Periodic) : IntPtr.Zero; result.PeriodicAddress = periodicAddr; if (periodicAddr != IntPtr.Zero) { From c5d55dcc14dfd74cb5c5b49879e29eb549f44cf7 Mon Sep 17 00:00:00 2001 From: erwan-joly Date: Fri, 11 Sep 2026 16:50:55 +1200 Subject: [PATCH 06/11] feat: attach foreign threads to the runtime before entering managed code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every detour body is a reverse P/Invoke, and the AOT prologue expects a per-thread context in this image's static TLS slot. The loader only fills that slot for threads created after the DLL loads — we inject into a running client, so the threads that matter all predate us. RuntimeBootstrap emits a stub that does what DLL_THREAD_ATTACH would have: allocate the thread's TLS block, seed it from the image template, publish it in the TEB, then run the image's TLS callbacks and entry point. Trampolines call it first and skip the managed hook entirely when it fails, because dropping a packet beats killing the client. Two details are deliberate: the TLS slot is not rewritten afterwards (the callbacks put AOT's own context there, which is what later hook fires look for), and hook arguments now come from the PUSHAD frame rather than live registers, which the bootstrap clobbers. The config comes from the loaded image's own TLS directory, since we arrive via LoadLibrary rather than a manual mapper. _NC_BOOTSTRAP=0 turns the stub off so its effect stays measurable. Note this did not turn out to be the cause of the crashes that prompted it — those were the NosCore server, and the earlier bisect that blamed detour combinations was measuring a 15s failure through a 14s window. With the server restarted, all four detours plus the bootstrap run stable. Co-Authored-By: Claude Opus 5 (1M context) --- .../ClientDriver.cs | 19 +- src/NosCore.DeveloperTools.Hook/Detour.cs | 58 +++- src/NosCore.DeveloperTools.Hook/HookEntry.cs | 1 + src/NosCore.DeveloperTools.Hook/Hooks.cs | 5 + .../RuntimeBootstrap.cs | 287 ++++++++++++++++++ 5 files changed, 360 insertions(+), 10 deletions(-) create mode 100644 src/NosCore.DeveloperTools.Hook/RuntimeBootstrap.cs diff --git a/src/NosCore.DeveloperTools.Cli/ClientDriver.cs b/src/NosCore.DeveloperTools.Cli/ClientDriver.cs index dd71e36..6ffd2a8 100644 --- a/src/NosCore.DeveloperTools.Cli/ClientDriver.cs +++ b/src/NosCore.DeveloperTools.Cli/ClientDriver.cs @@ -117,8 +117,23 @@ public async Task LaunchAsync( startInfo.EnvironmentVariables["_NC_AUTH_CODE"] = result.AuthCode; if (!string.IsNullOrWhiteSpace(hooks)) { - startInfo.EnvironmentVariables["_NC_HOOKS"] = hooks; - Note($"hooks limited to: {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."); diff --git a/src/NosCore.DeveloperTools.Hook/Detour.cs b/src/NosCore.DeveloperTools.Hook/Detour.cs index 9e3a571..f3ba631 100644 --- a/src/NosCore.DeveloperTools.Hook/Detour.cs +++ b/src/NosCore.DeveloperTools.Hook/Detour.cs @@ -72,14 +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 switch + var argCount = arg switch { HookArg.EaxThenEdx => 2, HookArg.None => 0, _ => 1, }; - var trampolineSize = 1 + 1 + argsSize + 5 + 1 + 1 + prologueSize + 5; + + // 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; @@ -92,27 +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]; @@ -139,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 e532695..e8fb4df 100644 --- a/src/NosCore.DeveloperTools.Hook/HookEntry.cs +++ b/src/NosCore.DeveloperTools.Hook/HookEntry.cs @@ -66,6 +66,7 @@ private static void InstallHooks() $"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 a372270..b496076 100644 --- a/src/NosCore.DeveloperTools.Hook/Hooks.cs +++ b/src/NosCore.DeveloperTools.Hook/Hooks.cs @@ -66,6 +66,10 @@ public static InstallResult Install() { var result = new InstallResult(); + // 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; @@ -284,6 +288,7 @@ private static void Capture(PacketDirection direction, PacketConnection connecti internal struct InstallResult { + public string? BootstrapStatus; public IntPtr PeriodicAddress; public IntPtr PlayerManagerStaticAddress; public IntPtr WalkAddress; 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(); + } +} From 8676737d46c2e92213443f0be73d20ba1a1f5330 Mon Sep 17 00:00:00 2001 From: erwan-joly Date: Fri, 11 Sep 2026 20:51:59 +1200 Subject: [PATCH 07/11] =?UTF-8?q?fix:=20walking=20works=20=E2=80=94=20four?= =?UTF-8?q?-argument=20call,=20plus=20two=20things=20that=20hid=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The walk routine takes four arguments, not two. Called with two, the callee still pops the stack argument nobody pushed, so the client emitted a walk to its own position and then died on the corrupted stack. Passing all four moves the character and leaves the client running; the invoker restores ESP from EBP, so the call shape is safe to get wrong. Verified against the server's own record: POS read 49,132 matching the character row exactly, then walking to 53,135 and 45,128 each landed on target, with the client emitting the intermediate steps itself ("walk 52 134 0 11", "walk 53 135 0 11") — checksum included, which is the whole reason for driving the client's routine instead of sending the packet ourselves. Two bugs made this look broken for a long time. Command replies shared the queue with captured traffic, and in-world that queue runs thousands of packets deep, so answers arrived tens of seconds after the question and every command appeared to time out; replies now have their own queue and are written first. And clicks were computed from the window rect without checking the screen: the window is taller than the desktop, so lower controls mapped past the bottom edge, SetCursorPos clamped, and the click silently landed on whatever else was there. Window lookup also no longer reads captions. GetWindowTextW sends WM_GETTEXT, and asking from the pipe thread blocks until the client's UI thread is free — precisely when we want to look. Match on the window class instead. Co-Authored-By: Claude Opus 5 (1M context) --- src/NosCore.DeveloperTools.Cli/Input.cs | 30 ++++++++++++ .../ProcessWindows.cs | 30 +++++++----- .../ClientWindow.cs | 47 ++++++++++++------- src/NosCore.DeveloperTools.Hook/Hooks.cs | 7 +++ src/NosCore.DeveloperTools.Hook/PipeServer.cs | 10 +++- 5 files changed, 95 insertions(+), 29 deletions(-) diff --git a/src/NosCore.DeveloperTools.Cli/Input.cs b/src/NosCore.DeveloperTools.Cli/Input.cs index f0442e6..4208e7c 100644 --- a/src/NosCore.DeveloperTools.Cli/Input.cs +++ b/src/NosCore.DeveloperTools.Cli/Input.cs @@ -41,6 +41,12 @@ private struct 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 SwpNoZOrder = 0x0004; + public static string Click(IntPtr window, int clientX, int clientY, bool restoreCursor = true) { if (window == IntPtr.Zero) throw new InvalidOperationException("No client window."); @@ -51,6 +57,30 @@ public static string Click(IntPtr window, int clientX, int clientY, bool restore 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(window); diff --git a/src/NosCore.DeveloperTools.Cli/ProcessWindows.cs b/src/NosCore.DeveloperTools.Cli/ProcessWindows.cs index be47b36..55394b8 100644 --- a/src/NosCore.DeveloperTools.Cli/ProcessWindows.cs +++ b/src/NosCore.DeveloperTools.Cli/ProcessWindows.cs @@ -24,7 +24,9 @@ internal static class ProcessWindows private static extern bool GetWindowRect(IntPtr window, out Rect rect); [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern int GetWindowTextW(IntPtr window, StringBuilder text, int count); + private static extern int GetClassNameW(IntPtr window, StringBuilder className, int count); + + private const string NosTaleWindowClass = "TNosTaleMainF"; [StructLayout(LayoutKind.Sequential)] private struct Rect @@ -36,13 +38,15 @@ private struct Rect } /// - /// The largest captioned top-level window the process owns, or zero - /// while it has none big enough to be the game window yet. + /// 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 best = IntPtr.Zero; - var bestArea = 0; + 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) @@ -50,8 +54,12 @@ public static IntPtr FindGameWindow(int processId, int minWidth = 640, int minHe GetWindowThreadProcessId(window, out var owner); if (owner != (uint)processId) continue; - var text = new StringBuilder(256); - if (GetWindowTextW(window, text, text.Capacity) == 0) 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; @@ -59,12 +67,12 @@ public static IntPtr FindGameWindow(int processId, int minWidth = 640, int minHe if (width < minWidth || height < minHeight) continue; var area = width * height; - if (area <= bestArea) continue; + if (area <= fallbackArea) continue; - bestArea = area; - best = window; + fallbackArea = area; + fallback = window; } - return best; + return fallback; } } diff --git a/src/NosCore.DeveloperTools.Hook/ClientWindow.cs b/src/NosCore.DeveloperTools.Hook/ClientWindow.cs index 9b88065..b1401ff 100644 --- a/src/NosCore.DeveloperTools.Hook/ClientWindow.cs +++ b/src/NosCore.DeveloperTools.Hook/ClientWindow.cs @@ -57,23 +57,34 @@ private struct 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. /// - /// Prefers the largest captioned window, since the client also owns - /// zero-size helper windows that would otherwise win. + /// 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 best = IntPtr.Zero; - var bestArea = -1; + 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) @@ -81,18 +92,22 @@ public static IntPtr Find() GetWindowThreadProcessId(window, out var owner); if (owner != pid) continue; - var text = new StringBuilder(256); - if (GetWindowTextW(window, text, text.Capacity) == 0) 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 <= bestArea) continue; + if (area <= fallbackArea) continue; - bestArea = area; - best = window; + fallbackArea = area; + fallback = window; } - return best; + return fallback; } /// Every top-level window this process owns, for diagnosis. @@ -107,10 +122,10 @@ public static string List() GetWindowThreadProcessId(window, out var owner); if (owner != pid) continue; - var text = new StringBuilder(256); - GetWindowTextW(window, text, text.Capacity); + var className = new StringBuilder(64); + GetClassNameW(window, className, className.Capacity); GetWindowRect(window, out var rect); - found.Add($"0x{window.ToInt64():X}:'{text}':{rect.Right - rect.Left}x{rect.Bottom - rect.Top}" + + found.Add($"0x{window.ToInt64():X}:{className}:{rect.Right - rect.Left}x{rect.Bottom - rect.Top}" + $":visible={IsWindowVisible(window)}:iconic={IsIconic(window)}"); } @@ -142,10 +157,10 @@ private static string Describe(IntPtr window) { GetWindowRect(window, out var rect); GetClientRect(window, out var client); - var title = new StringBuilder(256); - GetWindowTextW(window, title, title.Capacity); + var className = new StringBuilder(64); + GetClassNameW(window, className, className.Capacity); - return $"hwnd=0x{window.ToInt64():X} title='{title}' " + + 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)}"; diff --git a/src/NosCore.DeveloperTools.Hook/Hooks.cs b/src/NosCore.DeveloperTools.Hook/Hooks.cs index b496076..603ad6b 100644 --- a/src/NosCore.DeveloperTools.Hook/Hooks.cs +++ b/src/NosCore.DeveloperTools.Hook/Hooks.cs @@ -24,6 +24,13 @@ 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; diff --git a/src/NosCore.DeveloperTools.Hook/PipeServer.cs b/src/NosCore.DeveloperTools.Hook/PipeServer.cs index f089c92..c55e2b4 100644 --- a/src/NosCore.DeveloperTools.Hook/PipeServer.cs +++ b/src/NosCore.DeveloperTools.Hook/PipeServer.cs @@ -37,7 +37,7 @@ public static void Announce(string message) /// private static void Reply(string line) { - Hooks.Queue.Enqueue(new CapturedPacket(PacketDirection.Status, PacketConnection.World, line)); + Hooks.Replies.Enqueue(line); } public static void Run() @@ -68,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); } From 98fb432c833afaa2fa3f55fb84bc0f899a6723da Mon Sep 17 00:00:00 2001 From: erwan-joly Date: Sun, 13 Sep 2026 13:42:24 +1200 Subject: [PATCH 08/11] docs: describe the headless driver Records the endpoint surface, the two unnamed walk arguments and why a wrong guess is safe, the hook-selection flag and what it is for, and the two non-obvious constraints: the driver must be elevated because UIPI drops window calls and input from lower integrity, and clicks have to drive the real cursor because the client never sees posted mouse messages. --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index 819a1b6..5c7361e 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,33 @@ All client calls are marshalled onto the client's own thread via a per-frame per 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: From 54a433798084e4cfee85fee720b287da97e8a636 Mon Sep 17 00:00:00 2001 From: erwan-joly Date: Sun, 13 Sep 2026 14:11:41 +1200 Subject: [PATCH 09/11] fix: force the client above other windows before clicking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SetForegroundWindow is refused when the calling process does not own the foreground, so the client stayed behind whatever was maximised — and injected input goes to whatever is topmost at that point, not to the window we aimed at. Clicks were landing in the editor. Setting the window topmost is not subject to that restriction; the window drops back to normal ordering afterwards so it does not sit over the desktop permanently. Co-Authored-By: Claude Opus 5 (1M context) --- src/NosCore.DeveloperTools.Cli/Input.cs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/NosCore.DeveloperTools.Cli/Input.cs b/src/NosCore.DeveloperTools.Cli/Input.cs index 4208e7c..d30e487 100644 --- a/src/NosCore.DeveloperTools.Cli/Input.cs +++ b/src/NosCore.DeveloperTools.Cli/Input.cs @@ -45,7 +45,12 @@ private struct Point 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) { @@ -83,8 +88,15 @@ public static string Click(IntPtr window, int clientX, int clientY, bool restore 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(150); + 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. @@ -94,6 +106,10 @@ public static string Click(IntPtr window, int clientX, int clientY, bool restore 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); From 0980b91b2318087ebe37dd0f7085339fb97fbaad Mon Sep 17 00:00:00 2001 From: erwan-joly Date: Sun, 13 Sep 2026 14:32:00 +1200 Subject: [PATCH 10/11] feat: find and call the client's connect routine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logging in was the last step still driven by synthetic mouse clicks, because the client opens no world socket until the channel button is pressed. Traced it with a debugger: breakpoint ws2_32 connect, click once, read the stack. The return address led back into the client, and walking up the call stack reached 0x4EACDC call 0x004DAB80 with the Delphi register convention — EAX the connection object, EDX the host as a Delphi string, ECX the port. The worker it tail-calls writes CX straight into the sockaddr, which is what pins the argument order down. Scanning for the routine's prologue independently resolves to the same address, so the signature is sound rather than a hard-coded address. The routine is detoured to record the connection object as the client connects normally, and CONNECT calls it back. Two things this turned up. The default six-byte detour splits this prologue: it is push ebx / push esi / push edi / mov edi,ecx — exactly five — followed by the two-byte mov esi,edx. Taking six left an orphaned operand byte, the routine failed, and the client retried it thousands of times a second. Five bytes is the clean boundary, and the retry count drops from 1024-in-20-seconds to 1. And the call demonstrably works, but not yet for the world: the object captured at startup is the login connection, so calling it re-runs the login handshake rather than connecting to a channel. Distinguishing the two needs the port, which means forwarding ECX through the detour. Co-Authored-By: Claude Opus 5 (1M context) --- .../ClientDriver.cs | 7 ++ .../ControlServer.cs | 5 + .../ClientInvoker.cs | 48 +++++++++ src/NosCore.DeveloperTools.Hook/Hooks.cs | 8 ++ src/NosCore.DeveloperTools.Hook/PipeServer.cs | 25 ++++- src/NosCore.DeveloperTools.Hook/Signatures.cs | 22 ++++ .../WorldConnection.cs | 101 ++++++++++++++++++ .../Remote/RemoteAttachmentService.cs | 9 +- .../Services/InjectionService.cs | 7 ++ 9 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 src/NosCore.DeveloperTools.Hook/WorldConnection.cs diff --git a/src/NosCore.DeveloperTools.Cli/ClientDriver.cs b/src/NosCore.DeveloperTools.Cli/ClientDriver.cs index 6ffd2a8..49fe3af 100644 --- a/src/NosCore.DeveloperTools.Cli/ClientDriver.cs +++ b/src/NosCore.DeveloperTools.Cli/ClientDriver.cs @@ -257,6 +257,13 @@ public async Task ClickAsync(int x, int y, string? mode, TimeSpan timeou 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); diff --git a/src/NosCore.DeveloperTools.Cli/ControlServer.cs b/src/NosCore.DeveloperTools.Cli/ControlServer.cs index 7380c83..226f911 100644 --- a/src/NosCore.DeveloperTools.Cli/ControlServer.cs +++ b/src/NosCore.DeveloperTools.Cli/ControlServer.cs @@ -83,6 +83,11 @@ private async Task HandleAsync(HttpListenerContext context) 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 diff --git a/src/NosCore.DeveloperTools.Hook/ClientInvoker.cs b/src/NosCore.DeveloperTools.Hook/ClientInvoker.cs index 473d2b8..39fcb78 100644 --- a/src/NosCore.DeveloperTools.Hook/ClientInvoker.cs +++ b/src/NosCore.DeveloperTools.Hook/ClientInvoker.cs @@ -73,6 +73,54 @@ 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 diff --git a/src/NosCore.DeveloperTools.Hook/Hooks.cs b/src/NosCore.DeveloperTools.Hook/Hooks.cs index 603ad6b..339dbf3 100644 --- a/src/NosCore.DeveloperTools.Hook/Hooks.cs +++ b/src/NosCore.DeveloperTools.Hook/Hooks.cs @@ -126,6 +126,12 @@ public static InstallResult Install() } } + if (Enabled("connect")) + { + result.ConnectHooked = WorldConnection.Install(); + result.ConnectAddress = WorldConnection.ConnectAddress; + } + PlayerManager.Resolve(); result.PlayerManagerStaticAddress = PlayerManager.StaticAddress; result.WalkAddress = PlayerManager.WalkAddress; @@ -295,6 +301,8 @@ 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; diff --git a/src/NosCore.DeveloperTools.Hook/PipeServer.cs b/src/NosCore.DeveloperTools.Hook/PipeServer.cs index c55e2b4..2d18025 100644 --- a/src/NosCore.DeveloperTools.Hook/PipeServer.cs +++ b/src/NosCore.DeveloperTools.Hook/PipeServer.cs @@ -161,6 +161,27 @@ private static void HandleCommand(string line) 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)) @@ -305,7 +326,9 @@ private static void HandleDiagnostics() 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}"); + $"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}"; diff --git a/src/NosCore.DeveloperTools.Hook/Signatures.cs b/src/NosCore.DeveloperTools.Hook/Signatures.cs index ae51935..e81185f 100644 --- a/src/NosCore.DeveloperTools.Hook/Signatures.cs +++ b/src/NosCore.DeveloperTools.Hook/Signatures.cs @@ -91,4 +91,26 @@ internal static class Signatures // 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/Remote/RemoteAttachmentService.cs b/src/NosCore.DeveloperTools/Remote/RemoteAttachmentService.cs index 03b323e..37be44a 100644 --- a/src/NosCore.DeveloperTools/Remote/RemoteAttachmentService.cs +++ b/src/NosCore.DeveloperTools/Remote/RemoteAttachmentService.cs @@ -140,6 +140,12 @@ public bool RequestClick(int x, int y) 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(); @@ -197,7 +203,8 @@ private void OnPipeLine(string line) || line.StartsWith("SCANPLAYER ", StringComparison.Ordinal) || line.StartsWith("PEEK ", StringComparison.Ordinal) || line.StartsWith("WINDOW ", StringComparison.Ordinal) - || line.StartsWith("CLICK ", StringComparison.Ordinal)) + || line.StartsWith("CLICK ", StringComparison.Ordinal) + || line.StartsWith("CONNECTRESULT ", StringComparison.Ordinal)) { ControlReplyReceived?.Invoke(this, line); return; diff --git a/src/NosCore.DeveloperTools/Services/InjectionService.cs b/src/NosCore.DeveloperTools/Services/InjectionService.cs index 56468a9..a845f2b 100644 --- a/src/NosCore.DeveloperTools/Services/InjectionService.cs +++ b/src/NosCore.DeveloperTools/Services/InjectionService.cs @@ -97,4 +97,11 @@ public interface IInjectionService : IDisposable /// 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); } From 64585a2634eecac1ce471214f3fd04e31a56b8e8 Mon Sep 17 00:00:00 2001 From: erwan-joly Date: Sun, 13 Sep 2026 19:29:00 +1200 Subject: [PATCH 11/11] feat: expose the client driver as an MCP server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original goal was a local MCP to launch and control the NosTale client so it can be driven programmatically; so far the driver only spoke HTTP. Add an --mcp mode that runs the same ClientDriver behind an MCP stdio server (official ModelContextProtocol SDK), so an MCP host like Claude Code gets first-class tools instead of curl. Tools: launch, attach, status, position, walk, click, screenshot, inject, packets, packet_cursor, and wait_for_packet. The last is new — the assertion primitive a test loop needs: act, then block until the client's provoked packet matches a regex, instead of sleeping. One process holds one persistent driver so the hook session and launched client survive across tool calls. Logs go to stderr so stdout stays a clean MCP transport. HTTP mode is unchanged and still the default. Must run elevated (injection): start the MCP host itself as admin so this child inherits elevation without a UAC prompt that would break the stdio pipe. Co-Authored-By: Claude Opus 4.8 --- Directory.Packages.props | 2 + .../ClientDriver.cs | 36 ++++++ .../NosCore.DeveloperTools.Cli.csproj | 2 + .../NosTaleTools.cs | 112 ++++++++++++++++++ src/NosCore.DeveloperTools.Cli/Program.cs | 58 +++++---- 5 files changed, 188 insertions(+), 22 deletions(-) create mode 100644 src/NosCore.DeveloperTools.Cli/NosTaleTools.cs 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/src/NosCore.DeveloperTools.Cli/ClientDriver.cs b/src/NosCore.DeveloperTools.Cli/ClientDriver.cs index 49fe3af..2c5047a 100644 --- a/src/NosCore.DeveloperTools.Cli/ClientDriver.cs +++ b/src/NosCore.DeveloperTools.Cli/ClientDriver.cs @@ -80,6 +80,42 @@ public IReadOnlyList Statuses(int since) } } + /// + /// 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 diff --git a/src/NosCore.DeveloperTools.Cli/NosCore.DeveloperTools.Cli.csproj b/src/NosCore.DeveloperTools.Cli/NosCore.DeveloperTools.Cli.csproj index 46e972a..f7e5050 100644 --- a/src/NosCore.DeveloperTools.Cli/NosCore.DeveloperTools.Cli.csproj +++ b/src/NosCore.DeveloperTools.Cli/NosCore.DeveloperTools.Cli.csproj @@ -25,6 +25,8 @@ + + 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/Program.cs b/src/NosCore.DeveloperTools.Cli/Program.cs index 7948cb8..17231a0 100644 --- a/src/NosCore.DeveloperTools.Cli/Program.cs +++ b/src/NosCore.DeveloperTools.Cli/Program.cs @@ -1,4 +1,7 @@ using System.Runtime.InteropServices; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace NosCore.DeveloperTools.Cli; @@ -15,36 +18,47 @@ internal static class Program [STAThread] private static async Task Main(string[] args) { - // The client is DPI-aware, so every coordinate it reports — window - // rects, and the pixels in a capture — is physical. Left unaware, - // this process would read and write logical coordinates instead, - // and on a scaled display a click aimed from a screenshot lands - // somewhere else entirely. - try - { - SetProcessDpiAwarenessContext(PerMonitorAwareV2); - } - catch + // 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")) { - // Pre-1703 hosts: coordinates stay logical, clicks need scaling. + return await RunMcpAsync(args); } - var port = ParsePort(args) ?? DefaultPort; + 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(" POST /launch {password} auth + start the patched client"); - Console.WriteLine(" POST /attach {pid?} inject the hook, open the pipe"); - Console.WriteLine(" GET /diag resolved signatures, tick count, in-world"); - Console.WriteLine(" GET /pos live character id and coordinates"); - Console.WriteLine(" POST /walk {x, y} move via the client's own routine"); - Console.WriteLine(" POST /inject {payload, direction} raw packet injection"); - Console.WriteLine(" GET /packets?since=N&contains= captured traffic"); - Console.WriteLine(" GET /log?since=N hook status lines"); - Console.WriteLine(" GET /screenshot?path=&mode= capture just the client window"); - Console.WriteLine(" GET /quit stop the driver"); + Console.WriteLine(" (run with --mcp to expose the same control as an MCP stdio server instead)"); try {