roblox-studio-physical-operation-mcp 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,476 @@
1
+ """
2
+ Studio 会话管理
3
+
4
+ 通过扫描进程命令行和日志文件自动查找 Studio 会话,
5
+ 支持本地文件和云端 Place。
6
+
7
+ 使用索引文件缓存日志命令行信息,减少 IO 操作。
8
+ """
9
+
10
+ import os
11
+ import subprocess
12
+ import time
13
+ import glob
14
+ import json
15
+ from typing import Optional
16
+ from dataclasses import dataclass
17
+ from watchdog.observers import Observer
18
+ from watchdog.events import FileSystemEventHandler
19
+ import threading
20
+
21
+ from .windows_utils import get_studio_path, find_window_by_pid
22
+ from .log_utils import LOG_DIR
23
+
24
+
25
+ # 索引文件路径
26
+ LOG_INDEX_PATH = os.path.join(LOG_DIR, '.mcp_log_index.json')
27
+
28
+
29
+ @dataclass
30
+ class SessionInfo:
31
+ """会话信息"""
32
+ place_path: str
33
+ log_path: str
34
+ hwnd: int
35
+ pid: Optional[int] = None
36
+
37
+
38
+ class LogFileWatcher(FileSystemEventHandler):
39
+ """监听日志目录,捕获新创建的日志文件"""
40
+
41
+ def __init__(self):
42
+ self.new_log: Optional[str] = None
43
+ self.event = threading.Event()
44
+
45
+ def on_created(self, event):
46
+ if not event.is_directory and "Studio" in event.src_path and event.src_path.endswith(".log"):
47
+ self.new_log = event.src_path
48
+ self.event.set()
49
+
50
+ def wait_for_log(self, timeout: float = 10.0) -> Optional[str]:
51
+ """等待新日志文件创建"""
52
+ self.event.wait(timeout)
53
+ return self.new_log
54
+
55
+
56
+ # ============ 索引管理 ============
57
+
58
+ def load_log_index() -> dict:
59
+ """
60
+ 加载日志索引文件
61
+
62
+ Returns:
63
+ {log_filename: {"cmdline": str, "mtime": float}, ...}
64
+ """
65
+ if not os.path.exists(LOG_INDEX_PATH):
66
+ return {}
67
+
68
+ try:
69
+ with open(LOG_INDEX_PATH, 'r', encoding='utf-8') as f:
70
+ return json.load(f)
71
+ except Exception:
72
+ return {}
73
+
74
+
75
+ def save_log_index(index: dict) -> None:
76
+ """保存日志索引文件"""
77
+ try:
78
+ with open(LOG_INDEX_PATH, 'w', encoding='utf-8') as f:
79
+ json.dump(index, f, indent=2)
80
+ except Exception:
81
+ pass
82
+
83
+
84
+ def get_log_command_line_raw(log_path: str) -> Optional[str]:
85
+ """
86
+ 从日志文件前 30 行中提取命令行(不使用缓存)
87
+
88
+ Returns:
89
+ 命令行字符串,或 None
90
+ """
91
+ try:
92
+ with open(log_path, 'r', encoding='utf-8', errors='ignore') as f:
93
+ found_cmd = False
94
+ for i, line in enumerate(f):
95
+ if i > 30:
96
+ break
97
+ line = line.strip()
98
+ if 'Command line:' in line:
99
+ found_cmd = True
100
+ continue
101
+ if found_cmd and 'RobloxStudioBeta.exe' in line:
102
+ return line
103
+ except Exception:
104
+ pass
105
+ return None
106
+
107
+
108
+ def get_log_command_line(log_path: str, index: dict = None) -> Optional[str]:
109
+ """
110
+ 从日志文件提取命令行(使用索引缓存)
111
+
112
+ Args:
113
+ log_path: 日志文件路径
114
+ index: 索引字典(可选,用于批量操作时共享)
115
+
116
+ Returns:
117
+ 命令行字符串,或 None
118
+ """
119
+ filename = os.path.basename(log_path)
120
+
121
+ # 获取文件修改时间
122
+ try:
123
+ mtime = os.path.getmtime(log_path)
124
+ except Exception:
125
+ return None
126
+
127
+ # 加载索引
128
+ if index is None:
129
+ index = load_log_index()
130
+
131
+ # 检查缓存是否有效
132
+ if filename in index:
133
+ cached = index[filename]
134
+ if cached.get('mtime') == mtime:
135
+ return cached.get('cmdline')
136
+
137
+ # 缓存无效,读取文件
138
+ cmdline = get_log_command_line_raw(log_path)
139
+
140
+ # 更新索引
141
+ index[filename] = {
142
+ 'cmdline': cmdline,
143
+ 'mtime': mtime
144
+ }
145
+
146
+ return cmdline
147
+
148
+
149
+ def get_all_log_cmdlines(log_files: list[str]) -> dict[str, str]:
150
+ """
151
+ 批量获取日志文件的命令行(使用索引缓存)
152
+
153
+ Args:
154
+ log_files: 日志文件路径列表
155
+
156
+ Returns:
157
+ {log_path: cmdline, ...}
158
+ """
159
+ index = load_log_index()
160
+ result = {}
161
+ updated = False
162
+
163
+ for log_path in log_files:
164
+ filename = os.path.basename(log_path)
165
+
166
+ try:
167
+ mtime = os.path.getmtime(log_path)
168
+ except Exception:
169
+ continue
170
+
171
+ # 检查缓存
172
+ if filename in index and index[filename].get('mtime') == mtime:
173
+ cmdline = index[filename].get('cmdline')
174
+ else:
175
+ # 读取文件
176
+ cmdline = get_log_command_line_raw(log_path)
177
+ index[filename] = {'cmdline': cmdline, 'mtime': mtime}
178
+ updated = True
179
+
180
+ if cmdline:
181
+ result[log_path] = cmdline
182
+
183
+ # 保存更新的索引
184
+ if updated:
185
+ save_log_index(index)
186
+
187
+ return result
188
+
189
+
190
+ # ============ 进程和日志查找 ============
191
+
192
+
193
+ def get_all_studio_processes() -> list[dict]:
194
+ """
195
+ 获取所有运行中的 Studio 进程信息
196
+
197
+ Returns:
198
+ [{"pid": int, "cmdline": str}, ...]
199
+ """
200
+ result = subprocess.run(
201
+ ['wmic', 'process', 'where', "name='RobloxStudioBeta.exe'", 'get', 'ProcessId,CommandLine', '/format:csv'],
202
+ capture_output=True,
203
+ text=True
204
+ )
205
+
206
+ processes = []
207
+ for line in result.stdout.strip().split('\n'):
208
+ line = line.strip()
209
+ if not line or line.startswith('Node,CommandLine'):
210
+ continue
211
+
212
+ parts = line.split(',')
213
+ if len(parts) >= 3:
214
+ # CSV 格式: Node,CommandLine,ProcessId
215
+ cmdline = ','.join(parts[1:-1]) # 命令行可能包含逗号
216
+ try:
217
+ pid = int(parts[-1])
218
+ processes.append({"pid": pid, "cmdline": cmdline})
219
+ except ValueError:
220
+ pass
221
+
222
+ return processes
223
+
224
+
225
+ def find_latest_studio_logs(limit: int = 10) -> list[str]:
226
+ """
227
+ 获取最新的 Studio 日志文件列表
228
+
229
+ Returns:
230
+ 日志文件路径列表(按修改时间倒序)
231
+ """
232
+ pattern = os.path.join(LOG_DIR, '*_Studio_*_last.log')
233
+ files = glob.glob(pattern)
234
+
235
+ # 按修改时间排序
236
+ files.sort(key=lambda x: os.path.getmtime(x), reverse=True)
237
+
238
+ return files[:limit]
239
+
240
+
241
+ def find_session_by_place_path(place_path: str) -> Optional[SessionInfo]:
242
+ """
243
+ 通过本地文件路径查找 Studio 会话
244
+
245
+ Args:
246
+ place_path: .rbxl 文件路径
247
+
248
+ Returns:
249
+ SessionInfo 或 None
250
+ """
251
+ # 标准化路径
252
+ place_path = os.path.normpath(place_path).replace('/', '\\')
253
+ place_path_lower = place_path.lower()
254
+
255
+ # 获取所有运行中的 Studio 进程
256
+ processes = get_all_studio_processes()
257
+
258
+ # 获取最新的日志文件及其命令行(使用索引缓存)
259
+ log_files = find_latest_studio_logs(20)
260
+ log_cmdlines = get_all_log_cmdlines(log_files)
261
+
262
+ for log_path, cmdline in log_cmdlines.items():
263
+ # 标准化命令行中的路径
264
+ cmdline_normalized = cmdline.replace('/', '\\').lower()
265
+
266
+ # 检查是否包含目标路径
267
+ if place_path_lower not in cmdline_normalized:
268
+ continue
269
+
270
+ # 找到匹配的日志,现在找对应的进程
271
+ for proc in processes:
272
+ proc_cmdline = proc['cmdline'].replace('/', '\\').lower()
273
+ if place_path_lower in proc_cmdline:
274
+ # 找到匹配的进程
275
+ pid = proc['pid']
276
+ hwnd = find_window_by_pid(pid)
277
+ if hwnd:
278
+ return SessionInfo(
279
+ place_path=place_path,
280
+ log_path=log_path,
281
+ hwnd=hwnd,
282
+ pid=pid
283
+ )
284
+
285
+ return None
286
+
287
+
288
+ def find_session_by_place_id(place_id: int) -> Optional[SessionInfo]:
289
+ """
290
+ 通过云端 Place ID 查找 Studio 会话
291
+
292
+ Args:
293
+ place_id: Roblox Place ID
294
+
295
+ Returns:
296
+ SessionInfo 或 None
297
+ """
298
+ place_id_str = str(place_id)
299
+ pattern = f'-placeId {place_id_str}'
300
+
301
+ # 获取所有运行中的 Studio 进程
302
+ processes = get_all_studio_processes()
303
+
304
+ # 获取最新的日志文件及其命令行(使用索引缓存)
305
+ log_files = find_latest_studio_logs(20)
306
+ log_cmdlines = get_all_log_cmdlines(log_files)
307
+
308
+ for log_path, cmdline in log_cmdlines.items():
309
+ # 检查是否包含目标 placeId
310
+ if pattern not in cmdline:
311
+ continue
312
+
313
+ # 找到匹配的日志,现在找对应的进程
314
+ for proc in processes:
315
+ if pattern in proc['cmdline']:
316
+ # 找到匹配的进程
317
+ pid = proc['pid']
318
+ hwnd = find_window_by_pid(pid)
319
+ if hwnd:
320
+ return SessionInfo(
321
+ place_path=f"cloud:{place_id}",
322
+ log_path=log_path,
323
+ hwnd=hwnd,
324
+ pid=pid
325
+ )
326
+
327
+ return None
328
+
329
+ return None
330
+
331
+
332
+ def get_session_universal(
333
+ place_path: str = None,
334
+ place_id: int = None
335
+ ) -> tuple[bool, str, Optional[SessionInfo]]:
336
+ """
337
+ 通用会话获取函数,支持本地文件和云端 Place
338
+
339
+ Args:
340
+ place_path: 本地 .rbxl 文件路径
341
+ place_id: 云端 Place ID
342
+
343
+ Returns:
344
+ (success, message, session_info)
345
+ """
346
+ if place_path is None and place_id is None:
347
+ return False, "必须指定 place_path 或 place_id", None
348
+
349
+ if place_path is not None and place_id is not None:
350
+ return False, "不能同时指定 place_path 和 place_id", None
351
+
352
+ if place_path is not None:
353
+ session = find_session_by_place_path(place_path)
354
+ if session:
355
+ return True, "找到本地 Place 会话", session
356
+ return False, "Place 未被打开", None
357
+
358
+ else:
359
+ session = find_session_by_place_id(place_id)
360
+ if session:
361
+ return True, "找到云端 Place 会话", session
362
+ return False, f"未找到 Place ID {place_id} 的会话", None
363
+
364
+
365
+ # ============ 启动/关闭 Studio ============
366
+
367
+ def open_place(place_path: str) -> tuple[bool, str]:
368
+ """
369
+ 打开本地 Place 文件
370
+
371
+ Args:
372
+ place_path: .rbxl 文件路径
373
+
374
+ Returns:
375
+ (success, message)
376
+ """
377
+ if not os.path.exists(place_path):
378
+ return False, f"Place 文件不存在: {place_path}"
379
+
380
+ # 检查是否已打开
381
+ session = find_session_by_place_path(place_path)
382
+ if session:
383
+ return False, f"Place 已被打开 (PID: {session.pid})"
384
+
385
+ # 获取 Studio 路径
386
+ studio_path = get_studio_path()
387
+ if not studio_path:
388
+ return False, "无法从注册表获取 Roblox Studio 路径"
389
+
390
+ if not os.path.exists(studio_path):
391
+ return False, f"Roblox Studio 不存在: {studio_path}"
392
+
393
+ # 启动日志监听
394
+ watcher = LogFileWatcher()
395
+ observer = Observer()
396
+ observer.schedule(watcher, LOG_DIR, recursive=False)
397
+ observer.start()
398
+
399
+ try:
400
+ # 启动 Studio
401
+ process = subprocess.Popen([studio_path, place_path])
402
+ pid = process.pid
403
+
404
+ # 等待日志文件创建
405
+ log_path = watcher.wait_for_log(timeout=15.0)
406
+
407
+ # 等待窗口出现
408
+ hwnd = None
409
+ start_time = time.time()
410
+ timeout = 30.0
411
+ while time.time() - start_time < timeout:
412
+ hwnd = find_window_by_pid(pid)
413
+ if hwnd:
414
+ break
415
+ if process.poll() is not None:
416
+ return False, f"Studio 进程已退出 (exit code: {process.returncode})"
417
+ time.sleep(1)
418
+
419
+ if not hwnd:
420
+ return False, f"Studio 已启动 (PID: {pid}),但未找到窗口"
421
+
422
+ if not log_path:
423
+ return False, f"Studio 已启动 (PID: {pid}),但未找到日志文件"
424
+
425
+ return True, f"Studio 已启动 (PID: {pid}, HWND: {hwnd})"
426
+
427
+ except Exception as e:
428
+ return False, f"启动 Studio 失败: {e}"
429
+ finally:
430
+ observer.stop()
431
+ observer.join(timeout=5.0)
432
+
433
+
434
+ def close_place(place_path: str = None, place_id: int = None) -> tuple[bool, str]:
435
+ """
436
+ 关闭 Studio
437
+
438
+ Args:
439
+ place_path: 本地 .rbxl 文件路径
440
+ place_id: 云端 Place ID
441
+
442
+ Returns:
443
+ (success, message)
444
+ """
445
+ success, msg, session = get_session_universal(place_path, place_id)
446
+ if not success:
447
+ return False, msg
448
+
449
+ try:
450
+ subprocess.run(
451
+ ["taskkill", "/F", "/PID", str(session.pid)],
452
+ capture_output=True,
453
+ timeout=10.0
454
+ )
455
+
456
+ # 如果是本地文件,删除 .lock 文件
457
+ if place_path and not place_path.startswith("cloud:"):
458
+ lock_path = place_path + ".lock"
459
+ for _ in range(3):
460
+ time.sleep(0.2)
461
+ if os.path.exists(lock_path):
462
+ try:
463
+ os.remove(lock_path)
464
+ break
465
+ except Exception:
466
+ pass
467
+
468
+ return True, f"Studio 已关闭 (PID: {session.pid})"
469
+ except Exception as e:
470
+ return False, f"关闭 Studio 失败: {e}"
471
+
472
+
473
+ # 兼容旧接口
474
+ def get_session(place_path: str) -> tuple[bool, str, Optional[SessionInfo]]:
475
+ """兼容旧接口,使用 get_session_universal"""
476
+ return get_session_universal(place_path=place_path)