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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
"packageManager": "pnpm@10.6.2",
"scripts": {
"compile:pickit": "node scripts/build/compile-pickit.mjs",
"test:sdk": "cd packages/sdk && node --import tsx --loader ./test-loader.mjs --test 'game/*.test.ts'"
"test:sdk": "cd packages/sdk && node --import tsx --loader ./test-loader.mjs --test 'game/*.test.ts'",
"test:scripts": "node --import tsx --loader ./packages/sdk/test-loader.mjs --test 'scripts/lib/*.test.ts'",
"test": "pnpm test:sdk && pnpm test:scripts",
"test:e2e": "node scripts/e2e/run.mjs"
},
"devDependencies": {
"@aether/sdk": "workspace:*",
Expand Down
65 changes: 46 additions & 19 deletions packages/native/src/d2/functions.zig
Original file line number Diff line number Diff line change
Expand Up @@ -585,29 +585,56 @@ pub const GetInteractedUnit = struct {
pub const NPCMenuArray: [*]const u8 = @ptrFromInt(0x00726C48);
pub const NPCMenuCount: *const u32 = @ptrFromInt(0x00725A74);

/// Call an NPC menu option by scanning the menu array for the given NPC classId
/// and invoking the callback at the given option index.
pub fn callNpcMenuOption(npc_class_id: u32, option_index: u32) bool {
/// NPCMenu entry layout (39 bytes):
/// +0 (u32) classId
/// +4 (u32) optionsCount
/// +8 (i16[5]) stringIds (these are menu IDs — 0x0D44=Trade, 0x0D06=Trade/Repair, etc.)
/// +18 (u32[5]) callbacks
/// +38 (u8) enabled
const NPC_MENU_ENTRY_SIZE: u32 = 39;
const NPC_MENU_STRING_IDS_OFFSET: u32 = 8;
const NPC_MENU_CALLBACKS_OFFSET: u32 = 18;

/// Find the menu entry for an NPC classId, or null if absent.
fn findNpcMenuEntry(npc_class_id: u32) ?[*]const u8 {
const count = NPCMenuCount.*;
const entry_size: u32 = 39;

var i: u32 = 0;
while (i < count) : (i += 1) {
const entry = NPCMenuArray + i * entry_size;
const entry = NPCMenuArray + i * NPC_MENU_ENTRY_SIZE;
const entry_npc_id: u32 = @as(*align(1) const u32, @ptrCast(entry)).*;
if (entry_npc_id == npc_class_id) {
const options_count: u32 = @as(*align(1) const u32, @ptrCast(entry + 4)).*;
if (option_index < options_count) {
// Callback pointers start at offset 0x12 (18), each is 4 bytes
const cb_ptr: u32 = @as(*align(1) const u32, @ptrCast(entry + 18 + option_index * 4)).*;
if (cb_ptr != 0) {
const callback: *const fn () callconv(.winapi) void = @ptrFromInt(cb_ptr);
callback();
return true;
}
}
break;
}
if (entry_npc_id == npc_class_id) return entry;
}
return null;
}

/// Invoke `entry`'s callback at the given option index. Returns false if the
/// index is out of range or the callback slot is null.
fn invokeNpcMenuCallback(entry: [*]const u8, option_index: u32) bool {
const options_count: u32 = @as(*align(1) const u32, @ptrCast(entry + 4)).*;
if (option_index >= options_count) return false;
const cb_ptr: u32 = @as(*align(1) const u32, @ptrCast(entry + NPC_MENU_CALLBACKS_OFFSET + option_index * 4)).*;
if (cb_ptr == 0) return false;
const callback: *const fn () callconv(.winapi) void = @ptrFromInt(cb_ptr);
callback();
return true;
}

/// Call an NPC menu option by index.
pub fn callNpcMenuOption(npc_class_id: u32, option_index: u32) bool {
const entry = findNpcMenuEntry(npc_class_id) orelse return false;
return invokeNpcMenuCallback(entry, option_index);
}

/// Call an NPC menu option by menu ID (e.g. 0x0D44 = Trade). Mirrors kolbot's
/// `Misc.useMenu(menuId)` — the canonical way to pick a menu option, since
/// option order varies by NPC (Akara has Talk first; Charsi has Trade first).
pub fn callNpcMenuByMenuId(npc_class_id: u32, menu_id: u16) bool {
const entry = findNpcMenuEntry(npc_class_id) orelse return false;
const options_count: u32 = @as(*align(1) const u32, @ptrCast(entry + 4)).*;
var j: u32 = 0;
while (j < options_count and j < 5) : (j += 1) {
const sid: u16 = @bitCast(@as(*align(1) const i16, @ptrCast(entry + NPC_MENU_STRING_IDS_OFFSET + j * 2)).*);
if (sid == menu_id) return invokeNpcMenuCallback(entry, j);
}
return false;
}
Expand Down
1 change: 1 addition & 0 deletions packages/native/src/hook/game_hooks.zig
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ fn hookOogDraw() callconv(.c) void {
_ = d2.functions.SetFont.call(.{old});
const drawCursorOog: *const fn () callconv(.c) void = @ptrFromInt(ADDR_DRAW_CURSOR_OOG);
drawCursorOog();
@import("../sm/bindings.zig").flushScreenshot();
}

// --- Drawing: cursor (congrats/disc/unknown screens) ---
Expand Down
80 changes: 68 additions & 12 deletions packages/native/src/sm/bindings.zig
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,21 @@ fn jsUnitGetName(cx: ?*anyopaque, argc: c_uint, vp: ?*anyopaque) callconv(.c) c_
const unit_type: u32 = @bitCast(argInt32(argc, vp, 0));
const unit_id: u32 = @bitCast(argInt32(argc, vp, 1));
const unit = units.findUnit(unit_type, unit_id) orelse { retString(cx, argc, vp, ""); return 1; };
// GetUnitName returns wide string — convert to ascii

// For player units, D2CLIENT_GetUnitName doesn't return the char name — read
// pUnitData->szName directly (the d2bs pattern).
if (unit.dwType == 0) {
if (unit.pUnitData) |raw| {
const data: *types.PlayerData = @ptrCast(@alignCast(raw));
var len: usize = 0;
while (len < 16 and data.szName[len] != 0) len += 1;
retString(cx, argc, vp, data.szName[0..len]);
return 1;
}
retString(cx, argc, vp, "");
return 1;
}

const name_w = d2.GetUnitName.call(.{unit}) orelse { retString(cx, argc, vp, ""); return 1; };
var buf: [64]u8 = undefined;
var i: usize = 0;
Expand Down Expand Up @@ -493,18 +507,18 @@ fn jsItemGetLocation(_: ?*anyopaque, argc: c_uint, vp: ?*anyopaque) callconv(.c)
// body_location (0x44): non-zero = equipped body slot
// node_page (0x69): 0=none, 1=inv grid, 2=belt, 3=bodyloc, 4=swapped
const page = data.item_location;
const grid = data.game_location;
const body = data.body_location;
const npage = data.node_page;

// First: check explicit container pages
// NOTE: only `page` (item_location) is authoritative for storage page.
// `grid` (game_location) is overloaded and frequently has small values
// (3 etc.) for plain inventory items — using it caused 23 inv items to
// misclassify as Cube.
const result: i32 = blk: {
// item_location or game_location == 3 → cube
if (page == 3 or grid == 3) break :blk 3;
// item_location or game_location == 4 → stash
if (page == 4 or grid == 4) break :blk 4;
// vendor pages
if (page == 6 or page == 7 or grid == 6 or grid == 7) break :blk 6;
if (page == 3) break :blk 3; // cube
if (page == 4) break :blk 4; // stash
if (page == 6 or page == 7) break :blk 6; // vendor

// Ground items: not in any inventory
if (data.pOwnerInventory == null) break :blk @as(i32, 5); // ground
Expand Down Expand Up @@ -825,6 +839,19 @@ fn jsNpcMenuSelect(_: ?*anyopaque, argc: c_uint, vp: ?*anyopaque) callconv(.c) c
return 1;
}

fn jsNpcMenuByMenuId(_: ?*anyopaque, argc: c_uint, vp: ?*anyopaque) callconv(.c) c_int {
const menu_id: u16 = @truncate(@as(u32, @bitCast(argInt32(argc, vp, 0))));

const npc = d2.GetInteractedUnit.call() orelse {
retBool(argc, vp, false);
return 1;
};

const ok = d2.callNpcMenuByMenuId(npc.dwTxtFileNo, menu_id);
retBool(argc, vp, ok);
return 1;
}

fn jsRunToEntity(_: ?*anyopaque, argc: c_uint, vp: ?*anyopaque) callconv(.c) c_int {
const unit_type = argInt32(argc, vp, 0);
const unit_id = argInt32(argc, vp, 1);
Expand Down Expand Up @@ -2354,14 +2381,42 @@ const screenshot_mem = struct {
extern "kernel32" fn VirtualFree(lpAddress: [*]u8, dwSize: usize, dwFreeType: u32) callconv(.winapi) i32;
};

/// takeScreenshot() — requests a screenshot on the next draw hook (where overlay is visible).
fn jsScreenshot(_: ?*anyopaque, argc: c_uint, vp: ?*anyopaque) callconv(.c) c_int {
/// Static filename buffer used between request and flush.
/// Defaults to "aether_screenshot.bmp" if takeScreenshot() called with no arg.
var screenshot_filename: [260]u8 = .{0} ** 260;

/// takeScreenshot(name?: string) — requests a screenshot on the next draw hook
/// (where overlay is visible). If `name` is given, ".bmp" is appended if missing.
/// Works in-game and OOG. The file is written to the game directory.
fn jsScreenshot(cx: ?*anyopaque, argc: c_uint, vp: ?*anyopaque) callconv(.c) c_int {
const default_name = "aether_screenshot.bmp";
var len: usize = 0;
if (argc >= 1) {
var nbuf: [256]u8 = undefined;
const nlen = c.sm_arg_string(cx, argc, vp, 0, &nbuf, nbuf.len);
if (nlen > 0) {
const ulen: usize = @intCast(nlen);
const has_ext = ulen >= 4 and std.mem.eql(u8, nbuf[ulen - 4 .. ulen], ".bmp");
const total = if (has_ext) ulen else ulen + 4;
if (total < screenshot_filename.len) {
@memcpy(screenshot_filename[0..ulen], nbuf[0..ulen]);
if (!has_ext) @memcpy(screenshot_filename[ulen .. ulen + 4], ".bmp");
screenshot_filename[total] = 0;
len = total;
}
}
}
if (len == 0) {
@memcpy(screenshot_filename[0..default_name.len], default_name);
screenshot_filename[default_name.len] = 0;
}
shared_state.set(.screenshot_pending, 1);
retUndefined(argc, vp);
return 1;
}

/// Called from the draw hook dispatch (game_hooks.zig) — captures if pending.
/// Wired into both in-game and OOG post-draw hooks.
pub fn flushScreenshot() void {
if (shared_state.get(.screenshot_pending) == 0) return;
shared_state.set(.screenshot_pending, 0);
Expand All @@ -2380,7 +2435,7 @@ pub fn flushScreenshot() void {

if (d2.GetBackBuffer.call(buf) == 0) return;

const fp: [*:0]const u8 = "aether_screenshot.bmp";
const fp: [*:0]const u8 = @ptrCast(&screenshot_filename);
const hFile = win32.CreateFileA(fp, 0x40000000, 0, null, 2, 0x80, null) orelse return;
defer _ = win32.CloseHandle(hFile);

Expand Down Expand Up @@ -2420,7 +2475,7 @@ pub fn flushScreenshot() void {
_ = win32.WriteFile(hFile, &row_buf, row_stride, &written, null);
}
_ = win32.FlushFileBuffers(hFile);
log.print("screenshot saved");
log.printStr("screenshot saved: ", std.mem.sliceTo(&screenshot_filename, 0));
}

const bindings = [_]Binding{
Expand Down Expand Up @@ -2505,6 +2560,7 @@ const bindings = [_]Binding{
// Process control
.{ .name = "closeNPCInteract", .func = &jsCloseNPCInteract, .nargs = 0 },
.{ .name = "npcMenuSelect", .func = &jsNpcMenuSelect, .nargs = 1 },
.{ .name = "npcMenuByMenuId", .func = &jsNpcMenuByMenuId, .nargs = 1 },
.{ .name = "exitGame", .func = &jsExitGame, .nargs = 0 },
.{ .name = "exitClient", .func = &jsExitClient, .nargs = 0 },
.{ .name = "takeWaypoint", .func = &jsTakeWaypoint, .nargs = 2 },
Expand Down
66 changes: 66 additions & 0 deletions packages/sdk/game/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,72 @@ export class Game {
return false
}

/**
* Walk to a world position. Uses findPath to break long walks into nodes
* (D2 clips clicks to viewport; you can't reach a target a screen away in
* one click). Each node is walked with the kolbot single-click pattern:
* one click → wait for walk to start → wait for walk to finish → re-click
* if not close enough.
*
* Returns true on arrival, false if dead or stuck.
*/
*moveTo(tx: number, ty: number, minDist = 4) {
const minDist2 = minDist * minDist

const path = this.findPath(tx, ty)
const nodes = path.slice()
// Ensure the actual target is always the final node so we close in fully.
const last = nodes[nodes.length - 1]
if (!last || last.x !== tx || last.y !== ty) nodes.push({ x: tx, y: ty })

for (const node of nodes) {
const arrived = yield* this.walkToNode(node.x, node.y, minDist2)
if (arrived === "dead") return false
// Even if we didn't reach this intermediate node, try the next one.
}

// Final check against the actual target.
const dx = this.player.x - tx, dy = this.player.y - ty
return dx * dx + dy * dy <= minDist2
}

/** Single-node walk — kolbot pattern. */
private *walkToNode(tx: number, ty: number, minDist2: number): Generator<void, "ok" | "stuck" | "dead"> {
let consecutiveFailedStarts = 0
for (let attempt = 0; attempt < 6; attempt++) {
const p = this.player
const dx = p.x - tx, dy = p.y - ty
if (dx * dx + dy * dy <= minDist2) return "ok"
const m0 = p.mode
if (m0 === 0 || m0 === 17) return "dead"

nativeMove(tx, ty)

let walking = false
for (let t = 0; t < 20; t++) {
yield
const m = p.mode
if (m === 0 || m === 17) return "dead"
if (m === 2 || m === 3 || m === 6) { walking = true; break }
}
if (!walking) {
if (++consecutiveFailedStarts >= 3) return "stuck"
continue
}
consecutiveFailedStarts = 0

for (let t = 0; t < 120; t++) {
yield
const m = p.mode
if (m === 0 || m === 17) return "dead"
const dx2 = p.x - tx, dy2 = p.y - ty
if (dx2 * dx2 + dy2 * dy2 <= minDist2) return "ok"
if (m === 1 || m === 5) break
}
}
return "stuck"
}

// ── Packet hooks (S2C interception) ──────────────────────────────

private _packetHandlers = new Map<number, Array<(data: Uint8Array) => boolean | void>>()
Expand Down
87 changes: 87 additions & 0 deletions packages/sdk/game/npc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { test } from "node:test"
import assert from "node:assert/strict"
import { UiFlags, MenuOption } from "diablo:constants"
import { __mockNative } from "../test/native-stub.js"
import { NPC } from "./unit.js"
import { runGenerator } from "../test/mock-game.js"

test("NPC.openTrade: picks Trade by menu ID, returns true when Shop opens", () => {
__mockNative.reset()
const npc = new NPC(100)

// interact() polls NPCMenu/Shop. We trigger NPCMenu after interact fires.
__mockNative.setMenuIdResult(MenuOption.Trade, true,
() => __mockNative.setUIFlag(UiFlags.Shop, true))

let result: boolean | undefined
const wrapped = (function* () { result = yield* npc.openTrade() })()

// Step gen until interact is captured, then unblock NPCMenu
for (let i = 0; i < 200; i++) {
if (wrapped.next().done) break
if (__mockNative.getInteracts().length === 1 && !__mockNative.getMenuIdCalls().length) {
__mockNative.setUIFlag(UiFlags.NPCMenu, true)
}
}

assert.equal(result, true)
assert.equal(__mockNative.getInteracts().length, 1)
assert.deepEqual(__mockNative.getInteracts()[0], { type: 1, unitId: 100 })
assert.deepEqual(__mockNative.getMenuIdCalls(), [MenuOption.Trade])
})

test("NPC.openTrade: falls back to TradeRepair when Trade unavailable", () => {
__mockNative.reset()
const npc = new NPC(154) // Charsi-like

__mockNative.setMenuIdResult(MenuOption.Trade, false)
__mockNative.setMenuIdResult(MenuOption.TradeRepair, true,
() => __mockNative.setUIFlag(UiFlags.Shop, true))

let result: boolean | undefined
const wrapped = (function* () { result = yield* npc.openTrade() })()

for (let i = 0; i < 200; i++) {
if (wrapped.next().done) break
if (__mockNative.getInteracts().length === 1 && !__mockNative.getMenuIdCalls().length) {
__mockNative.setUIFlag(UiFlags.NPCMenu, true)
}
}

assert.equal(result, true)
assert.deepEqual(__mockNative.getMenuIdCalls(), [MenuOption.Trade, MenuOption.TradeRepair])
})

test("NPC.openTrade: returns false if interact never sees menu/shop", () => {
__mockNative.reset()
const npc = new NPC(200)

let result: boolean | undefined
const wrapped = (function* () { result = yield* npc.openTrade() })()
runGenerator(wrapped)

assert.equal(result, false)
assert.equal(__mockNative.getMenuIdCalls().length, 0, "no menu select on failed interact")
})

test("NPC.repair: interacts, picks TradeRepair, sends repair packet", () => {
__mockNative.reset()
const npc = new NPC(154)

__mockNative.setMenuIdResult(MenuOption.TradeRepair, true)

const wrapped = npc.repair()

for (let i = 0; i < 200; i++) {
if (wrapped.next().done) break
// Unblock interact's waitUntil
if (__mockNative.getInteracts().length === 1) {
__mockNative.setUIFlag(UiFlags.NPCMenu, true)
}
}

assert.equal(__mockNative.getInteracts().length, 1)
assert.deepEqual(__mockNative.getMenuIdCalls(), [MenuOption.TradeRepair])
assert.equal(__mockNative.getSentPackets().length, 1, "should have sent NpcRepair packet")
assert.equal(__mockNative.getCloseCount(), 1, "should have closed dialog")
})
Loading
Loading