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.
- roblox_studio_physical_operation_mcp/__init__.py +13 -0
- roblox_studio_physical_operation_mcp/__main__.py +8 -0
- roblox_studio_physical_operation_mcp/log_filter.py +99 -0
- roblox_studio_physical_operation_mcp/log_utils.py +467 -0
- roblox_studio_physical_operation_mcp/server.py +602 -0
- roblox_studio_physical_operation_mcp/studio_manager.py +476 -0
- roblox_studio_physical_operation_mcp/toolbar_detector.py +513 -0
- roblox_studio_physical_operation_mcp/windows_utils.py +578 -0
- roblox_studio_physical_operation_mcp-0.1.0.dist-info/METADATA +273 -0
- roblox_studio_physical_operation_mcp-0.1.0.dist-info/RECORD +13 -0
- roblox_studio_physical_operation_mcp-0.1.0.dist-info/WHEEL +4 -0
- roblox_studio_physical_operation_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- roblox_studio_physical_operation_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|