mirror of
https://github.com/LmeSzinc/StarRailCopilot.git
synced 2024-11-25 10:01:10 +00:00
627 lines
20 KiB
Python
627 lines
20 KiB
Python
import asyncio
|
|
import ctypes
|
|
import json
|
|
import os
|
|
import sys
|
|
from functools import partial, wraps
|
|
|
|
import cv2
|
|
import numpy as np
|
|
|
|
from module.base.decorator import cached_property, del_cached_property, has_cached_property
|
|
from module.base.timer import Timer
|
|
from module.base.utils import ensure_time
|
|
from module.config.utils import deep_get
|
|
from module.device.method.minitouch import insert_swipe, random_rectangle_point
|
|
from module.device.method.utils import RETRY_TRIES, retry_sleep
|
|
from module.device.platform import Platform
|
|
from module.exception import RequestHumanTakeover
|
|
from module.logger import logger
|
|
|
|
|
|
class NemuIpcIncompatible(Exception):
|
|
pass
|
|
|
|
|
|
class NemuIpcError(Exception):
|
|
pass
|
|
|
|
|
|
class CaptureStd:
|
|
"""
|
|
Capture stdout and stderr from both python and C library
|
|
https://stackoverflow.com/questions/5081657/how-do-i-prevent-a-c-shared-library-to-print-on-stdout-in-python/17954769
|
|
|
|
```
|
|
with CaptureStd() as capture:
|
|
# String wasn't printed
|
|
print('whatever')
|
|
# But captured in ``capture.stdout``
|
|
print(f'Got stdout: "{capture.stdout}"')
|
|
print(f'Got stderr: "{capture.stderr}"')
|
|
```
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.stdout = b''
|
|
self.stderr = b''
|
|
|
|
def _redirect_stdout(self, to):
|
|
sys.stdout.close()
|
|
os.dup2(to, self.fdout)
|
|
sys.stdout = os.fdopen(self.fdout, 'w')
|
|
|
|
def _redirect_stderr(self, to):
|
|
sys.stderr.close()
|
|
os.dup2(to, self.fderr)
|
|
sys.stderr = os.fdopen(self.fderr, 'w')
|
|
|
|
def __enter__(self):
|
|
self.fdout = sys.stdout.fileno()
|
|
self.fderr = sys.stderr.fileno()
|
|
self.reader_out, self.writer_out = os.pipe()
|
|
self.reader_err, self.writer_err = os.pipe()
|
|
self.old_stdout = os.dup(self.fdout)
|
|
self.old_stderr = os.dup(self.fderr)
|
|
|
|
file_out = os.fdopen(self.writer_out, 'w')
|
|
file_err = os.fdopen(self.writer_err, 'w')
|
|
self._redirect_stdout(to=file_out.fileno())
|
|
self._redirect_stderr(to=file_err.fileno())
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
self._redirect_stdout(to=self.old_stdout)
|
|
self._redirect_stderr(to=self.old_stderr)
|
|
os.close(self.old_stdout)
|
|
os.close(self.old_stderr)
|
|
|
|
self.stdout = self.recvall(self.reader_out)
|
|
self.stderr = self.recvall(self.reader_err)
|
|
os.close(self.reader_out)
|
|
os.close(self.reader_err)
|
|
|
|
@staticmethod
|
|
def recvall(reader, length=1024) -> bytes:
|
|
fragments = []
|
|
while 1:
|
|
chunk = os.read(reader, length)
|
|
if chunk:
|
|
fragments.append(chunk)
|
|
else:
|
|
break
|
|
output = b''.join(fragments)
|
|
return output
|
|
|
|
|
|
class CaptureNemuIpc(CaptureStd):
|
|
instance = None
|
|
|
|
def is_capturing(self):
|
|
"""
|
|
Only capture at the topmost wrapper to avoid nested capturing
|
|
If a capture is ongoing, this instance does nothing
|
|
"""
|
|
cls = self.__class__
|
|
return isinstance(cls.instance, cls) and cls.instance != self
|
|
|
|
def __enter__(self):
|
|
if self.is_capturing():
|
|
return self
|
|
|
|
super().__enter__()
|
|
CaptureNemuIpc.instance = self
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
if self.is_capturing():
|
|
return
|
|
|
|
CaptureNemuIpc.instance = None
|
|
super().__exit__(exc_type, exc_val, exc_tb)
|
|
|
|
self.check_stdout()
|
|
self.check_stderr()
|
|
|
|
def check_stdout(self):
|
|
if not self.stdout:
|
|
return
|
|
logger.info(f'NemuIpc stdout: {self.stdout}')
|
|
|
|
def check_stderr(self):
|
|
if not self.stderr:
|
|
return
|
|
logger.error(f'NemuIpc stderr: {self.stderr}')
|
|
|
|
# Calling an old MuMu12 player
|
|
# Tested on 3.4.0
|
|
# b'nemu_capture_display rpc error: 1783\r\n'
|
|
# Tested on 3.7.3
|
|
# b'nemu_capture_display rpc error: 1745\r\n'
|
|
if b'error: 1783' in self.stderr or b'error: 1745' in self.stderr:
|
|
raise NemuIpcIncompatible(
|
|
f'NemuIpc requires MuMu12 version >= 3.8.13, please check your version')
|
|
# contact_id incorrect
|
|
# b'nemu_capture_display cannot find rpc connection\r\n'
|
|
if b'cannot find rpc connection' in self.stderr:
|
|
raise NemuIpcError(self.stderr)
|
|
# Emulator died
|
|
# b'nemu_capture_display rpc error: 1722\r\n'
|
|
# MuMuVMMSVC.exe died
|
|
# b'nemu_capture_display rpc error: 1726\r\n'
|
|
# No idea how to handle yet
|
|
if b'error: 1722' in self.stderr or b'error: 1726' in self.stderr:
|
|
raise NemuIpcError('Emulator instance is probably dead')
|
|
|
|
|
|
def retry(func):
|
|
@wraps(func)
|
|
def retry_wrapper(self, *args, **kwargs):
|
|
"""
|
|
Args:
|
|
self (NemuIpcImpl):
|
|
"""
|
|
init = None
|
|
for _ in range(RETRY_TRIES):
|
|
try:
|
|
if callable(init):
|
|
retry_sleep(_)
|
|
init()
|
|
return func(self, *args, **kwargs)
|
|
# Can't handle
|
|
except RequestHumanTakeover:
|
|
break
|
|
# Can't handle
|
|
except NemuIpcIncompatible as e:
|
|
logger.error(e)
|
|
break
|
|
# Function call timeout
|
|
except asyncio.TimeoutError:
|
|
logger.warning(f'Func {func.__name__}() call timeout, retrying: {_}')
|
|
|
|
def init():
|
|
self.reconnect()
|
|
# NemuIpcError
|
|
except NemuIpcError as e:
|
|
logger.error(e)
|
|
|
|
def init():
|
|
self.reconnect()
|
|
# Unknown, probably a trucked image
|
|
except Exception as e:
|
|
logger.exception(e)
|
|
|
|
def init():
|
|
pass
|
|
|
|
logger.critical(f'Retry {func.__name__}() failed')
|
|
raise RequestHumanTakeover
|
|
|
|
return retry_wrapper
|
|
|
|
|
|
class NemuIpcImpl:
|
|
def __init__(self, nemu_folder: str, instance_id: int, display_id: int = 0):
|
|
"""
|
|
Args:
|
|
nemu_folder: Installation path of MuMu12, e.g. E:/ProgramFiles/MuMuPlayer-12.0
|
|
instance_id: Emulator instance ID, starting from 0
|
|
display_id: Always 0 if keep app alive was disabled
|
|
"""
|
|
self.nemu_folder: str = nemu_folder
|
|
self.instance_id: int = instance_id
|
|
self.display_id: int = display_id
|
|
|
|
ipc_dll = os.path.abspath(os.path.join(nemu_folder, './shell/sdk/external_renderer_ipc.dll'))
|
|
logger.info(
|
|
f'NemuIpcImpl init, '
|
|
f'nemu_folder={nemu_folder}, '
|
|
f'ipc_dll={ipc_dll}, '
|
|
f'instance_id={instance_id}, '
|
|
f'display_id={display_id}'
|
|
)
|
|
|
|
try:
|
|
self.lib = ctypes.CDLL(ipc_dll)
|
|
except OSError as e:
|
|
logger.error(e)
|
|
# OSError: [WinError 126] 找不到指定的模块。
|
|
if not os.path.exists(ipc_dll):
|
|
raise NemuIpcIncompatible(
|
|
f'ipc_dll={ipc_dll} does not exist, '
|
|
f'NemuIpc requires MuMu12 version >= 3.8.13, please check your version')
|
|
else:
|
|
raise NemuIpcIncompatible(
|
|
f'ipc_dll={ipc_dll} exists, but cannot be loaded')
|
|
self.connect_id: int = 0
|
|
self.width = 0
|
|
self.height = 0
|
|
|
|
def connect(self):
|
|
if self.connect_id > 0:
|
|
return
|
|
|
|
connect_id = self.ev_run_sync(
|
|
self.lib.nemu_connect,
|
|
self.nemu_folder, self.instance_id
|
|
)
|
|
if connect_id == 0:
|
|
raise NemuIpcError(
|
|
'Connection failed, please check if nemu_folder is correct and emulator is running'
|
|
)
|
|
|
|
self.connect_id = connect_id
|
|
# logger.info(f'NemuIpc connected: {self.connect_id}')
|
|
|
|
def disconnect(self):
|
|
if self.connect_id == 0:
|
|
return
|
|
|
|
self.ev_run_sync(
|
|
self.lib.nemu_disconnect,
|
|
self.connect_id
|
|
)
|
|
|
|
# logger.info(f'NemuIpc disconnected: {self.connect_id}')
|
|
self.connect_id = 0
|
|
|
|
def reconnect(self):
|
|
self.disconnect()
|
|
self.connect()
|
|
|
|
def __enter__(self):
|
|
self.connect()
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
self.disconnect()
|
|
if has_cached_property(self, '_ev'):
|
|
self._ev.close()
|
|
del_cached_property(self, '_ev')
|
|
if has_cached_property(self, '_pool'):
|
|
self._pool.shutdown(wait=False)
|
|
del_cached_property(self, '_pool')
|
|
|
|
@cached_property
|
|
def _ev(self):
|
|
return asyncio.new_event_loop()
|
|
|
|
@cached_property
|
|
def _pool(self):
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
return ThreadPoolExecutor(
|
|
max_workers=1,
|
|
thread_name_prefix='NemuIpc',
|
|
)
|
|
|
|
async def ev_run_async(self, func, *args, timeout=0.15, **kwargs):
|
|
"""
|
|
Args:
|
|
func: Sync function to call
|
|
*args:
|
|
timeout:
|
|
**kwargs:
|
|
|
|
Raises:
|
|
asyncio.TimeoutError: If function call timeout
|
|
"""
|
|
func_wrapped = partial(func, *args, **kwargs)
|
|
# Increased timeout for slow PCs
|
|
# Default screenshot interval is 0.2s, so a 0.15s timeout would have a fast retry without extra time costs
|
|
result = await asyncio.wait_for(self._ev.run_in_executor(self._pool, func_wrapped), timeout=timeout)
|
|
return result
|
|
|
|
def ev_run_sync(self, func, *args, **kwargs):
|
|
"""
|
|
Args:
|
|
func: Sync function to call
|
|
*args:
|
|
**kwargs:
|
|
|
|
Raises:
|
|
asyncio.TimeoutError: If function call timeout
|
|
NemuIpcIncompatible:
|
|
NemuIpcError
|
|
"""
|
|
result = self._ev.run_until_complete(self.ev_run_async(func, *args, **kwargs))
|
|
|
|
err = False
|
|
if func.__name__ == 'nemu_connect':
|
|
if result == 0:
|
|
err = True
|
|
else:
|
|
if result > 0:
|
|
err = True
|
|
# Get to actual error message printed in std
|
|
if err:
|
|
logger.warning(f'Failed to call {func.__name__}, result={result}')
|
|
with CaptureNemuIpc():
|
|
result = self._ev.run_until_complete(self.ev_run_async(func, *args, **kwargs))
|
|
|
|
return result
|
|
|
|
def get_resolution(self):
|
|
"""
|
|
Get emulator resolution, `self.width` and `self.height` will be set
|
|
"""
|
|
if self.connect_id == 0:
|
|
self.connect()
|
|
|
|
width_ptr = ctypes.pointer(ctypes.c_int(0))
|
|
height_ptr = ctypes.pointer(ctypes.c_int(0))
|
|
nullptr = ctypes.POINTER(ctypes.c_int)()
|
|
|
|
ret = self.ev_run_sync(
|
|
self.lib.nemu_capture_display,
|
|
self.connect_id, self.display_id, 0, width_ptr, height_ptr, nullptr
|
|
)
|
|
if ret > 0:
|
|
raise NemuIpcError('nemu_capture_display failed during get_resolution()')
|
|
self.width = width_ptr.contents.value
|
|
self.height = height_ptr.contents.value
|
|
|
|
@retry
|
|
def screenshot(self, timeout=0.15):
|
|
"""
|
|
Returns:
|
|
np.ndarray: Image array in RGBA color space
|
|
Note that image is upside down
|
|
"""
|
|
if self.connect_id == 0:
|
|
self.connect()
|
|
|
|
self.get_resolution()
|
|
|
|
width_ptr = ctypes.pointer(ctypes.c_int(self.width))
|
|
height_ptr = ctypes.pointer(ctypes.c_int(self.height))
|
|
length = self.width * self.height * 4
|
|
pixels_pointer = ctypes.pointer((ctypes.c_ubyte * length)())
|
|
|
|
ret = self.ev_run_sync(
|
|
self.lib.nemu_capture_display,
|
|
self.connect_id, self.display_id, length, width_ptr, height_ptr, pixels_pointer,
|
|
timeout=timeout,
|
|
)
|
|
if ret > 0:
|
|
raise NemuIpcError('nemu_capture_display failed during screenshot()')
|
|
|
|
# image = np.ctypeslib.as_array(pixels_pointer, shape=(self.height, self.width, 4))
|
|
image = np.ctypeslib.as_array(pixels_pointer.contents).reshape((self.height, self.width, 4))
|
|
return image
|
|
|
|
def convert_xy(self, x, y):
|
|
"""
|
|
Convert classic ADB coordinates to Nemu's
|
|
`self.height` must be updated before calling this method
|
|
|
|
Returns:
|
|
int, int
|
|
"""
|
|
x, y = int(x), int(y)
|
|
x, y = self.height - y, x
|
|
return x, y
|
|
|
|
@retry
|
|
def down(self, x, y):
|
|
"""
|
|
Contact down, continuous contact down will be considered as swipe
|
|
"""
|
|
if self.connect_id == 0:
|
|
self.connect()
|
|
if self.height == 0:
|
|
self.get_resolution()
|
|
|
|
x, y = self.convert_xy(x, y)
|
|
|
|
ret = self.ev_run_sync(
|
|
self.lib.nemu_input_event_touch_down,
|
|
self.connect_id, self.display_id, x, y
|
|
)
|
|
if ret > 0:
|
|
raise NemuIpcError('nemu_input_event_touch_down failed')
|
|
|
|
@retry
|
|
def up(self):
|
|
"""
|
|
Contact up
|
|
"""
|
|
if self.connect_id == 0:
|
|
self.connect()
|
|
|
|
ret = self.ev_run_sync(
|
|
self.lib.nemu_input_event_touch_up,
|
|
self.connect_id, self.display_id
|
|
)
|
|
if ret > 0:
|
|
raise NemuIpcError('nemu_input_event_touch_up failed')
|
|
|
|
@staticmethod
|
|
def serial_to_id(serial: str):
|
|
"""
|
|
Predict instance ID from serial
|
|
E.g.
|
|
"127.0.0.1:16384" -> 0
|
|
"127.0.0.1:16416" -> 1
|
|
Port from 16414 to 16418 -> 1
|
|
|
|
Returns:
|
|
int: instance_id, or None if failed to predict
|
|
"""
|
|
try:
|
|
port = int(serial.split(':')[1])
|
|
except (IndexError, ValueError):
|
|
return None
|
|
index, offset = divmod(port - 16384 + 16, 32)
|
|
offset -= 16
|
|
if 0 <= index < 32 and offset in [-2, -1, 0, 1, 2]:
|
|
return index
|
|
else:
|
|
return None
|
|
|
|
|
|
class NemuIpc(Platform):
|
|
_screenshot_interval = Timer(0.1)
|
|
|
|
@cached_property
|
|
def nemu_ipc(self) -> NemuIpcImpl:
|
|
"""
|
|
Initialize a nemu ipc implementation
|
|
"""
|
|
# Try existing settings first
|
|
if self.config.EmulatorInfo_path:
|
|
if 'MuMuPlayerGlobal' in self.config.EmulatorInfo_path:
|
|
logger.info(f'nemu_ipc is not available on MuMuPlayerGlobal, {self.config.EmulatorInfo_path}')
|
|
raise RequestHumanTakeover
|
|
folder = os.path.abspath(os.path.join(self.config.EmulatorInfo_path, '../../'))
|
|
index = NemuIpcImpl.serial_to_id(self.serial)
|
|
if index is not None:
|
|
try:
|
|
return NemuIpcImpl(
|
|
nemu_folder=folder,
|
|
instance_id=index,
|
|
display_id=0
|
|
).__enter__()
|
|
except (NemuIpcIncompatible, NemuIpcError) as e:
|
|
logger.error(e)
|
|
logger.error('Emulator info incorrect')
|
|
|
|
# Search emulator instance
|
|
# with E:\ProgramFiles\MuMuPlayer-12.0\shell\MuMuPlayer.exe
|
|
# installation path is E:\ProgramFiles\MuMuPlayer-12.0
|
|
if self.emulator_instance is None:
|
|
logger.error('Unable to use NemuIpc because emulator instance not found')
|
|
raise RequestHumanTakeover
|
|
if 'MuMuPlayerGlobal' in self.emulator_instance.path:
|
|
logger.info(f'nemu_ipc is not available on MuMuPlayerGlobal, {self.emulator_instance.path}')
|
|
raise RequestHumanTakeover
|
|
try:
|
|
return NemuIpcImpl(
|
|
nemu_folder=self.emulator_instance.emulator.abspath('../'),
|
|
instance_id=self.emulator_instance.MuMuPlayer12_id,
|
|
display_id=0
|
|
).__enter__()
|
|
except (NemuIpcIncompatible, NemuIpcError) as e:
|
|
logger.error(e)
|
|
logger.error('Unable to initialize NemuIpc')
|
|
raise RequestHumanTakeover
|
|
|
|
def nemu_ipc_available(self) -> bool:
|
|
if not self.is_mumu_family:
|
|
return False
|
|
# >= 4.0 has no info in getprop
|
|
if self.nemud_player_version == '':
|
|
return True
|
|
if self.nemud_app_keep_alive == '':
|
|
return False
|
|
try:
|
|
_ = self.nemu_ipc
|
|
except RequestHumanTakeover:
|
|
return False
|
|
return True
|
|
|
|
@staticmethod
|
|
def check_mumu_app_keep_alive_400(file):
|
|
"""
|
|
Check app_keep_alive from emulator config if version >= 4.0
|
|
|
|
Args:
|
|
file: E:/ProgramFiles/MuMuPlayer-12.0/vms/MuMuPlayer-12.0-1/config/customer_config.json
|
|
|
|
Returns:
|
|
bool: If success to read file
|
|
"""
|
|
# with E:\ProgramFiles\MuMuPlayer-12.0\shell\MuMuPlayer.exe
|
|
# config is E:\ProgramFiles\MuMuPlayer-12.0\vms\MuMuPlayer-12.0-1\config\customer_config.json
|
|
try:
|
|
with open(file, mode='r', encoding='utf-8') as f:
|
|
s = f.read()
|
|
data = json.loads(s)
|
|
except FileNotFoundError:
|
|
logger.warning(f'Failed to check check_mumu_app_keep_alive, file {file} not exists')
|
|
return False
|
|
value = deep_get(data, keys='customer.app_keptlive', default=None)
|
|
logger.attr('customer.app_keptlive', value)
|
|
if str(value).lower() == 'true':
|
|
# https://mumu.163.com/help/20230802/35047_1102450.html
|
|
logger.critical('请在MuMu模拟器设置内关闭 "后台挂机时保活运行"')
|
|
raise RequestHumanTakeover
|
|
return True
|
|
|
|
def check_mumu_app_keep_alive(self):
|
|
if not self.is_mumu_over_version_400:
|
|
return super().check_mumu_app_keep_alive()
|
|
|
|
# Try existing settings first
|
|
if self.config.EmulatorInfo_path:
|
|
index = NemuIpcImpl.serial_to_id(self.serial)
|
|
if index is not None:
|
|
file = os.path.abspath(os.path.join(
|
|
self.config.EmulatorInfo_path, f'../../vms/MuMuPlayer-12.0-{index}/configs/customer_config.json'))
|
|
if self.check_mumu_app_keep_alive_400(file):
|
|
return True
|
|
|
|
# Search emulator instance
|
|
if self.emulator_instance is None:
|
|
logger.warning('Failed to check check_mumu_app_keep_alive as emulator_instance is None')
|
|
return False
|
|
name = self.emulator_instance.name
|
|
file = self.emulator_instance.emulator.abspath(f'../vms/{name}/configs/customer_config.json')
|
|
if self.check_mumu_app_keep_alive_400(file):
|
|
return True
|
|
|
|
return False
|
|
|
|
def nemu_ipc_release(self):
|
|
if has_cached_property(self, 'nemu_ipc'):
|
|
self.nemu_ipc.disconnect()
|
|
del_cached_property(self, 'nemu_ipc')
|
|
logger.info('nemu_ipc released')
|
|
|
|
def screenshot_nemu_ipc(self):
|
|
timeout = max(self._screenshot_interval.limit - 0.01, 0.15)
|
|
image = self.nemu_ipc.screenshot(timeout=timeout)
|
|
|
|
image = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
|
|
cv2.flip(image, 0, dst=image)
|
|
return image
|
|
|
|
def click_nemu_ipc(self, x, y):
|
|
down = ensure_time((0.010, 0.020))
|
|
self.nemu_ipc.down(x, y)
|
|
self.sleep(down)
|
|
self.nemu_ipc.up()
|
|
self.sleep(0.050 - down)
|
|
|
|
def long_click_nemu_ipc(self, x, y, duration=1.0):
|
|
self.nemu_ipc.down(x, y)
|
|
self.sleep(duration)
|
|
self.nemu_ipc.up()
|
|
self.sleep(0.050)
|
|
|
|
def swipe_nemu_ipc(self, p1, p2):
|
|
points = insert_swipe(p0=p1, p3=p2)
|
|
|
|
for point in points:
|
|
self.nemu_ipc.down(*point)
|
|
self.sleep(0.010)
|
|
|
|
self.nemu_ipc.up()
|
|
self.sleep(0.050)
|
|
|
|
def drag_nemu_ipc(self, p1, p2, point_random=(-10, -10, 10, 10)):
|
|
p1 = np.array(p1) - random_rectangle_point(point_random)
|
|
p2 = np.array(p2) - random_rectangle_point(point_random)
|
|
points = insert_swipe(p0=p1, p3=p2, speed=20)
|
|
|
|
for point in points:
|
|
self.nemu_ipc.down(*point)
|
|
self.sleep(0.010)
|
|
|
|
self.nemu_ipc.down(*p2)
|
|
self.sleep(0.140)
|
|
self.nemu_ipc.down(*p2)
|
|
self.sleep(0.140)
|
|
|
|
self.nemu_ipc.up()
|
|
self.sleep(0.050)
|