cheatengine 5.8.13 → 5.8.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/base.js ADDED
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env node
2
+ // -*- coding: utf-8 -*-
3
+ /**
4
+ * Cheat Engine MCP Server - Named pipe bridge to Cheat Engine
5
+ * JavaScript/Node.js version
6
+ */
7
+
8
+ const net = require('net');
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { spawn, execSync } = require('child_process');
12
+
13
+ // ============ Configuration ============
14
+ const Config = {
15
+ PIPE_NAME: `\\\\.\\pipe\\${process.env.CE_MCP_PIPE_NAME || 'ce_mcp_bridge'}`,
16
+ AUTH_TOKEN: process.env.CE_MCP_AUTH_TOKEN || null,
17
+ MAX_RETRIES: 3,
18
+ CHUNK_SIZE: 1024 * 1024,
19
+ MAX_RESPONSE_SIZE: 10 * 1024 * 1024,
20
+ DEBUG: process.env.DEBUG === '1' || false,
21
+ };
22
+
23
+ // ============ Timeout Error ============
24
+ class TimeoutError extends Error {
25
+ constructor(toolName, params, elapsedTime, timeout) {
26
+ super(`Tool '${toolName}' timed out after ${elapsedTime.toFixed(2)}s (timeout: ${timeout}s)`);
27
+ this.toolName = toolName;
28
+ this.params = params;
29
+ this.elapsedTime = elapsedTime;
30
+ this.timeout = timeout;
31
+ }
32
+ }
33
+
34
+ // ============ Logger ============
35
+ class Logger {
36
+ static log(msg, level = 'INFO') {
37
+ if (level === 'DEBUG' && !Config.DEBUG) return;
38
+ process.stderr.write(`[CheatEngine-MCP-${level}] ${msg}\n`);
39
+ }
40
+
41
+ static info(msg) {
42
+ Logger.log(msg, 'INFO');
43
+ }
44
+
45
+ static debug(msg) {
46
+ Logger.log(msg, 'DEBUG');
47
+ }
48
+
49
+ static error(msg) {
50
+ Logger.log(msg, 'ERROR');
51
+ }
52
+ }
53
+
54
+ const log = Logger;
55
+
56
+ // ============ Connection Health Monitoring ============
57
+ class ConnectionHealth {
58
+ constructor() {
59
+ this.lastSuccessTime = 0.0;
60
+ this.connectionAttempts = 0;
61
+ this.consecutiveErrors = 0;
62
+ this.totalErrors = 0;
63
+ this.lastError = null;
64
+ this.isConnected = false;
65
+ }
66
+ }
67
+
68
+ class HealthMonitor {
69
+ constructor(errorThreshold = 5) {
70
+ this.health = new ConnectionHealth();
71
+ this.errorThreshold = errorThreshold;
72
+ }
73
+
74
+ recordSuccess() {
75
+ this.health.lastSuccessTime = Date.now() / 1000;
76
+ this.health.consecutiveErrors = 0;
77
+ this.health.isConnected = true;
78
+ }
79
+
80
+ recordError(error) {
81
+ this.health.consecutiveErrors++;
82
+ this.health.totalErrors++;
83
+ this.health.lastError = error;
84
+ this.health.isConnected = false;
85
+ return this.health.consecutiveErrors >= this.errorThreshold;
86
+ }
87
+
88
+ recordConnectionAttempt() {
89
+ this.health.connectionAttempts++;
90
+ }
91
+
92
+ getMetrics() {
93
+ return {
94
+ last_success_time: this.health.lastSuccessTime,
95
+ connection_attempts: this.health.connectionAttempts,
96
+ consecutive_errors: this.health.consecutiveErrors,
97
+ total_errors: this.health.totalErrors,
98
+ last_error: this.health.lastError,
99
+ is_connected: this.health.isConnected,
100
+ };
101
+ }
102
+
103
+ reset() {
104
+ this.health = new ConnectionHealth();
105
+ }
106
+ }
107
+
108
+ // ============ Timeout Manager ============
109
+ class TimeoutManager {
110
+ static DEFAULT_TIMEOUT = 30;
111
+
112
+ static TOOL_TIMEOUTS = {
113
+ ce_find_pointer_path: 60,
114
+ ce_break_and_trace: 60,
115
+ ce_find_what_accesses: 30,
116
+ ce_find_what_writes: 30,
117
+ ce_aob_scan: 30,
118
+ ce_value_scan: 30,
119
+ ce_scan_new: 30,
120
+ ce_scan_next: 30,
121
+ ce_build_cfg: 45,
122
+ ce_symbolic_trace: 45,
123
+ ce_call_function: 30,
124
+ };
125
+
126
+ constructor(defaultTimeout = null, toolTimeouts = null) {
127
+ this.defaultTimeout = defaultTimeout !== null ? defaultTimeout : TimeoutManager.DEFAULT_TIMEOUT;
128
+ this.toolTimeouts = { ...TimeoutManager.TOOL_TIMEOUTS };
129
+ if (toolTimeouts) {
130
+ Object.assign(this.toolTimeouts, toolTimeouts);
131
+ }
132
+ }
133
+
134
+ getTimeout(toolName) {
135
+ return this.toolTimeouts[toolName] || this.defaultTimeout;
136
+ }
137
+
138
+ setTimeout(toolName, timeout) {
139
+ this.toolTimeouts[toolName] = timeout;
140
+ }
141
+
142
+ getAllTimeouts() {
143
+ return {
144
+ default_timeout: this.defaultTimeout,
145
+ tool_timeouts: { ...this.toolTimeouts },
146
+ };
147
+ }
148
+ }
149
+
150
+ // ============ Metrics Collector ============
151
+ class ToolMetrics {
152
+ constructor() {
153
+ this.callCount = 0;
154
+ this.totalTime = 0.0;
155
+ this.errorCount = 0;
156
+ this.lastCallTime = 0.0;
157
+ }
158
+ }
159
+
160
+ class MetricsCollector {
161
+ constructor() {
162
+ this.toolMetrics = new Map();
163
+ this.startTime = Date.now() / 1000;
164
+ }
165
+
166
+ recordCall(toolName, duration, isError = false) {
167
+ if (!this.toolMetrics.has(toolName)) {
168
+ this.toolMetrics.set(toolName, new ToolMetrics());
169
+ }
170
+
171
+ const metrics = this.toolMetrics.get(toolName);
172
+ metrics.callCount++;
173
+ metrics.totalTime += duration;
174
+ metrics.lastCallTime = Date.now() / 1000;
175
+ if (isError) {
176
+ metrics.errorCount++;
177
+ }
178
+ }
179
+
180
+ getToolMetrics(toolName) {
181
+ const metrics = this.toolMetrics.get(toolName);
182
+ if (!metrics) return null;
183
+
184
+ return {
185
+ calls: metrics.callCount,
186
+ total_time: metrics.totalTime,
187
+ avg_time: metrics.callCount > 0 ? metrics.totalTime / metrics.callCount : 0,
188
+ errors: metrics.errorCount,
189
+ error_rate: metrics.callCount > 0 ? metrics.errorCount / metrics.callCount : 0,
190
+ last_call_time: metrics.lastCallTime,
191
+ };
192
+ }
193
+
194
+ getSummary() {
195
+ let totalCalls = 0;
196
+ let totalErrors = 0;
197
+ const tools = {};
198
+
199
+ for (const [name, metrics] of this.toolMetrics) {
200
+ totalCalls += metrics.callCount;
201
+ totalErrors += metrics.errorCount;
202
+ tools[name] = {
203
+ calls: metrics.callCount,
204
+ avg_time: metrics.callCount > 0 ? metrics.totalTime / metrics.callCount : 0,
205
+ errors: metrics.errorCount,
206
+ error_rate: metrics.callCount > 0 ? metrics.errorCount / metrics.callCount : 0,
207
+ };
208
+ }
209
+
210
+ return {
211
+ uptime: Date.now() / 1000 - this.startTime,
212
+ total_calls: totalCalls,
213
+ total_errors: totalErrors,
214
+ error_rate: totalCalls > 0 ? totalErrors / totalCalls : 0,
215
+ tools,
216
+ };
217
+ }
218
+
219
+ reset() {
220
+ this.toolMetrics.clear();
221
+ this.startTime = Date.now() / 1000;
222
+ }
223
+ }
224
+
225
+ module.exports = {
226
+ Config,
227
+ TimeoutError,
228
+ ConnectionHealth,
229
+ HealthMonitor,
230
+ TimeoutManager,
231
+ ToolMetrics,
232
+ MetricsCollector,
233
+ Logger,
234
+ log,
235
+ };
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Windows Named Pipe Client for Cheat Engine MCP Bridge
3
+ * Uses Node.js net module to connect to Windows named pipes
4
+ */
5
+
6
+ const net = require('net');
7
+ const { EventEmitter } = require('events');
8
+ const { Config, HealthMonitor, Logger, log } = require('./base');
9
+
10
+ // Windows error codes
11
+ const ERROR_FILE_NOT_FOUND = 2;
12
+ const ERROR_PIPE_BUSY = 231;
13
+ const ERROR_ACCESS_DENIED = 5;
14
+ const ERROR_BROKEN_PIPE = 109;
15
+ const ERROR_PIPE_NOT_CONNECTED = 233;
16
+
17
+ class PipeClient extends EventEmitter {
18
+ constructor(pipeName = Config.PIPE_NAME, errorThreshold = 5) {
19
+ super();
20
+ this.pipeName = pipeName;
21
+ this.socket = null;
22
+ this.healthMonitor = new HealthMonitor(errorThreshold);
23
+ this.reconnectTimer = null;
24
+ this.stopReconnectFlag = false;
25
+ this.connected = false;
26
+ this.connectionAttempts = 0;
27
+ this.lastError = null;
28
+ this.lastConnectErrorCode = null;
29
+ this.lastSuccessTime = 0;
30
+ this.responseBuffer = Buffer.alloc(0);
31
+ this.pendingResponse = null;
32
+ }
33
+
34
+ isValid() {
35
+ return this.socket !== null && this.connected && !this.socket.destroyed;
36
+ }
37
+
38
+ async _tryConnectOnce() {
39
+ return new Promise((resolve) => {
40
+ try {
41
+ const socket = net.createConnection(this.pipeName, () => {
42
+ this.socket = socket;
43
+ this.connected = true;
44
+ this.lastError = null;
45
+ this.lastConnectErrorCode = null;
46
+ this.lastSuccessTime = Date.now() / 1000;
47
+ log.info('Connected to Cheat Engine Pipe');
48
+ this.healthMonitor.recordSuccess();
49
+ resolve(true);
50
+ });
51
+
52
+ socket.on('error', (err) => {
53
+ this.lastConnectErrorCode = err.code;
54
+ this.lastError = err.message;
55
+
56
+ // Handle specific error codes
57
+ if (err.code === 'ENOENT') {
58
+ this.lastConnectErrorCode = ERROR_FILE_NOT_FOUND;
59
+ } else if (err.code === 'EACCES') {
60
+ this.lastConnectErrorCode = ERROR_ACCESS_DENIED;
61
+ } else if (err.message && err.message.includes('EBUSY')) {
62
+ this.lastConnectErrorCode = ERROR_PIPE_BUSY;
63
+ }
64
+
65
+ resolve(false);
66
+ });
67
+
68
+ socket.on('close', () => {
69
+ this.connected = false;
70
+ if (this.socket) {
71
+ this.socket = null;
72
+ }
73
+ });
74
+
75
+ socket.on('data', (data) => {
76
+ this._handleData(data);
77
+ });
78
+
79
+ } catch (err) {
80
+ this.lastError = err.message;
81
+ resolve(false);
82
+ }
83
+ });
84
+ }
85
+
86
+ _handleData(data) {
87
+ this.responseBuffer = Buffer.concat([this.responseBuffer, data]);
88
+
89
+ // Try to parse response
90
+ if (this.pendingResponse && this.responseBuffer.length >= 4) {
91
+ const respLen = this.responseBuffer.readUInt32LE(0);
92
+
93
+ if (respLen === 0 || respLen > Config.MAX_RESPONSE_SIZE) {
94
+ this.pendingResponse.reject(new Error(`Invalid response size: ${respLen}`));
95
+ this.pendingResponse = null;
96
+ this.responseBuffer = Buffer.alloc(0);
97
+ return;
98
+ }
99
+
100
+ if (this.responseBuffer.length >= 4 + respLen) {
101
+ const respData = this.responseBuffer.slice(4, 4 + respLen);
102
+ this.responseBuffer = this.responseBuffer.slice(4 + respLen);
103
+
104
+ try {
105
+ const decoded = this._decodeResponse(respData);
106
+ const result = JSON.parse(decoded);
107
+ this.pendingResponse.resolve(result);
108
+ } catch (err) {
109
+ this.pendingResponse.reject(err);
110
+ }
111
+ this.pendingResponse = null;
112
+ }
113
+ }
114
+ }
115
+
116
+ _decodeResponse(data) {
117
+ // Try multiple encodings
118
+ const encodings = ['utf-8', 'latin1'];
119
+ for (const encoding of encodings) {
120
+ try {
121
+ return data.toString(encoding);
122
+ } catch (err) {
123
+ continue;
124
+ }
125
+ }
126
+ return data.toString('utf-8');
127
+ }
128
+
129
+ async _reconnectLoop() {
130
+ let backoff = 0.5;
131
+ const maxBackoff = 10.0;
132
+
133
+ while (!this.stopReconnectFlag) {
134
+ if (!this.isValid()) {
135
+ this.connectionAttempts++;
136
+ this.healthMonitor.recordConnectionAttempt();
137
+
138
+ if (await this._tryConnectOnce()) {
139
+ backoff = 0.5;
140
+ } else {
141
+ backoff = Math.min(backoff * 1.5, maxBackoff);
142
+ }
143
+ } else {
144
+ backoff = 0.5;
145
+ }
146
+
147
+ await this._sleep(backoff * 1000);
148
+ }
149
+ }
150
+
151
+ _sleep(ms) {
152
+ return new Promise(resolve => setTimeout(resolve, ms));
153
+ }
154
+
155
+ startBackgroundReconnect() {
156
+ if (this.reconnectTimer) return;
157
+ this.stopReconnectFlag = false;
158
+ this._reconnectLoop();
159
+ }
160
+
161
+ async connect(force = false, timeout = 3000) {
162
+ if (!force && this.isValid()) {
163
+ return true;
164
+ }
165
+
166
+ // Try immediate connection
167
+ this.connectionAttempts++;
168
+ this.healthMonitor.recordConnectionAttempt();
169
+ if (await this._tryConnectOnce()) {
170
+ return true;
171
+ }
172
+
173
+ // Start background reconnect and wait
174
+ this.startBackgroundReconnect();
175
+
176
+ // Wait for connection with timeout
177
+ const startTime = Date.now();
178
+ while (Date.now() - startTime < timeout) {
179
+ if (this.isValid()) return true;
180
+ await this._sleep(100);
181
+ }
182
+ return false;
183
+ }
184
+
185
+ _close() {
186
+ this.connected = false;
187
+ if (this.socket) {
188
+ try {
189
+ this.socket.destroy();
190
+ } catch (err) {
191
+ // Ignore
192
+ }
193
+ this.socket = null;
194
+ }
195
+ }
196
+
197
+ _getConnectionErrorMessage(winerrorCode, attempt) {
198
+ const messages = {
199
+ [ERROR_FILE_NOT_FOUND]: (
200
+ "Cheat Engine MCP Bridge not running. " +
201
+ "Load 'ce_mcp_bridge.lua' in CE (Table -> Show Cheat Table Lua Script -> Execute)"
202
+ ),
203
+ [ERROR_PIPE_BUSY]: "Pipe busy - another client may be connected",
204
+ [ERROR_ACCESS_DENIED]: "Access denied - try running as administrator",
205
+ [ERROR_BROKEN_PIPE]: "Connection lost - restart bridge script in CE",
206
+ };
207
+
208
+ const baseMsg = messages[winerrorCode] || `Connection failed (error ${winerrorCode})`;
209
+ return `${baseMsg} [attempt ${attempt}/${Config.MAX_RETRIES}]`;
210
+ }
211
+
212
+ async _doSendReceive(data) {
213
+ // Add authentication token if configured
214
+ if (Config.AUTH_TOKEN) {
215
+ data = { ...data, auth_token: Config.AUTH_TOKEN };
216
+ }
217
+
218
+ for (let retry = 0; retry < Config.MAX_RETRIES; retry++) {
219
+ const connected = await this.connect(retry > 0);
220
+ if (!connected) {
221
+ if (this.lastConnectErrorCode) {
222
+ return {
223
+ error: this._getConnectionErrorMessage(this.lastConnectErrorCode, retry + 1)
224
+ };
225
+ }
226
+ return {
227
+ error: `Cannot connect to CE (attempt ${retry + 1}/${Config.MAX_RETRIES})`
228
+ };
229
+ }
230
+
231
+ try {
232
+ const jsonStr = JSON.stringify(data);
233
+ const jsonBytes = Buffer.from(jsonStr, 'utf-8');
234
+ const lenBuffer = Buffer.alloc(4);
235
+ lenBuffer.writeUInt32LE(jsonBytes.length, 0);
236
+
237
+ // Send request
238
+ this.socket.write(lenBuffer);
239
+ this.socket.write(jsonBytes);
240
+
241
+ // Wait for response
242
+ const result = await this._waitForResponse();
243
+ this.lastSuccessTime = Date.now() / 1000;
244
+ this.healthMonitor.recordSuccess();
245
+ return result;
246
+
247
+ } catch (err) {
248
+ log.debug(`Pipe error (attempt ${retry + 1}): ${err.message}`);
249
+ this.healthMonitor.recordError(err.message);
250
+ this._close();
251
+ await this._sleep(100 * (retry + 1));
252
+ }
253
+ }
254
+
255
+ return { error: this.lastError || "Connection failed after retries" };
256
+ }
257
+
258
+ _waitForResponse() {
259
+ return new Promise((resolve, reject) => {
260
+ this.pendingResponse = { resolve, reject };
261
+ this.responseBuffer = Buffer.alloc(0);
262
+ });
263
+ }
264
+
265
+ async sendReceive(data, timeout = 30000, toolName = null) {
266
+ const timeoutSeconds = timeout / 1000.0;
267
+ const startTime = Date.now();
268
+
269
+ // Create timeout promise
270
+ const timeoutPromise = new Promise((_, reject) => {
271
+ setTimeout(() => {
272
+ const elapsedTime = (Date.now() - startTime) / 1000;
273
+ const errorMsg = `Tool execution timed out after ${elapsedTime.toFixed(2)}s (timeout: ${timeoutSeconds}s)`;
274
+ log.error(`Timeout: tool=${toolName || 'unknown'}, params=${JSON.stringify(data.params || {})}, elapsed=${elapsedTime.toFixed(2)}s`);
275
+ this.healthMonitor.recordError(errorMsg);
276
+ reject(new TimeoutError(toolName || data.command || 'unknown', data.params || {}, elapsedTime, timeoutSeconds));
277
+ }, timeout);
278
+ });
279
+
280
+ try {
281
+ const result = await Promise.race([
282
+ this._doSendReceive(data),
283
+ timeoutPromise
284
+ ]);
285
+ return result;
286
+ } catch (err) {
287
+ if (err instanceof TimeoutError) {
288
+ return {
289
+ error: err.message,
290
+ timeout_info: {
291
+ tool_name: toolName || data.command || 'unknown',
292
+ params: data.params || {},
293
+ elapsed_time: err.elapsedTime,
294
+ timeout_seconds: err.timeout,
295
+ },
296
+ };
297
+ }
298
+ return { error: err.message };
299
+ }
300
+ }
301
+
302
+ getHealthMetrics() {
303
+ return this.healthMonitor.getMetrics();
304
+ }
305
+
306
+ stop() {
307
+ this.stopReconnectFlag = true;
308
+ if (this.reconnectTimer) {
309
+ clearTimeout(this.reconnectTimer);
310
+ this.reconnectTimer = null;
311
+ }
312
+ this._close();
313
+ }
314
+ }
315
+
316
+ module.exports = { PipeClient };