Add hot-update boot pipeline, UI bundles/atlas assets, and design docs.

Ignore AIClient/Library and AIClient/HybridCLRData permanently; include HybridCLR/YooAsset project setup and UI tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-09-21 16:02:58 +08:00
co-authored by Cursor
parent f3f66ab063
commit b7a0bbcb89
279 changed files with 27332 additions and 14 deletions
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""CDN 发布与本地联调工具。
目录约定(与 ProjectDesign / Unity BuildAndStage 一致)::
Tools/cdn_root/wechat/version.json
Tools/cdn_root/wechat/1.0.0/DefaultPackage/<yooasset files>
用法::
# 本地 HTTP(HostPlayMode 联调)
python Tools/cdn_publish.py serve --port 8787
# 校验目录
python Tools/cdn_publish.py status
# 上传到远端(需配置)
python Tools/cdn_publish.py upload --mode copy --dest //server/cdn/www
python Tools/cdn_publish.py upload --mode rsync --dest user@host:/var/cdn/www
# 或自定义命令({src} {dest}):
set CDN_UPLOAD_CMD=scp -r {src}/. {dest}/
python Tools/cdn_publish.py upload --mode cmd --dest user@host:/var/www
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
CDN_ROOT = REPO / "Tools" / "cdn_root"
CHANNEL = "wechat"
DEFAULT_PORT = 8787
class QuietHandler(SimpleHTTPRequestHandler):
def log_message(self, fmt: str, *args) -> None:
sys.stderr.write("[%s] %s\n" % (self.log_date_time_string(), fmt % args))
def end_headers(self) -> None:
# 方便微信/编辑器跨域拉 version.json;生产 CDN 由 Caddy 配
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", "no-cache")
super().end_headers()
def cmd_status(_: argparse.Namespace) -> int:
version = CDN_ROOT / CHANNEL / "version.json"
meta = CDN_ROOT / CHANNEL / "last_publish.json"
print(f"cdn_root={CDN_ROOT}")
print(f"version_json_exists={version.is_file()} path={version}")
if version.is_file():
print(version.read_text(encoding="utf-8"))
print(f"meta_exists={meta.is_file()} path={meta}")
if meta.is_file():
print(meta.read_text(encoding="utf-8"))
pkg_root = CDN_ROOT / CHANNEL
if pkg_root.is_dir():
for p in sorted(pkg_root.rglob("*")):
if p.is_file() and p.name != "version.json":
rel = p.relative_to(CDN_ROOT)
print(f" file {rel} ({p.stat().st_size} bytes)")
return 0
def cmd_serve(args: argparse.Namespace) -> int:
if not CDN_ROOT.is_dir():
print(f"ERROR: missing {CDN_ROOT}, run Unity menu AIGame/YooAsset/Build And Stage first", file=sys.stderr)
return 1
os.chdir(CDN_ROOT)
handler = partial(QuietHandler, directory=str(CDN_ROOT))
server = ThreadingHTTPServer(("0.0.0.0", args.port), handler)
print(f"serving {CDN_ROOT} at http://127.0.0.1:{args.port}/")
print(f"version url: http://127.0.0.1:{args.port}/{CHANNEL}/version.json")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nstopped")
return 0
def cmd_upload(args: argparse.Namespace) -> int:
src = CDN_ROOT
if not src.is_dir():
print(f"ERROR: missing {src}", file=sys.stderr)
return 1
dest = args.dest
if not dest:
print("ERROR: --dest required", file=sys.stderr)
return 1
# 上传前把 version.prod.json 覆盖为对外 version.json(若存在)
prod = src / CHANNEL / "version.prod.json"
live = src / CHANNEL / "version.json"
if prod.is_file():
shutil.copy2(prod, live)
print(f"promoted {prod.name} -> version.json for upload")
mode = args.mode
if mode == "copy":
dest_path = Path(dest)
dest_path.mkdir(parents=True, exist_ok=True)
# 同步 wechat 通道目录
target = dest_path / CHANNEL
if target.exists():
shutil.rmtree(target)
shutil.copytree(src / CHANNEL, target, ignore=shutil.ignore_patterns("version.prod.json", "last_publish.json"))
print(f"copied {src / CHANNEL} -> {target}")
return 0
if mode == "rsync":
cmd = [
"rsync", "-avz", "--delete",
"--exclude", "version.prod.json",
"--exclude", "last_publish.json",
f"{src / CHANNEL}/",
f"{dest.rstrip('/')}/{CHANNEL}/",
]
print(" ".join(cmd))
return subprocess.call(cmd)
if mode == "cmd":
template = os.environ.get("CDN_UPLOAD_CMD", "").strip()
if not template:
print("ERROR: set CDN_UPLOAD_CMD with {src} and {dest}", file=sys.stderr)
return 1
cmd = template.format(src=str(src / CHANNEL), dest=dest)
print(cmd)
return subprocess.call(cmd, shell=True)
print(f"ERROR: unknown mode {mode}", file=sys.stderr)
return 1
def cmd_write_version(args: argparse.Namespace) -> int:
"""手动改写 version.json(不重新打 AB)。"""
path = CDN_ROOT / CHANNEL / "version.json"
path.parent.mkdir(parents=True, exist_ok=True)
data = {
"appVersionMin": args.app_version,
"appVersionMax": args.app_version,
"resVersion": args.res_version,
"cdnRoot": args.cdn_root,
"packages": ["DefaultPackage"],
"forceUpdate": bool(args.force_update),
"notice": args.notice or "",
}
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"wrote {path}")
print(json.dumps(data, ensure_ascii=False, indent=2))
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="AIGame CDN publish helpers")
sub = parser.add_subparsers(dest="cmd", required=True)
p_status = sub.add_parser("status", help="Show staged CDN files")
p_status.set_defaults(func=cmd_status)
p_serve = sub.add_parser("serve", help="Serve Tools/cdn_root over HTTP")
p_serve.add_argument("--port", type=int, default=DEFAULT_PORT)
p_serve.set_defaults(func=cmd_serve)
p_upload = sub.add_parser("upload", help="Upload wechat channel to remote CDN root")
p_upload.add_argument("--mode", choices=("copy", "rsync", "cmd"), default="copy")
p_upload.add_argument("--dest", required=True, help="Remote or local destination root (www root)")
p_upload.set_defaults(func=cmd_upload)
p_ver = sub.add_parser("write-version", help="Rewrite version.json")
p_ver.add_argument("--app-version", default="1.0.0")
p_ver.add_argument("--res-version", required=True)
p_ver.add_argument("--cdn-root", default="https://www.xworld.ren/wechat/1.0.0/DefaultPackage")
p_ver.add_argument("--force-update", action="store_true")
p_ver.add_argument("--notice", default="")
p_ver.set_defaults(func=cmd_write_version)
args = parser.parse_args()
return int(args.func(args) or 0)
if __name__ == "__main__":
raise SystemExit(main())
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
import json
import urllib.error
import urllib.request
BASE = "https://game.xworld.ren"
CASES = [
("POST /login empty json", "POST", "/login", {"Content-Type": "application/json"}, b"{}"),
("POST /login guest", "POST", "/login", {"Content-Type": "application/json"}, json.dumps({"mode": "guest", "pid": "m2"}).encode()),
("POST /login device", "POST", "/login", {"Content-Type": "application/json"}, json.dumps({"deviceId": "m2_probe", "platform": "editor"}).encode()),
("POST /login user", "POST", "/login", {"Content-Type": "application/json"}, json.dumps({"username": "test", "password": "test"}).encode()),
("POST /api/v1/login", "POST", "/api/v1/login", {"Content-Type": "application/json"}, json.dumps({"deviceId": "m2"}).encode()),
("POST /auth/login", "POST", "/auth/login", {"Content-Type": "application/json"}, json.dumps({"deviceId": "m2"}).encode()),
("POST /ws/token", "POST", "/ws/token", {"Content-Type": "application/json"}, json.dumps({"pid": "m2"}).encode()),
("GET /login", "GET", "/login", {}, None),
("OPTIONS /login", "OPTIONS", "/login", {"Origin": "https://www.xworld.ren"}, None),
]
def hit(method, path, headers, body):
req = urllib.request.Request(BASE + path, data=body, method=method)
for k, v in headers.items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
data = resp.read()
print(f"OK {method} {path} {resp.status} {data[:500]!r}")
except urllib.error.HTTPError as e:
body = e.read()
print(f"HTTP {e.code} {method} {path} headers={dict(e.headers)} body={body[:500]!r}")
except Exception as e:
print(f"ERR {method} {path}: {type(e).__name__} {e}")
def main():
for name, method, path, headers, body in CASES:
print("---", name)
hit(method, path, headers, body)
if __name__ == "__main__":
main()
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
import json
import urllib.error
import urllib.request
BASE = "https://game.xworld.ren"
BODIES = [
{"user": "test", "pass": "test"},
{"account": "test", "password": "test"},
{"name": "test", "password": "test"},
{"username": "admin", "password": "admin"},
{"username": "guest", "password": "guest"},
{"username": "demo", "password": "demo"},
{"username": "udx", "password": "abcd99"},
{"username": "test", "password": "test", "platform": "editor"},
{"username": "test", "password": "test", "pid": "m2"},
{"username": "a", "password": "b", "deviceId": "x"},
]
PATHS = ["/login", "/register", "/signup", "/guest", "/auth", "/token"]
def post(path, payload):
data = json.dumps(payload).encode()
req = urllib.request.Request(BASE + path, data=data, method="POST")
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
body = resp.read().decode("utf-8", "replace")
print(f"OK {resp.status} {path} {payload} => {body[:500]}")
return body
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", "replace")
print(f"HTTP {e.code} {path} {payload} => {body[:500]}")
return None
except Exception as e:
print(f"ERR {path}: {e}")
return None
def main():
print("=== path discovery ===")
for p in PATHS:
post(p, {"username": "x", "password": "y"})
print("=== login field variants ===")
for b in BODIES:
post("/login", b)
if __name__ == "__main__":
main()
+651
View File
@@ -0,0 +1,651 @@
{
"count": 38,
"items": [
{
"name": "ui_panel_main",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\ui_panel_main.png",
"planned": [
8,
8,
480,
256
],
"box": [
22,
62,
543,
315
],
"score": 9476.7
},
{
"name": "ui_panel_popup",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\ui_panel_popup.png",
"planned": [
496,
8,
360,
240
],
"box": [
588,
102,
329,
252
],
"score": 15755.6
},
{
"name": "ui_panel_content",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\ui_panel_content.png",
"planned": [
864,
8,
320,
200
],
"box": [
944,
119,
327,
218
],
"score": 21237.9
},
{
"name": "ui_panel_header",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\ui_panel_header.png",
"planned": [
1192,
8,
400,
80
],
"box": [
1294,
115,
427,
110
],
"score": 29148.7
},
{
"name": "ui_overlay_dim",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\ui_overlay_dim.png",
"planned": [
1600,
8,
192,
192
],
"box": [
1768,
77,
257,
260
],
"score": 52426.6
},
{
"name": "ui_divider_h",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\ui_divider_h.png",
"planned": [
1192,
96,
400,
16
],
"box": [
1294,
288,
452,
23
],
"score": 55947.7
},
{
"name": "btn_primary_normal",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\btn_primary_normal.png",
"planned": [
8,
280,
224,
88
],
"box": [
28,
416,
247,
116
],
"score": 24205.5
},
{
"name": "btn_primary_pressed",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\btn_primary_pressed.png",
"planned": [
240,
280,
224,
88
],
"box": [
294,
416,
245,
116
],
"score": 27326.5
},
{
"name": "btn_primary_disabled",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\btn_primary_disabled.png",
"planned": [
472,
280,
224,
88
],
"box": [
553,
416,
243,
116
],
"score": 31317.7
},
{
"name": "btn_secondary_normal",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\btn_secondary_normal.png",
"planned": [
704,
280,
224,
88
],
"box": [
806,
416,
232,
112
],
"score": 33491.7
},
{
"name": "btn_secondary_pressed",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\btn_secondary_pressed.png",
"planned": [
936,
280,
224,
88
],
"box": [
1052,
416,
217,
112
],
"score": 34903.1
},
{
"name": "btn_small_normal",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\btn_small_normal.png",
"planned": [
1168,
280,
160,
64
],
"box": [
1287,
438,
156,
79
],
"score": 41241.7
},
{
"name": "btn_small_pressed",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\btn_small_pressed.png",
"planned": [
1336,
280,
160,
64
],
"box": [
1465,
438,
158,
78
],
"score": 43700.6
},
{
"name": "btn_close",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\btn_close.png",
"planned": [
1504,
280,
72,
72
],
"box": [
1640,
413,
116,
122
],
"score": 56458.2
},
{
"name": "btn_back",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\btn_back.png",
"planned": [
1584,
280,
72,
72
],
"box": [
1772,
413,
113,
121
],
"score": 74212.4
},
{
"name": "scrollbar_track_v",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\scrollbar_track_v.png",
"planned": [
1664,
280,
28,
160
],
"box": [
1900,
358,
51,
224
],
"score": 79677.2
},
{
"name": "scrollbar_thumb_v",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\scrollbar_thumb_v.png",
"planned": [
1700,
280,
28,
80
],
"box": [
1969,
398,
53,
151
],
"score": 115048.8
},
{
"name": "input_bg_normal",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\input_bg_normal.png",
"planned": [
8,
400,
360,
72
],
"box": [
24,
582,
356,
104
],
"score": 40769.7
},
{
"name": "input_bg_focus",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\input_bg_focus.png",
"planned": [
376,
400,
360,
72
],
"box": [
387,
576,
355,
113
],
"score": 41075.7
},
{
"name": "ui_list_item_normal",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\ui_list_item_normal.png",
"planned": [
744,
400,
320,
72
],
"box": [
752,
580,
351,
108
],
"score": 41610.3
},
{
"name": "ui_list_item_selected",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\ui_list_item_selected.png",
"planned": [
1072,
400,
320,
72
],
"box": [
1111,
579,
352,
109
],
"score": 43971.9
},
{
"name": "tab_active",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\tab_active.png",
"planned": [
1400,
400,
200,
72
],
"box": [
1477,
578,
253,
88
],
"score": 45976.1
},
{
"name": "tab_inactive",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\tab_inactive.png",
"planned": [
1608,
400,
200,
72
],
"box": [
1742,
590,
273,
78
],
"score": 67077.6
},
{
"name": "progress_bg",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\progress_bg.png",
"planned": [
8,
504,
400,
32
],
"box": [
30,
743,
434,
57
],
"score": 69423.9
},
{
"name": "progress_fill",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\progress_fill.png",
"planned": [
416,
504,
400,
32
],
"box": [
488,
743,
405,
57
],
"score": 73361.1
},
{
"name": "slider_track",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\slider_track.png",
"planned": [
824,
504,
320,
28
],
"box": [
927,
750,
263,
42
],
"score": 71486.1
},
{
"name": "slider_fill",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\slider_fill.png",
"planned": [
1152,
504,
320,
28
],
"box": [
1220,
749,
233,
45
],
"score": 68125.3
},
{
"name": "slider_handle",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\slider_handle.png",
"planned": [
1480,
496,
56,
56
],
"box": [
1483,
718,
97,
99
],
"score": 68583.1
},
{
"name": "toast_bg",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\toast_bg.png",
"planned": [
1544,
496,
280,
80
],
"box": [
1613,
701,
402,
118
],
"score": 70046.3
},
{
"name": "toggle_bg_off",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\toggle_bg_off.png",
"planned": [
8,
616,
112,
56
],
"box": [
32,
875,
212,
108
],
"score": 99618.0
},
{
"name": "toggle_bg_on",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\toggle_bg_on.png",
"planned": [
128,
616,
112,
56
],
"box": [
266,
875,
201,
108
],
"score": 126013.9
},
{
"name": "toggle_knob",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\toggle_knob.png",
"planned": [
248,
616,
48,
48
],
"box": [
517,
876,
100,
103
],
"score": 188991.6
},
{
"name": "checkbox_off",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\checkbox_off.png",
"planned": [
304,
616,
56,
56
],
"box": [
671,
870,
110,
112
],
"score": 249523.3
},
{
"name": "checkbox_on",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\checkbox_on.png",
"planned": [
368,
616,
56,
56
],
"box": [
840,
868,
113,
118
],
"score": 347900.6
},
{
"name": "ico_search",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\ico_search.png",
"planned": [
432,
616,
56,
56
],
"box": [
1023,
862,
129,
134
],
"score": 503184.0
},
{
"name": "ico_check",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\ico_check.png",
"planned": [
496,
616,
56,
56
],
"box": [
1210,
863,
124,
127
],
"score": 662935.9
},
{
"name": "ico_settings",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\ico_settings.png",
"planned": [
560,
616,
56,
56
],
"box": [
1416,
855,
143,
147
],
"score": 929290.7
},
{
"name": "ico_refresh",
"path": "d:\\UD\\AI\\AIGame\\AIClient\\Assets\\Bundles\\UI\\Texture\\Common\\ico_refresh.png",
"planned": [
624,
616,
56,
56
],
"box": [
1619,
859,
141,
135
],
"score": 1189245.0
}
]
}
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Slice UI Normal atlas into Common single sprites."""
from __future__ import annotations
import json
from collections import deque
from pathlib import Path
import numpy as np
from PIL import Image
ATLAS = Path(r"d:\UD\AI\AIGame\Tools\output\ui_normal_atlas_gpt.png")
OUT_DIR = Path(r"d:\UD\AI\AIGame\AIClient\Assets\Bundles\UI\Texture\Common")
REPORT = Path(r"d:\UD\AI\AIGame\Tools\output\ui_normal_slice_report.json")
# From Doc/UI_Normal_Set.md (left-top origin)
SLICES = [
("ui_panel_main", 8, 8, 480, 256),
("ui_panel_popup", 496, 8, 360, 240),
("ui_panel_content", 864, 8, 320, 200),
("ui_panel_header", 1192, 8, 400, 80),
("ui_overlay_dim", 1600, 8, 192, 192),
("ui_divider_h", 1192, 96, 400, 16),
("btn_primary_normal", 8, 280, 224, 88),
("btn_primary_pressed", 240, 280, 224, 88),
("btn_primary_disabled", 472, 280, 224, 88),
("btn_secondary_normal", 704, 280, 224, 88),
("btn_secondary_pressed", 936, 280, 224, 88),
("btn_small_normal", 1168, 280, 160, 64),
("btn_small_pressed", 1336, 280, 160, 64),
("btn_close", 1504, 280, 72, 72),
("btn_back", 1584, 280, 72, 72),
("scrollbar_track_v", 1664, 280, 28, 160),
("scrollbar_thumb_v", 1700, 280, 28, 80),
("input_bg_normal", 8, 400, 360, 72),
("input_bg_focus", 376, 400, 360, 72),
("ui_list_item_normal", 744, 400, 320, 72),
("ui_list_item_selected", 1072, 400, 320, 72),
("tab_active", 1400, 400, 200, 72),
("tab_inactive", 1608, 400, 200, 72),
("progress_bg", 8, 504, 400, 32),
("progress_fill", 416, 504, 400, 32),
("slider_track", 824, 504, 320, 28),
("slider_fill", 1152, 504, 320, 28),
("slider_handle", 1480, 496, 56, 56),
("toast_bg", 1544, 496, 280, 80),
("toggle_bg_off", 8, 616, 112, 56),
("toggle_bg_on", 128, 616, 112, 56),
("toggle_knob", 248, 616, 48, 48),
("checkbox_off", 304, 616, 56, 56),
("checkbox_on", 368, 616, 56, 56),
("ico_search", 432, 616, 56, 56),
("ico_check", 496, 616, 56, 56),
("ico_settings", 560, 616, 56, 56),
("ico_refresh", 624, 616, 56, 56),
]
def find_boxes(arr: np.ndarray, alpha_thr: int = 24, min_px: int = 60):
mask = arr[:, :, 3] > alpha_thr
h, w = mask.shape
visited = np.zeros_like(mask, dtype=bool)
boxes = []
for y in range(h):
for x in np.where(mask[y] & ~visited[y])[0]:
if visited[y, x] or not mask[y, x]:
continue
q = deque([(int(y), int(x))])
visited[y, x] = True
minx = maxx = int(x)
miny = maxy = int(y)
cnt = 0
while q:
cy, cx = q.popleft()
cnt += 1
for ny, nx in ((cy - 1, cx), (cy + 1, cx), (cy, cx - 1), (cy, cx + 1)):
if 0 <= ny < h and 0 <= nx < w and mask[ny, nx] and not visited[ny, nx]:
visited[ny, nx] = True
q.append((ny, nx))
minx = min(minx, nx)
maxx = max(maxx, nx)
miny = min(miny, ny)
maxy = max(maxy, ny)
bw, bh = maxx - minx + 1, maxy - miny + 1
if cnt >= min_px and bw >= 10 and bh >= 8:
boxes.append(
{
"miny": miny,
"minx": minx,
"maxy": maxy,
"maxx": maxx,
"w": bw,
"h": bh,
"cx": (minx + maxx) / 2,
"cy": (miny + maxy) / 2,
"px": cnt,
}
)
return boxes
def match_slices(boxes: list[dict]) -> list[dict]:
used = set()
matches = []
for name, x, y, w, h in SLICES:
pcx, pcy = x + w / 2, y + h / 2
best_i, best_score = None, 1e18
for i, b in enumerate(boxes):
if i in used:
continue
# prefer position; lightly penalize size mismatch
dist = (b["cx"] - pcx) ** 2 + (b["cy"] - pcy) ** 2
size_pen = ((b["w"] - w) / max(w, 1)) ** 2 + ((b["h"] - h) / max(h, 1)) ** 2
score = dist + size_pen * 8000
if score < best_score:
best_score = score
best_i = i
if best_i is None:
raise RuntimeError(f"no box for {name}")
used.add(best_i)
b = boxes[best_i]
matches.append(
{
"name": name,
"planned": [x, y, w, h],
"box": [b["minx"], b["miny"], b["w"], b["h"]],
"score": round(best_score, 1),
}
)
return matches
def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
img = Image.open(ATLAS).convert("RGBA")
arr = np.array(img)
boxes = find_boxes(arr)
if len(boxes) != len(SLICES):
raise SystemExit(f"expected {len(SLICES)} components, got {len(boxes)}")
matches = match_slices(boxes)
written = []
for m in matches:
x, y, w, h = m["box"]
crop = img.crop((x, y, x + w, y + h))
# pad 2px transparent to reduce bleed when bilinear sampling
pad = 2
canvas = Image.new("RGBA", (w + pad * 2, h + pad * 2), (0, 0, 0, 0))
canvas.paste(crop, (pad, pad))
out = OUT_DIR / f"{m['name']}.png"
canvas.save(out)
written.append({"name": m["name"], "path": str(out), **m})
REPORT.write_text(json.dumps({"count": len(written), "items": written}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"sliced {len(written)} sprites -> {OUT_DIR}")
print(f"report -> {REPORT}")
for m in matches[:5]:
print(m["name"], "planned", m["planned"], "->", m["box"], "score", m["score"])
print("...")
for m in matches[-3:]:
print(m["name"], "planned", m["planned"], "->", m["box"], "score", m["score"])
if __name__ == "__main__":
main()
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
import asyncio
import json
import sys
async def main() -> int:
try:
import websockets
except ImportError:
print("NO_WEBSOCKETS")
return 2
url = "wss://game.xworld.ren/ws?pid=m2_probe"
print("connecting", url)
try:
async with websockets.connect(url, open_timeout=10, close_timeout=5) as ws:
print("OPEN ok")
try:
msg = await asyncio.wait_for(ws.recv(), timeout=3)
print("recv", type(msg).__name__, repr(msg)[:800])
except asyncio.TimeoutError:
print("recv_timeout (no hello)")
hello = {"op": "hello", "proto": 1, "app": "1.0.0", "pid": "m2_probe"}
await ws.send(json.dumps(hello, ensure_ascii=False))
print("sent", hello)
try:
msg = await asyncio.wait_for(ws.recv(), timeout=5)
print("after_hello", type(msg).__name__, repr(msg)[:800])
except asyncio.TimeoutError:
print("after_hello_timeout")
return 0
except Exception as e:
print("ERR", type(e).__name__, e)
return 1
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
import asyncio
import inspect
URL = "wss://game.xworld.ren/ws"
CASES = [
("pid_only", f"{URL}?pid=m2_probe", None),
("token_q", f"{URL}?pid=m2_probe&token=test", None),
("auth_q", f"{URL}?pid=m2_probe&auth=test", None),
("key_q", f"{URL}?pid=m2_probe&key=test", None),
("sig_q", f"{URL}?pid=m2_probe&sig=test", None),
("no_query", URL, None),
("bearer", f"{URL}?pid=m2_probe", [("Authorization", "Bearer test")]),
("x_token", f"{URL}?pid=m2_probe", [("X-Token", "test")]),
("x_api_key", f"{URL}?pid=m2_probe", [("X-Api-Key", "test")]),
]
async def try_one(websockets, name, url, headers):
kwargs = {"open_timeout": 8}
# websockets version differences
sig = inspect.signature(websockets.connect)
if headers:
if "additional_headers" in sig.parameters:
kwargs["additional_headers"] = headers
elif "extra_headers" in sig.parameters:
kwargs["extra_headers"] = headers
try:
async with websockets.connect(url, **kwargs) as ws:
print(f"OK {name}")
try:
msg = await asyncio.wait_for(ws.recv(), timeout=2)
print(f" recv {repr(msg)[:300]}")
except asyncio.TimeoutError:
print(" open but silent")
except Exception as e:
status = getattr(e, "status_code", None)
if status is None and hasattr(e, "response"):
status = getattr(e.response, "status_code", None)
print(f"FAIL {name}: {type(e).__name__} status={status} {e}")
async def main():
import websockets
print("websockets", websockets.__version__)
print("connect params", list(inspect.signature(websockets.connect).parameters))
for name, url, headers in CASES:
await try_one(websockets, name, url, headers)
if __name__ == "__main__":
asyncio.run(main())
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
import asyncio
import json
import time
import urllib.error
import urllib.request
BASE = "https://game.xworld.ren"
USER = f"m2_{int(time.time())}"
PASS = "m2_pass_123"
def http_json(method, path, payload=None):
data = None if payload is None else json.dumps(payload).encode()
req = urllib.request.Request(BASE + path, data=data, method=method)
if payload is not None:
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req, timeout=10) as resp:
return resp.status, json.loads(resp.read().decode())
async def ws_try(url, headers=None):
import websockets
kwargs = {"open_timeout": 8}
if headers:
kwargs["extra_headers"] = headers
print("WS", url, headers)
async with websockets.connect(url, **kwargs) as ws:
print("WS OPEN")
try:
msg = await asyncio.wait_for(ws.recv(), timeout=3)
print("recv", repr(msg)[:500])
except asyncio.TimeoutError:
print("recv timeout")
# try hello
await ws.send(json.dumps({"op": "hello", "proto": 1}))
try:
msg = await asyncio.wait_for(ws.recv(), timeout=3)
print("after hello", repr(msg)[:500])
except asyncio.TimeoutError:
print("after hello timeout")
# ping style
for payload in (
{"op": "ping"},
{"type": "ping"},
"ping",
):
await ws.send(payload if isinstance(payload, str) else json.dumps(payload))
try:
msg = await asyncio.wait_for(ws.recv(), timeout=2)
print("after", payload, "=>", repr(msg)[:300])
except asyncio.TimeoutError:
print("after", payload, "=> timeout")
async def main():
print("register", USER)
st, reg = http_json("POST", "/register", {"username": USER, "password": PASS})
print("register", st, reg)
token = reg["token"]
pid = reg["pid"]
st, login = http_json("POST", "/login", {"username": USER, "password": PASS})
print("login", st, login)
cases = [
(f"wss://game.xworld.ren/ws?pid={pid}&token={token}", None),
(f"wss://game.xworld.ren/ws?pid={pid}", [("Authorization", f"Bearer {token}")]),
(f"wss://game.xworld.ren/ws?token={token}", None),
(f"wss://game.xworld.ren/ws?pid={pid}", [("X-Token", token)]),
]
for url, headers in cases:
try:
await ws_try(url, headers)
break
except Exception as e:
print("FAIL", type(e).__name__, e)
if __name__ == "__main__":
asyncio.run(main())