mirror of
https://github.com/Leonmmcoset/cleonos.git
synced 2026-04-21 18:44:01 +00:00
Wine适配1
This commit is contained in:
@@ -697,8 +697,16 @@ u64 cleonos_syscall(u64 id, u64 arg0, u64 arg1, u64 arg2);
|
||||
|
||||
## 7. Wine 兼容说明
|
||||
|
||||
- `wine/cleonos_wine_lib/runner.py` 当前已覆盖到 `0..80`(含 `stats/fd/exec_pathv_io`)。
|
||||
- `DL_*`(`77..79`)在 Wine 中当前为占位实现:`DL_OPEN=-1`、`DL_CLOSE=0`、`DL_SYM=0`。
|
||||
- framebuffer 相关 syscall(`81..83`)尚未在 Wine 中实现(会返回未支持路径)。
|
||||
- `wine/cleonos_wine_lib/runner.py` 当前已覆盖到 `0..83`(含 `DL_*`、`FB_*`)。
|
||||
- `DL_*`(`77..79`)在 Wine 中为“可运行兼容”实现:
|
||||
- `DL_OPEN`:加载 guest ELF 到当前 Unicorn 地址空间,返回稳定 `handle`,并做引用计数。
|
||||
- `DL_SYM`:解析 ELF `SYMTAB/DYNSYM` 并返回 guest 可调用地址。
|
||||
- `DL_CLOSE`:引用计数归零后释放句柄。
|
||||
- `DL_*` 兼容限制:未实现完整动态链接器语义(例如完整重定位/依赖库链),但对 CLeonOS 现有用户态库调用场景可工作。
|
||||
- framebuffer syscall(`81..83`)在 Wine 中已实现兼容:
|
||||
- `FB_INFO` 返回 framebuffer 参数(默认 `1280x800x32`,可用环境变量 `CLEONOS_WINE_FB_WIDTH/HEIGHT` 调整)。
|
||||
- `FB_BLIT` 实现内核同类参数校验并支持 `scale>=1` 绘制。
|
||||
- 配合 Wine 参数 `--fb-window` 可将 framebuffer 实时显示到主机窗口(pygame 后端);未启用时保持内存缓冲模式。
|
||||
- `FB_CLEAR` 支持清屏颜色写入。
|
||||
- Wine 在运行时崩溃场景下会生成与内核一致格式的“信号编码退出状态”,可通过 `WAITPID` 读取。
|
||||
- Wine 当前音频 syscall 为占位实现:`AUDIO_AVAILABLE=0`,`AUDIO_PLAY_TONE=0`,`AUDIO_STOP=1`。
|
||||
|
||||
@@ -13,7 +13,7 @@ CLeonOS-Wine 现在改为自研运行器:基于 Python + Unicorn,直接运
|
||||
- `wine/cleonos_wine_lib/input_pump.py`:主机键盘输入线程
|
||||
- `wine/cleonos_wine_lib/constants.py`:常量与 syscall ID
|
||||
- `wine/cleonos_wine_lib/platform.py`:Unicorn 导入与平台适配
|
||||
- `wine/requirements.txt`:Python 依赖(Unicorn)
|
||||
- `wine/requirements.txt`:Python 依赖(Unicorn + pygame)
|
||||
|
||||
## 安装
|
||||
|
||||
@@ -26,6 +26,7 @@ pip install -r wine/requirements.txt
|
||||
```bash
|
||||
python wine/cleonos_wine.py /hello.elf --rootfs build/x86_64/ramdisk_root
|
||||
python wine/cleonos_wine.py /shell/shell.elf --rootfs build/x86_64/ramdisk_root
|
||||
python wine/cleonos_wine.py /shell/qrcode.elf --rootfs build/x86_64/ramdisk_root --fb-window -- --ecc H "hello wine"
|
||||
```
|
||||
|
||||
也支持直接传宿主路径:
|
||||
@@ -37,7 +38,7 @@ python wine/cleonos_wine.py build/x86_64/ramdisk_root/shell/shell.elf --rootfs b
|
||||
## 支持
|
||||
|
||||
- ELF64 (x86_64) PT_LOAD 段装载
|
||||
- CLeonOS `int 0x80` syscall 0..80(含 `FD_*`、`PROC_*`、`STATS_*`、`EXEC_PATHV_IO`)
|
||||
- CLeonOS `int 0x80` syscall 0..83(含 `FD_*`、`DL_*`、`FB_*`、`PROC_*`、`STATS_*`、`EXEC_PATHV_IO`)
|
||||
- TTY 输出与键盘输入队列
|
||||
- rootfs 文件/目录访问(`FS_*`)
|
||||
- `/temp` 写入限制(`FS_MKDIR/WRITE/APPEND/REMOVE`)
|
||||
@@ -48,11 +49,20 @@ python wine/cleonos_wine.py build/x86_64/ramdisk_root/shell/shell.elf --rootfs b
|
||||
- 进程枚举与快照(`PROC_COUNT/PROC_PID_AT/PROC_SNAPSHOT/PROC_KILL`)
|
||||
- syscall 统计(`STATS_TOTAL/STATS_ID_COUNT/STATS_RECENT_*`)
|
||||
- 文件描述符(`FD_OPEN/FD_READ/FD_WRITE/FD_CLOSE/FD_DUP`)
|
||||
- 动态库兼容加载(`DL_OPEN/DL_CLOSE/DL_SYM`,基于 ELF 符号解析)
|
||||
- framebuffer 兼容(`FB_INFO/FB_BLIT/FB_CLEAR`,支持内存缓冲与窗口显示)
|
||||
- 异常退出状态编码与故障元信息(`PROC_LAST_SIGNAL/PROC_FAULT_*`)
|
||||
|
||||
## 参数
|
||||
|
||||
- `--no-kbd`:关闭输入线程
|
||||
- `--fb-window`:启用 framebuffer 窗口显示(pygame)
|
||||
- `--fb-scale N`:窗口缩放倍数(默认 `2`)
|
||||
- `--fb-max-fps N`:窗口刷新上限(默认 `60`)
|
||||
- `--fb-hold-ms N`:程序退出后窗口保留毫秒数(默认 `2500`,静态图更容易看清)
|
||||
- `--argv-line "..."`:直接指定 guest 参数行(等价于 shell 参数字符串)
|
||||
- `--cwd PATH`:写入命令上下文中的工作目录(默认 `/`)
|
||||
- `--` 之后内容:作为 guest argv 透传(推荐)
|
||||
- `--max-exec-depth N`:设置 exec 嵌套深度上限
|
||||
- `--verbose`:打印更多日志
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
wine/cleonos_wine_lib/__pycache__/fb_window.cpython-313.pyc
Normal file
BIN
wine/cleonos_wine_lib/__pycache__/fb_window.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
@@ -1,18 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from .constants import DEFAULT_MAX_EXEC_DEPTH
|
||||
from .runner import CLeonOSWineNative, resolve_elf_target, resolve_rootfs
|
||||
from .state import SharedKernelState
|
||||
|
||||
|
||||
def _encode_cstr(text: str, size: int) -> bytes:
|
||||
if size <= 0:
|
||||
return b""
|
||||
|
||||
data = text.encode("utf-8", errors="replace")
|
||||
if len(data) >= size:
|
||||
data = data[: size - 1]
|
||||
return data + (b"\x00" * (size - len(data)))
|
||||
|
||||
|
||||
def _normalize_guest_cwd(cwd: str) -> str:
|
||||
value = (cwd or "").strip().replace("\\", "/")
|
||||
if not value:
|
||||
return "/"
|
||||
if not value.startswith("/"):
|
||||
value = "/" + value
|
||||
return value
|
||||
|
||||
|
||||
def _write_command_context(rootfs: Path, guest_path: str, guest_args: List[str], cwd: str) -> None:
|
||||
name = Path(guest_path).name
|
||||
cmd = name[:-4] if name.lower().endswith(".elf") else name
|
||||
arg = " ".join(guest_args)
|
||||
ctx_payload = b"".join(
|
||||
(
|
||||
_encode_cstr(cmd, 32),
|
||||
_encode_cstr(arg, 160),
|
||||
_encode_cstr(_normalize_guest_cwd(cwd), 192),
|
||||
)
|
||||
)
|
||||
ctx_path = rootfs / "temp" / ".ush_cmd_ctx.bin"
|
||||
ctx_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
ctx_path.write_bytes(ctx_payload)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="CLeonOS-Wine: run CLeonOS ELF with Unicorn.")
|
||||
parser.add_argument("elf", help="Target ELF path. Supports /guest/path or host file path.")
|
||||
parser.add_argument("--rootfs", help="Rootfs directory (default: build/x86_64/ramdisk_root).")
|
||||
parser.add_argument("--no-kbd", action="store_true", help="Disable host keyboard input pump.")
|
||||
parser.add_argument("--fb-window", action="store_true", help="Enable host framebuffer window (pygame backend).")
|
||||
parser.add_argument("--fb-scale", type=int, default=2, help="Framebuffer window scale factor (default: 2).")
|
||||
parser.add_argument("--fb-max-fps", type=int, default=60, help="Framebuffer present FPS limit (default: 60).")
|
||||
parser.add_argument("--fb-hold-ms", type=int, default=2500, help="Keep fb window visible after app exits (ms).")
|
||||
parser.add_argument("--argv-line", default="", help="Guest argv as one line (whitespace-separated).")
|
||||
parser.add_argument("--cwd", default="/", help="Guest cwd for command-context apps.")
|
||||
parser.add_argument("guest_args", nargs="*", help="Guest args (for dash-prefixed args use '--').")
|
||||
parser.add_argument("--max-exec-depth", type=int, default=DEFAULT_MAX_EXEC_DEPTH, help="Nested exec depth guard.")
|
||||
parser.add_argument("--verbose", action="store_true", help="Enable verbose runner output.")
|
||||
return parser.parse_args()
|
||||
@@ -20,6 +65,8 @@ def parse_args() -> argparse.Namespace:
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
guest_args = list(args.guest_args or [])
|
||||
argv_items: List[str]
|
||||
|
||||
try:
|
||||
rootfs = resolve_rootfs(args.rootfs)
|
||||
@@ -28,6 +75,24 @@ def main() -> int:
|
||||
print(f"[WINE][ERROR] {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if len(guest_args) > 0 and guest_args[0] == "--":
|
||||
guest_args = guest_args[1:]
|
||||
|
||||
if (args.argv_line or "").strip():
|
||||
try:
|
||||
guest_args = shlex.split(args.argv_line)
|
||||
except Exception:
|
||||
guest_args = (args.argv_line or "").split()
|
||||
|
||||
argv_items = [guest_path]
|
||||
argv_items.extend(guest_args)
|
||||
|
||||
if guest_args:
|
||||
try:
|
||||
_write_command_context(rootfs, guest_path, guest_args, args.cwd)
|
||||
except Exception as exc:
|
||||
print(f"[WINE][WARN] failed to write command context: {exc}", file=sys.stderr)
|
||||
|
||||
if args.verbose:
|
||||
print("[WINE] backend=unicorn", file=sys.stderr)
|
||||
print(f"[WINE] rootfs={rootfs}", file=sys.stderr)
|
||||
@@ -44,6 +109,11 @@ def main() -> int:
|
||||
no_kbd=args.no_kbd,
|
||||
verbose=args.verbose,
|
||||
top_level=True,
|
||||
fb_window=args.fb_window,
|
||||
fb_scale=max(1, args.fb_scale),
|
||||
fb_max_fps=max(1, args.fb_max_fps),
|
||||
fb_hold_ms=max(0, args.fb_hold_ms),
|
||||
argv_items=argv_items,
|
||||
)
|
||||
ret = runner.run()
|
||||
if ret is None:
|
||||
|
||||
@@ -89,6 +89,9 @@ SYS_DL_OPEN = 77
|
||||
SYS_DL_CLOSE = 78
|
||||
SYS_DL_SYM = 79
|
||||
SYS_EXEC_PATHV_IO = 80
|
||||
SYS_FB_INFO = 81
|
||||
SYS_FB_BLIT = 82
|
||||
SYS_FB_CLEAR = 83
|
||||
|
||||
# proc states (from cleonos/c/include/cleonos_syscall.h)
|
||||
PROC_STATE_UNUSED = 0
|
||||
|
||||
143
wine/cleonos_wine_lib/fb_window.py
Normal file
143
wine/cleonos_wine_lib/fb_window.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from .state import SharedKernelState
|
||||
|
||||
|
||||
CLEONOS_KEY_LEFT = 0x01
|
||||
CLEONOS_KEY_RIGHT = 0x02
|
||||
CLEONOS_KEY_UP = 0x03
|
||||
CLEONOS_KEY_DOWN = 0x04
|
||||
|
||||
|
||||
class FBWindow:
|
||||
def __init__(self, pygame_mod, width: int, height: int, scale: int, max_fps: int, *, verbose: bool = False) -> None:
|
||||
self._pygame = pygame_mod
|
||||
self.width = int(width)
|
||||
self.height = int(height)
|
||||
self.scale = max(1, int(scale))
|
||||
self._verbose = bool(verbose)
|
||||
self._closed = False
|
||||
self._last_present_ns = 0
|
||||
self._frame_interval_ns = int(1_000_000_000 / max_fps) if int(max_fps) > 0 else 0
|
||||
|
||||
window_w = self.width * self.scale
|
||||
window_h = self.height * self.scale
|
||||
|
||||
self._pygame.display.set_caption("CLeonOS Wine Framebuffer")
|
||||
self._screen = self._pygame.display.set_mode((window_w, window_h))
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
width: int,
|
||||
height: int,
|
||||
scale: int,
|
||||
max_fps: int,
|
||||
*,
|
||||
verbose: bool = False,
|
||||
) -> Optional["FBWindow"]:
|
||||
try:
|
||||
import pygame # type: ignore
|
||||
except Exception as exc:
|
||||
print(f"[WINE][WARN] framebuffer window disabled: pygame import failed ({exc})", file=sys.stderr)
|
||||
return None
|
||||
|
||||
try:
|
||||
pygame.init()
|
||||
pygame.display.init()
|
||||
return cls(pygame, width, height, scale, max_fps, verbose=verbose)
|
||||
except Exception as exc:
|
||||
print(f"[WINE][WARN] framebuffer window disabled: unable to init display ({exc})", file=sys.stderr)
|
||||
try:
|
||||
pygame.quit()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def close(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
|
||||
self._closed = True
|
||||
try:
|
||||
self._pygame.display.quit()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._pygame.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def is_closed(self) -> bool:
|
||||
return self._closed
|
||||
|
||||
def pump_input(self, state: SharedKernelState) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
|
||||
try:
|
||||
events = self._pygame.event.get()
|
||||
except Exception:
|
||||
self.close()
|
||||
return
|
||||
|
||||
for event in events:
|
||||
if event.type == self._pygame.QUIT:
|
||||
self.close()
|
||||
continue
|
||||
|
||||
if event.type != self._pygame.KEYDOWN:
|
||||
continue
|
||||
|
||||
key = event.key
|
||||
ch = 0
|
||||
|
||||
if key == self._pygame.K_LEFT:
|
||||
ch = CLEONOS_KEY_LEFT
|
||||
elif key == self._pygame.K_RIGHT:
|
||||
ch = CLEONOS_KEY_RIGHT
|
||||
elif key == self._pygame.K_UP:
|
||||
ch = CLEONOS_KEY_UP
|
||||
elif key == self._pygame.K_DOWN:
|
||||
ch = CLEONOS_KEY_DOWN
|
||||
elif key in (self._pygame.K_RETURN, self._pygame.K_KP_ENTER):
|
||||
ch = ord("\n")
|
||||
elif key == self._pygame.K_BACKSPACE:
|
||||
ch = 8
|
||||
elif key == self._pygame.K_ESCAPE:
|
||||
ch = 27
|
||||
elif key == self._pygame.K_TAB:
|
||||
ch = ord("\t")
|
||||
else:
|
||||
text = getattr(event, "unicode", "")
|
||||
if isinstance(text, str) and len(text) == 1:
|
||||
code = ord(text)
|
||||
if 32 <= code <= 126:
|
||||
ch = code
|
||||
|
||||
if ch != 0:
|
||||
state.push_key(ch)
|
||||
|
||||
def present(self, pixels_bgra: bytearray, *, force: bool = False) -> bool:
|
||||
if self._closed:
|
||||
return False
|
||||
|
||||
now_ns = time.monotonic_ns()
|
||||
if not force and self._frame_interval_ns > 0 and (now_ns - self._last_present_ns) < self._frame_interval_ns:
|
||||
return False
|
||||
|
||||
try:
|
||||
src = self._pygame.image.frombuffer(pixels_bgra, (self.width, self.height), "BGRA")
|
||||
if self.scale != 1:
|
||||
src = self._pygame.transform.scale(src, (self.width * self.scale, self.height * self.scale))
|
||||
self._screen.blit(src, (0, 0))
|
||||
self._pygame.display.flip()
|
||||
self._last_present_ns = now_ns
|
||||
return True
|
||||
except Exception:
|
||||
self.close()
|
||||
return False
|
||||
@@ -4,7 +4,7 @@ import os
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
@@ -48,6 +48,9 @@ from .constants import (
|
||||
SYS_FD_OPEN,
|
||||
SYS_FD_READ,
|
||||
SYS_FD_WRITE,
|
||||
SYS_FB_BLIT,
|
||||
SYS_FB_CLEAR,
|
||||
SYS_FB_INFO,
|
||||
SYS_FS_APPEND,
|
||||
SYS_FS_CHILD_COUNT,
|
||||
SYS_FS_GET_CHILD_NAME,
|
||||
@@ -112,6 +115,7 @@ from .constants import (
|
||||
u64,
|
||||
u64_neg1,
|
||||
)
|
||||
from .fb_window import FBWindow
|
||||
from .input_pump import InputPump
|
||||
from .platform import (
|
||||
Uc,
|
||||
@@ -165,6 +169,19 @@ class FDEntry:
|
||||
tty_index: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class DLImage:
|
||||
handle: int
|
||||
guest_path: str
|
||||
host_path: str
|
||||
owner_pid: int
|
||||
ref_count: int
|
||||
map_start: int
|
||||
map_end: int
|
||||
load_bias: int
|
||||
symbols: Dict[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
EXEC_PATH_MAX = 192
|
||||
EXEC_ARG_LINE_MAX = 256
|
||||
EXEC_ENV_LINE_MAX = 512
|
||||
@@ -174,6 +191,14 @@ EXEC_ITEM_MAX = 128
|
||||
EXEC_STATUS_SIGNAL_FLAG = 1 << 63
|
||||
PROC_PATH_MAX = 192
|
||||
FD_MAX = 64
|
||||
DL_MAX_NAME = 192
|
||||
DL_MAX_SYMBOL = 128
|
||||
DL_BASE_START = 0x0000000100000000
|
||||
DL_BASE_GAP = 0x0000000000100000
|
||||
FB_DEFAULT_WIDTH = 1280
|
||||
FB_DEFAULT_HEIGHT = 800
|
||||
FB_MAX_DIM = 4096
|
||||
FB_MAX_UPLOAD_BYTES = 64 * 1024 * 1024
|
||||
|
||||
|
||||
class CLeonOSWineNative:
|
||||
@@ -194,6 +219,10 @@ class CLeonOSWineNative:
|
||||
argv_items: Optional[List[str]] = None,
|
||||
env_items: Optional[List[str]] = None,
|
||||
inherited_fds: Optional[Dict[int, FDEntry]] = None,
|
||||
fb_window: bool = False,
|
||||
fb_scale: int = 2,
|
||||
fb_max_fps: int = 60,
|
||||
fb_hold_ms: int = 2500,
|
||||
) -> None:
|
||||
self.elf_path = elf_path
|
||||
self.rootfs = rootfs
|
||||
@@ -206,6 +235,10 @@ class CLeonOSWineNative:
|
||||
self.top_level = top_level
|
||||
self.pid = int(pid)
|
||||
self.ppid = int(ppid)
|
||||
self.fb_window = bool(fb_window)
|
||||
self.fb_scale = max(1, int(fb_scale))
|
||||
self.fb_max_fps = max(1, int(fb_max_fps))
|
||||
self.fb_hold_ms = max(0, int(fb_hold_ms))
|
||||
self.argv_items = list(argv_items) if argv_items is not None else []
|
||||
self.env_items = list(env_items) if env_items is not None else []
|
||||
self._exit_requested = False
|
||||
@@ -222,6 +255,20 @@ class CLeonOSWineNative:
|
||||
self._tty_index = int(self.state.tty_active)
|
||||
self._fds: Dict[int, FDEntry] = {}
|
||||
self._fd_inherited = inherited_fds if inherited_fds is not None else {}
|
||||
self._dl_images: Dict[int, DLImage] = {}
|
||||
self._dl_path_to_handle: Dict[str, int] = {}
|
||||
self._dl_next_handle = 1
|
||||
self._dl_next_base = DL_BASE_START
|
||||
|
||||
self._fb_width = self._bounded_env_int("CLEONOS_WINE_FB_WIDTH", FB_DEFAULT_WIDTH, 64, FB_MAX_DIM)
|
||||
self._fb_height = self._bounded_env_int("CLEONOS_WINE_FB_HEIGHT", FB_DEFAULT_HEIGHT, 64, FB_MAX_DIM)
|
||||
self._fb_bpp = 32
|
||||
self._fb_pitch = self._fb_width * 4
|
||||
self._fb_pixels = bytearray(self._fb_pitch * self._fb_height)
|
||||
self._fb_window: Optional[FBWindow] = None
|
||||
self._fb_window_failed = False
|
||||
self._fb_dirty = False
|
||||
self._fb_presented_once = False
|
||||
|
||||
default_path = self._normalize_guest_path(self.guest_path_hint or f"/{self.elf_path.name}")
|
||||
self.argv_items, self.env_items = self._prepare_exec_items(default_path, self.argv_items, self.env_items)
|
||||
@@ -250,6 +297,89 @@ class CLeonOSWineNative:
|
||||
mode = cls._fd_access_mode(flags)
|
||||
return mode in (O_WRONLY, O_RDWR)
|
||||
|
||||
@staticmethod
|
||||
def _bounded_env_int(name: str, default: int, min_value: int, max_value: int) -> int:
|
||||
raw = os.environ.get(name)
|
||||
value = default
|
||||
|
||||
if raw is not None:
|
||||
try:
|
||||
value = int(raw.strip(), 10)
|
||||
except Exception:
|
||||
value = default
|
||||
|
||||
if value < min_value:
|
||||
value = min_value
|
||||
if value > max_value:
|
||||
value = max_value
|
||||
return value
|
||||
|
||||
def _ensure_fb_window(self) -> None:
|
||||
if not self.fb_window:
|
||||
return
|
||||
|
||||
if self._fb_window is not None or self._fb_window_failed:
|
||||
return
|
||||
|
||||
self._fb_window = FBWindow.create(
|
||||
self._fb_width,
|
||||
self._fb_height,
|
||||
self.fb_scale,
|
||||
self.fb_max_fps,
|
||||
verbose=self.verbose,
|
||||
)
|
||||
|
||||
if self._fb_window is None:
|
||||
self._fb_window_failed = True
|
||||
|
||||
def _fb_poll_window(self) -> None:
|
||||
self._ensure_fb_window()
|
||||
if self._fb_window is not None:
|
||||
self._fb_window.pump_input(self.state)
|
||||
if self._fb_window.is_closed():
|
||||
self._fb_window = None
|
||||
|
||||
def _fb_present(self, *, force: bool = False) -> None:
|
||||
self._ensure_fb_window()
|
||||
if self._fb_window is None:
|
||||
return
|
||||
|
||||
self._fb_window.pump_input(self.state)
|
||||
did_present = self._fb_window.present(self._fb_pixels, force=force)
|
||||
if did_present:
|
||||
self._fb_dirty = False
|
||||
self._fb_presented_once = True
|
||||
|
||||
if self._fb_window.is_closed():
|
||||
self._fb_window = None
|
||||
|
||||
def _fb_mark_dirty(self) -> None:
|
||||
self._fb_dirty = True
|
||||
self._fb_present(force=False)
|
||||
|
||||
def _fb_hold_after_exit(self) -> None:
|
||||
end_ns: int
|
||||
|
||||
if self._fb_window is None:
|
||||
return
|
||||
|
||||
if self.fb_hold_ms <= 0 or self._fb_presented_once is False:
|
||||
return
|
||||
|
||||
end_ns = time.monotonic_ns() + (self.fb_hold_ms * 1_000_000)
|
||||
|
||||
while time.monotonic_ns() < end_ns:
|
||||
if self._fb_window is None:
|
||||
return
|
||||
|
||||
self._fb_window.pump_input(self.state)
|
||||
if self._fb_window.is_closed():
|
||||
self._fb_window = None
|
||||
return
|
||||
|
||||
self._fb_window.present(self._fb_pixels, force=True)
|
||||
time.sleep(0.016)
|
||||
|
||||
def _init_default_fds(self) -> None:
|
||||
self._fds = {
|
||||
0: FDEntry(kind="tty", flags=O_RDONLY, offset=0, tty_index=self._tty_index),
|
||||
@@ -325,6 +455,8 @@ class CLeonOSWineNative:
|
||||
self._install_hooks(uc)
|
||||
self._load_segments(uc)
|
||||
self._prepare_stack_and_return(uc)
|
||||
self._ensure_fb_window()
|
||||
self._fb_present(force=True)
|
||||
|
||||
if self.top_level and not self.no_kbd:
|
||||
self._input_pump = InputPump(self.state)
|
||||
@@ -346,6 +478,11 @@ class CLeonOSWineNative:
|
||||
finally:
|
||||
if self.top_level and self._input_pump is not None:
|
||||
self._input_pump.stop()
|
||||
if self._fb_window is not None:
|
||||
self._fb_hold_after_exit()
|
||||
if self._fb_window is not None:
|
||||
self._fb_window.close()
|
||||
self._fb_window = None
|
||||
|
||||
if interrupted:
|
||||
self.state.mark_exited(self.pid, u64_neg1())
|
||||
@@ -391,6 +528,8 @@ class CLeonOSWineNative:
|
||||
self._reg_write(uc, UC_X86_REG_RAX, u64(ret))
|
||||
|
||||
def _dispatch_syscall(self, uc: Uc, sid: int, arg0: int, arg1: int, arg2: int) -> int:
|
||||
self._fb_poll_window()
|
||||
|
||||
if sid == SYS_LOG_WRITE:
|
||||
data = self._read_guest_bytes(uc, arg0, arg1)
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
@@ -563,11 +702,17 @@ class CLeonOSWineNative:
|
||||
if sid == SYS_FD_DUP:
|
||||
return self._fd_dup(arg0)
|
||||
if sid == SYS_DL_OPEN:
|
||||
return u64_neg1()
|
||||
return self._dl_open(uc, arg0)
|
||||
if sid == SYS_DL_CLOSE:
|
||||
return 0
|
||||
return self._dl_close(arg0)
|
||||
if sid == SYS_DL_SYM:
|
||||
return 0
|
||||
return self._dl_sym(uc, arg0, arg1)
|
||||
if sid == SYS_FB_INFO:
|
||||
return self._fb_info(uc, arg0)
|
||||
if sid == SYS_FB_BLIT:
|
||||
return self._fb_blit(uc, arg0)
|
||||
if sid == SYS_FB_CLEAR:
|
||||
return self._fb_clear(arg0)
|
||||
|
||||
return u64_neg1()
|
||||
|
||||
@@ -643,6 +788,12 @@ class CLeonOSWineNative:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _range_overlaps_mapped(self, start: int, end: int) -> bool:
|
||||
for ms, me in self._mapped_ranges:
|
||||
if start < me and end > ms:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _reg_read(uc: Uc, reg: int) -> int:
|
||||
return int(uc.reg_read(reg))
|
||||
@@ -715,24 +866,23 @@ class CLeonOSWineNative:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _parse_elf(path: Path) -> ELFImage:
|
||||
data = path.read_bytes()
|
||||
def _parse_elf_image_from_blob(data: bytes, *, require_entry: bool) -> ELFImage:
|
||||
if len(data) < 64:
|
||||
raise RuntimeError(f"ELF too small: {path}")
|
||||
raise RuntimeError("ELF too small")
|
||||
if data[0:4] != b"\x7fELF":
|
||||
raise RuntimeError(f"invalid ELF magic: {path}")
|
||||
raise RuntimeError("invalid ELF magic")
|
||||
if data[4] != 2 or data[5] != 1:
|
||||
raise RuntimeError(f"unsupported ELF class/endianness: {path}")
|
||||
raise RuntimeError("unsupported ELF class/endianness")
|
||||
|
||||
entry = struct.unpack_from("<Q", data, 0x18)[0]
|
||||
phoff = struct.unpack_from("<Q", data, 0x20)[0]
|
||||
phentsize = struct.unpack_from("<H", data, 0x36)[0]
|
||||
phnum = struct.unpack_from("<H", data, 0x38)[0]
|
||||
|
||||
if entry == 0:
|
||||
raise RuntimeError(f"ELF entry is 0: {path}")
|
||||
if require_entry and entry == 0:
|
||||
raise RuntimeError("ELF entry is 0")
|
||||
if phentsize == 0 or phnum == 0:
|
||||
raise RuntimeError(f"ELF has no program headers: {path}")
|
||||
raise RuntimeError("ELF has no program headers")
|
||||
|
||||
segments: List[ELFSegment] = []
|
||||
for i in range(phnum):
|
||||
@@ -760,10 +910,18 @@ class CLeonOSWineNative:
|
||||
segments.append(ELFSegment(vaddr=int(p_vaddr), memsz=int(p_memsz), flags=int(p_flags), data=seg_data))
|
||||
|
||||
if not segments:
|
||||
raise RuntimeError(f"ELF has no PT_LOAD segments: {path}")
|
||||
raise RuntimeError("ELF has no PT_LOAD segments")
|
||||
|
||||
return ELFImage(entry=int(entry), segments=segments)
|
||||
|
||||
@staticmethod
|
||||
def _parse_elf(path: Path) -> ELFImage:
|
||||
data = path.read_bytes()
|
||||
try:
|
||||
return CLeonOSWineNative._parse_elf_image_from_blob(data, require_entry=True)
|
||||
except RuntimeError as exc:
|
||||
raise RuntimeError(f"{exc}: {path}") from exc
|
||||
|
||||
def _fs_node_count(self) -> int:
|
||||
count = 1
|
||||
for _root, dirs, files in os.walk(self.rootfs):
|
||||
@@ -1087,6 +1245,10 @@ class CLeonOSWineNative:
|
||||
argv_items=argv_items,
|
||||
env_items=env_items,
|
||||
inherited_fds=child_stdio,
|
||||
fb_window=self.fb_window,
|
||||
fb_scale=self.fb_scale,
|
||||
fb_max_fps=self.fb_max_fps,
|
||||
fb_hold_ms=self.fb_hold_ms,
|
||||
)
|
||||
child_ret = child.run()
|
||||
|
||||
@@ -1445,6 +1607,403 @@ class CLeonOSWineNative:
|
||||
self._fds[slot] = self._clone_fd_entry(src)
|
||||
return slot
|
||||
|
||||
def _dl_alloc_handle(self) -> int:
|
||||
handle = int(u64(self._dl_next_handle))
|
||||
if handle == 0:
|
||||
handle = 1
|
||||
|
||||
while handle in self._dl_images or handle == 0:
|
||||
handle = int(u64(handle + 1))
|
||||
if handle == 0:
|
||||
handle = 1
|
||||
|
||||
self._dl_next_handle = int(u64(handle + 1))
|
||||
if self._dl_next_handle == 0:
|
||||
self._dl_next_handle = 1
|
||||
|
||||
return handle
|
||||
|
||||
def _dl_pick_base(self, min_vaddr: int, max_vaddr: int) -> Tuple[int, int, int]:
|
||||
old_base = page_floor(min_vaddr)
|
||||
span = page_ceil(max_vaddr - old_base)
|
||||
candidate = page_ceil(self._dl_next_base)
|
||||
retries = 0
|
||||
|
||||
if span <= 0:
|
||||
return 0, 0, 0
|
||||
|
||||
while retries < 1024:
|
||||
start = candidate
|
||||
end = start + span
|
||||
|
||||
if not self._range_overlaps_mapped(start, end):
|
||||
self._dl_next_base = end + DL_BASE_GAP
|
||||
return start, end, start - old_base
|
||||
|
||||
candidate = end + DL_BASE_GAP
|
||||
retries += 1
|
||||
|
||||
return 0, 0, 0
|
||||
|
||||
@staticmethod
|
||||
def _dl_rebase_non_exec_segment(data: bytes, old_base: int, old_end: int, delta: int) -> bytes:
|
||||
if not data or delta == 0:
|
||||
return data
|
||||
|
||||
patched = bytearray(data)
|
||||
limit = len(patched) - (len(patched) % 8)
|
||||
|
||||
for off in range(0, limit, 8):
|
||||
value = struct.unpack_from("<Q", patched, off)[0]
|
||||
if old_base <= value < old_end:
|
||||
struct.pack_into("<Q", patched, off, u64(value + delta))
|
||||
|
||||
return bytes(patched)
|
||||
|
||||
@staticmethod
|
||||
def _read_elf_cstring(blob: bytes, start: int, end: int) -> str:
|
||||
if start < 0 or end <= start or start >= len(blob):
|
||||
return ""
|
||||
|
||||
limit = min(end, len(blob))
|
||||
cur = start
|
||||
while cur < limit and blob[cur] != 0:
|
||||
cur += 1
|
||||
|
||||
if cur <= start:
|
||||
return ""
|
||||
|
||||
return blob[start:cur].decode("utf-8", errors="ignore")
|
||||
|
||||
@classmethod
|
||||
def _dl_extract_symbols(cls, blob: bytes, load_bias: int) -> Dict[str, int]:
|
||||
symbols: Dict[str, int] = {}
|
||||
sections: List[Tuple[int, int, int, int, int, int, int, int, int, int]] = []
|
||||
|
||||
if len(blob) < 0x40:
|
||||
return symbols
|
||||
|
||||
try:
|
||||
shoff = struct.unpack_from("<Q", blob, 0x28)[0]
|
||||
shentsize = struct.unpack_from("<H", blob, 0x3A)[0]
|
||||
shnum = struct.unpack_from("<H", blob, 0x3C)[0]
|
||||
except struct.error:
|
||||
return symbols
|
||||
|
||||
if shoff == 0 or shentsize < 64 or shnum == 0:
|
||||
return symbols
|
||||
|
||||
if shoff + (shentsize * shnum) > len(blob):
|
||||
return symbols
|
||||
|
||||
for idx in range(shnum):
|
||||
off = shoff + (idx * shentsize)
|
||||
try:
|
||||
sh = struct.unpack_from("<IIQQQQIIQQ", blob, off)
|
||||
except struct.error:
|
||||
return symbols
|
||||
sections.append(sh)
|
||||
|
||||
for sh in sections:
|
||||
sh_type = int(sh[1])
|
||||
sh_offset = int(sh[4])
|
||||
sh_size = int(sh[5])
|
||||
sh_link = int(sh[6])
|
||||
sh_entsize = int(sh[9])
|
||||
|
||||
if sh_type not in (2, 11):
|
||||
continue
|
||||
|
||||
if sh_link < 0 or sh_link >= len(sections):
|
||||
continue
|
||||
|
||||
if sh_entsize < 24:
|
||||
sh_entsize = 24
|
||||
|
||||
if sh_offset < 0 or sh_size <= 0 or sh_offset >= len(blob):
|
||||
continue
|
||||
|
||||
sym_end = min(len(blob), sh_offset + sh_size)
|
||||
if sym_end <= sh_offset:
|
||||
continue
|
||||
|
||||
strtab = sections[sh_link]
|
||||
str_off = int(strtab[4])
|
||||
str_size = int(strtab[5])
|
||||
|
||||
if str_size <= 0 or str_off < 0 or str_off >= len(blob):
|
||||
continue
|
||||
|
||||
str_end = min(len(blob), str_off + str_size)
|
||||
count = (sym_end - sh_offset) // sh_entsize
|
||||
|
||||
for i in range(count):
|
||||
ent_off = sh_offset + (i * sh_entsize)
|
||||
if ent_off + 24 > len(blob):
|
||||
break
|
||||
|
||||
try:
|
||||
st_name, _st_info, _st_other, st_shndx, st_value, _st_size = struct.unpack_from(
|
||||
"<IBBHQQ", blob, ent_off
|
||||
)
|
||||
except struct.error:
|
||||
break
|
||||
|
||||
if st_name == 0 or st_shndx == 0 or st_value == 0:
|
||||
continue
|
||||
|
||||
name = cls._read_elf_cstring(blob, str_off + st_name, str_end)
|
||||
if not name:
|
||||
continue
|
||||
|
||||
addr = int(u64(st_value + load_bias))
|
||||
if addr == 0:
|
||||
continue
|
||||
|
||||
if name not in symbols:
|
||||
symbols[name] = addr
|
||||
|
||||
return symbols
|
||||
|
||||
def _dl_open(self, uc: Uc, path_ptr: int) -> int:
|
||||
guest_path = self._normalize_guest_path(self._read_guest_cstring(uc, path_ptr, DL_MAX_NAME))
|
||||
cached_handle = self._dl_path_to_handle.get(guest_path)
|
||||
host_path: Optional[Path]
|
||||
file_blob: bytes
|
||||
image: ELFImage
|
||||
e_type: int
|
||||
min_vaddr: int
|
||||
max_vaddr: int
|
||||
map_start: int
|
||||
map_end: int
|
||||
load_bias: int
|
||||
handle: int
|
||||
owner_pid: int
|
||||
|
||||
if not guest_path.startswith("/") or guest_path == "/":
|
||||
return u64_neg1()
|
||||
if len(guest_path.encode("utf-8", errors="replace")) >= DL_MAX_NAME:
|
||||
return u64_neg1()
|
||||
|
||||
if cached_handle is not None:
|
||||
cached = self._dl_images.get(cached_handle)
|
||||
if cached is not None:
|
||||
cached.ref_count += 1
|
||||
return cached.handle
|
||||
|
||||
host_path = self._guest_to_host(guest_path, must_exist=True)
|
||||
if host_path is None or not host_path.is_file():
|
||||
return u64_neg1()
|
||||
|
||||
try:
|
||||
file_blob = host_path.read_bytes()
|
||||
image = self._parse_elf_image_from_blob(file_blob, require_entry=False)
|
||||
e_type = struct.unpack_from("<H", file_blob, 0x10)[0]
|
||||
except Exception:
|
||||
return u64_neg1()
|
||||
|
||||
min_vaddr = min(seg.vaddr for seg in image.segments)
|
||||
max_vaddr = max(seg.vaddr + seg.memsz for seg in image.segments)
|
||||
if max_vaddr <= min_vaddr:
|
||||
return u64_neg1()
|
||||
|
||||
map_start, map_end, load_bias = self._dl_pick_base(min_vaddr, max_vaddr)
|
||||
if map_start == 0 or map_end <= map_start:
|
||||
return u64_neg1()
|
||||
|
||||
try:
|
||||
for seg in image.segments:
|
||||
seg_start = page_floor(seg.vaddr + load_bias)
|
||||
seg_end = page_ceil(seg.vaddr + load_bias + seg.memsz)
|
||||
self._map_region(uc, seg_start, seg_end - seg_start, UC_PROT_ALL)
|
||||
|
||||
for seg in image.segments:
|
||||
payload = seg.data
|
||||
if e_type == 2 and (seg.flags & 0x1) == 0 and load_bias != 0 and payload:
|
||||
payload = self._dl_rebase_non_exec_segment(payload, min_vaddr, max_vaddr, load_bias)
|
||||
if payload:
|
||||
self._mem_write(uc, seg.vaddr + load_bias, payload)
|
||||
|
||||
for seg in image.segments:
|
||||
seg_start = page_floor(seg.vaddr + load_bias)
|
||||
seg_end = page_ceil(seg.vaddr + load_bias + seg.memsz)
|
||||
perms = 0
|
||||
if seg.flags & 0x4:
|
||||
perms |= UC_PROT_READ
|
||||
if seg.flags & 0x2:
|
||||
perms |= UC_PROT_WRITE
|
||||
if seg.flags & 0x1:
|
||||
perms |= UC_PROT_EXEC
|
||||
if perms == 0:
|
||||
perms = UC_PROT_READ
|
||||
try:
|
||||
uc.mem_protect(seg_start, seg_end - seg_start, perms)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
return u64_neg1()
|
||||
|
||||
handle = self._dl_alloc_handle()
|
||||
owner_pid = self.state.get_current_pid()
|
||||
self._dl_images[handle] = DLImage(
|
||||
handle=handle,
|
||||
guest_path=guest_path,
|
||||
host_path=str(host_path),
|
||||
owner_pid=owner_pid,
|
||||
ref_count=1,
|
||||
map_start=map_start,
|
||||
map_end=map_end,
|
||||
load_bias=load_bias,
|
||||
symbols=self._dl_extract_symbols(file_blob, load_bias),
|
||||
)
|
||||
self._dl_path_to_handle[guest_path] = handle
|
||||
return handle
|
||||
|
||||
def _dl_close(self, handle: int) -> int:
|
||||
key = int(handle)
|
||||
image = self._dl_images.get(key)
|
||||
|
||||
if key == 0 or image is None:
|
||||
return u64_neg1()
|
||||
|
||||
if image.ref_count > 1:
|
||||
image.ref_count -= 1
|
||||
return 0
|
||||
|
||||
del self._dl_images[key]
|
||||
if self._dl_path_to_handle.get(image.guest_path) == key:
|
||||
del self._dl_path_to_handle[image.guest_path]
|
||||
return 0
|
||||
|
||||
def _dl_sym(self, uc: Uc, handle: int, symbol_ptr: int) -> int:
|
||||
symbol = self._read_guest_cstring(uc, symbol_ptr, DL_MAX_SYMBOL)
|
||||
image = self._dl_images.get(int(handle))
|
||||
addr: Optional[int]
|
||||
|
||||
if image is None or not symbol:
|
||||
return 0
|
||||
|
||||
addr = image.symbols.get(symbol)
|
||||
if addr is None and not symbol.startswith("_"):
|
||||
addr = image.symbols.get(f"_{symbol}")
|
||||
|
||||
return int(u64(addr)) if addr is not None else 0
|
||||
|
||||
def _fb_info(self, uc: Uc, out_ptr: int) -> int:
|
||||
if out_ptr == 0:
|
||||
return 0
|
||||
|
||||
self._ensure_fb_window()
|
||||
|
||||
payload = struct.pack(
|
||||
"<QQQQ",
|
||||
u64(self._fb_width),
|
||||
u64(self._fb_height),
|
||||
u64(self._fb_pitch),
|
||||
u64(self._fb_bpp),
|
||||
)
|
||||
ok = 1 if self._write_guest_bytes(uc, out_ptr, payload) else 0
|
||||
if ok == 1:
|
||||
self._fb_present(force=True)
|
||||
return ok
|
||||
|
||||
def _fb_clear(self, rgb: int) -> int:
|
||||
if len(self._fb_pixels) == 0:
|
||||
return 0
|
||||
|
||||
pixel = struct.pack("<I", int(u64(rgb) & 0xFFFFFFFF))
|
||||
self._fb_pixels[:] = pixel * (len(self._fb_pixels) // 4)
|
||||
self._fb_mark_dirty()
|
||||
return 1
|
||||
|
||||
def _fb_blit(self, uc: Uc, req_ptr: int) -> int:
|
||||
req_blob = self._read_guest_bytes_exact(uc, int(req_ptr), 56)
|
||||
pixels_ptr: int
|
||||
src_width: int
|
||||
src_height: int
|
||||
src_pitch_bytes: int
|
||||
dst_x: int
|
||||
dst_y: int
|
||||
scale: int
|
||||
total_src_bytes: int
|
||||
src_blob: Optional[bytes]
|
||||
max_src_w: int
|
||||
max_src_h: int
|
||||
copy_w: int
|
||||
copy_h: int
|
||||
|
||||
if req_ptr == 0 or req_blob is None or len(req_blob) != 56:
|
||||
return 0
|
||||
|
||||
pixels_ptr, src_width, src_height, src_pitch_bytes, dst_x, dst_y, scale = struct.unpack("<QQQQQQQ", req_blob)
|
||||
|
||||
if pixels_ptr == 0 or src_width == 0 or src_height == 0 or scale == 0:
|
||||
return 0
|
||||
|
||||
if src_width > 4096 or src_height > 4096 or scale > 8:
|
||||
return 0
|
||||
|
||||
if src_pitch_bytes == 0:
|
||||
src_pitch_bytes = src_width * 4
|
||||
|
||||
if src_pitch_bytes < (src_width * 4):
|
||||
return 0
|
||||
|
||||
if src_height > (u64_neg1() // src_pitch_bytes):
|
||||
return 0
|
||||
|
||||
if dst_x >= self._fb_width or dst_y >= self._fb_height:
|
||||
return 0
|
||||
|
||||
total_src_bytes = src_pitch_bytes * src_height
|
||||
if total_src_bytes == 0 or total_src_bytes > FB_MAX_UPLOAD_BYTES:
|
||||
return 0
|
||||
|
||||
src_blob = self._read_guest_bytes_exact(uc, pixels_ptr, total_src_bytes)
|
||||
if src_blob is None:
|
||||
return 0
|
||||
|
||||
max_src_w = (self._fb_width - dst_x + scale - 1) // scale
|
||||
max_src_h = (self._fb_height - dst_y + scale - 1) // scale
|
||||
copy_w = min(src_width, max_src_w)
|
||||
copy_h = min(src_height, max_src_h)
|
||||
|
||||
if copy_w <= 0 or copy_h <= 0:
|
||||
return 0
|
||||
|
||||
if scale == 1:
|
||||
row_bytes = copy_w * 4
|
||||
for y in range(copy_h):
|
||||
src_off = y * src_pitch_bytes
|
||||
dst_off = ((dst_y + y) * self._fb_width + dst_x) * 4
|
||||
self._fb_pixels[dst_off : dst_off + row_bytes] = src_blob[src_off : src_off + row_bytes]
|
||||
else:
|
||||
draw_row_pixels = min(self._fb_width - dst_x, copy_w * scale)
|
||||
draw_row_bytes = draw_row_pixels * 4
|
||||
|
||||
for y in range(copy_h):
|
||||
src_row_off = y * src_pitch_bytes
|
||||
src_row = memoryview(src_blob)[src_row_off : src_row_off + (copy_w * 4)]
|
||||
expanded = bytearray(copy_w * scale * 4)
|
||||
write_off = 0
|
||||
|
||||
for x in range(copy_w):
|
||||
pixel = bytes(src_row[x * 4 : (x + 1) * 4])
|
||||
expanded[write_off : write_off + (scale * 4)] = pixel * scale
|
||||
write_off += scale * 4
|
||||
|
||||
draw_y = dst_y + (y * scale)
|
||||
repeat_h = min(scale, self._fb_height - draw_y)
|
||||
row_data = expanded[:draw_row_bytes]
|
||||
|
||||
for sy in range(repeat_h):
|
||||
dst_off = ((draw_y + sy) * self._fb_width + dst_x) * 4
|
||||
self._fb_pixels[dst_off : dst_off + draw_row_bytes] = row_data
|
||||
|
||||
self._fb_mark_dirty()
|
||||
|
||||
return 1
|
||||
|
||||
@staticmethod
|
||||
def _truncate_item_text(text: str, max_bytes: int = EXEC_ITEM_MAX) -> str:
|
||||
if max_bytes <= 1:
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
unicorn>=2.0.1
|
||||
pygame>=2.5.2
|
||||
|
||||
Reference in New Issue
Block a user