From 7dc3e515e92e46dad93e5876984ed92f2dfa85a8 Mon Sep 17 00:00:00 2001 From: voson Date: Sun, 26 Jul 2026 12:16:47 +0800 Subject: [PATCH] Persist full GSI payloads to per-match JSONL for later analysis. Default-on dump keeps every POST body under samples/raw//gsi.jsonl, with CLI overrides to disable when disk use is a concern. Co-authored-by: Cursor --- AGENTS.md | 3 ++- CHANGELOG.md | 5 +++++ DESIGN.md | 8 ++++++++ README.md | 2 +- capture.py | 16 ++++++++++++++++ config.json | 3 ++- gsi_watch.py | 28 +++++++++++++++++++++++++++- 7 files changed, 61 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ce8aa2a..fb3a3ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段 运行时产物(**勿提交**,见 `.gitignore`): -- `samples/raw//` — GSI 会话截图;手动 `capture.py` 可写在 `raw/` 根下 +- `samples/raw//` — GSI 会话截图与 `gsi.jsonl`;手动 `capture.py` 可写在 `raw/` 根下 - `preview/` — 标定 / sheet 预览 - `results/` — 每局 JSON - `failures/` — `--truth` 调试用错识裁切 @@ -39,6 +39,7 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段 ``` Dota 2 GSI → gsi_watch.py (:3223) + → 全量 payload → samples/raw//gsi.jsonl(可关) → DraftSession 轮询截屏 → samples/raw// → recognize_image (CDN 模板 + 可选天梯遮罩) → roles / grid / modes(辅助) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef9b8dd..68507a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ## [Unreleased] +### Added + +- GSI 全量落盘:默认把每包 POST 追加到 `samples/raw//gsi.jsonl` + (`gsi.dump_payloads` / `--dump-gsi` / `--no-dump-gsi`)。 + ### Changed - 项目定名:**上分帝** / **Climperor**(目录 `climperor`),由 `dota2-draft-vision` 迁出独立仓库。 diff --git a/DESIGN.md b/DESIGN.md index 6adbcf3..d601022 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -76,6 +76,12 @@ Valve 已明确关闭普通玩家视角的实时 draft GSI 会话写入 `samples/raw/{matchid}/`;手动 `capture.py` 仍写 `samples/raw/` 根目录。 结果 JSON 仍为 `results/draft_<时间戳>.json`(内含 `match_id`)。 +**⑥ GSI 全量落盘** + +`gsi.dump_payloads`(默认开)把每包 POST body 追加到 `samples/raw/{matchid}/gsi.jsonl`, +行格式 `{"t": , "payload": <原文>}`。内容上限仍是 cfg 订阅字段 + 普通玩家视角; +不能靠落盘补出双方 pick。CLI:`--dump-gsi` / `--no-dump-gsi`。 + --- ## 3. 关键实测(2026-07-25) @@ -174,6 +180,7 @@ climperor/ | `gsi.revise_gain` | 选人阶段改判所需分数增益 | 0.15 | | `gsi.strategy_tail_polls` | 决策阶段继续视觉轮询 | 8 | | `gsi.strategy_gsi_wait` | 视觉结束后等待本人 GSI 英雄 | 3.0 | +| `gsi.dump_payloads` | 全量 GSI JSONL 落盘 | true | --- @@ -182,6 +189,7 @@ climperor/ ``` Dota 2 (-gamestateintegration) → POST → gsi_watch.py :3223 + → samples/raw/{matchid}/gsi.jsonl(可选) → DraftSession 轮询 recognize + roles/grid/modes → results/draft_*.json ``` diff --git a/README.md b/README.md index af98a76..cec0224 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ python gsi_watch.py ``` 进入英雄选择后自动跟踪;凑齐 10 人或超时后写出 `results/draft_<时间戳>.json`(该目录已 gitignore)。 -会话截图按对局落在 `samples/raw//`。 +会话截图按对局落在 `samples/raw//`;默认同时把每包 GSI JSON 追加到同目录 `gsi.jsonl`(`--no-dump-gsi` 可关)。 > 主菜单收不到 GSI 是正常的:客户端**第一次载入对局后**才开始推送。看到 `[gsi] connected` 才算链路通。 diff --git a/capture.py b/capture.py index 917aa7c..685bbd2 100644 --- a/capture.py +++ b/capture.py @@ -10,6 +10,7 @@ Notes: - Frames are saved as PNG at native resolution, named cap_HHMMSS.png. """ +import json import sys import time from pathlib import Path @@ -19,6 +20,7 @@ import mss import numpy as np RAW_DIR = Path(__file__).parent / "samples" / "raw" +GSI_JSONL = "gsi.jsonl" def raw_dir_for_match(match_id: str | None = None) -> Path: @@ -32,6 +34,20 @@ def raw_dir_for_match(match_id: str | None = None) -> Path: return RAW_DIR / safe +def append_gsi_payload(match_id: str | None, payload: dict) -> Path: + """Append one full GSI POST body to samples/raw/{match_id}/gsi.jsonl. + + Each line is {"t": , "payload": }. + """ + out = raw_dir_for_match(match_id) + out.mkdir(parents=True, exist_ok=True) + path = out / GSI_JSONL + record = {"t": time.time(), "payload": payload} + with path.open("a", encoding="utf-8") as f: + f.write(json.dumps(record, ensure_ascii=False) + "\n") + return path + + def grab_frame(sct=None) -> np.ndarray: """Grab the primary monitor as a BGR image.""" if sct is None: diff --git a/config.json b/config.json index 254137a..b223ab6 100644 --- a/config.json +++ b/config.json @@ -119,7 +119,8 @@ "strategy_tail_polls": 8, "strategy_gsi_wait": 3.0, "capture_interval": 1.0, - "target_slots": 10 + "target_slots": 10, + "dump_payloads": true }, "calibrated_from": "draft_141704.png" } \ No newline at end of file diff --git a/gsi_watch.py b/gsi_watch.py index 59e6d31..63cfaa0 100644 --- a/gsi_watch.py +++ b/gsi_watch.py @@ -6,6 +6,8 @@ Usage: python gsi_watch.py --once # capture+recognize right now, no GSI python gsi_watch.py --port 3223 python gsi_watch.py --states HERO_SELECTION,STRATEGY_TIME + python gsi_watch.py --dump-gsi # force full GSI JSONL logging + python gsi_watch.py --no-dump-gsi # disable it When the game enters hero selection the watcher follows the whole draft, polling the screen and logging each pick as it is revealed (All Pick reveals @@ -14,6 +16,9 @@ which lane role you queued for. The result lands in results/draft_.json. If config.json has no calibrated slots yet the watcher runs in capture-only mode: it still saves frames to samples/raw// so you can calibrate from them. + +With gsi.dump_payloads (default on), every POST body is appended to +samples/raw//gsi.jsonl for later analysis. """ import json @@ -25,7 +30,7 @@ from pathlib import Path import mss -from capture import grab_frame, raw_dir_for_match, save_frame +from capture import append_gsi_payload, grab_frame, raw_dir_for_match, save_frame from common import ROOT, load_config, load_template_library from draft_session import HERO_SELECTION, DraftSession, describe, gsi_slot, loc from recognize import recognize_image @@ -52,6 +57,7 @@ class Watcher: gsi = cfg.get("gsi", {}) self.trigger_states = set(gsi.get("trigger_states", ["DOTA_GAMERULES_STATE_STRATEGY_TIME"])) self.interval = gsi.get("capture_interval", 1.0) + self.dump_payloads = bool(gsi.get("dump_payloads", True)) self.library = load_template_library() if calibrated else [] self.last_state: str | None = None @@ -59,7 +65,9 @@ class Watcher: self.handled_matches: set[str] = set() self.connected = False self.busy = threading.Lock() + self.dump_lock = threading.Lock() self.self_info: dict = {} + self._dump_announced: set[str] = set() def on_payload(self, payload: dict) -> None: if not self.connected: @@ -79,6 +87,8 @@ class Watcher: state = (payload.get("map") or {}).get("game_state") match_id = str((payload.get("map") or {}).get("matchid") or "no-match") self.last_match_id = match_id + if self.dump_payloads: + self._dump_gsi(match_id, payload) if state is None: # main menu / no active match if self.last_state is not None: @@ -108,6 +118,17 @@ class Watcher: return threading.Thread(target=self.track, args=(match_id, state, key), daemon=True).start() + def _dump_gsi(self, match_id: str, payload: dict) -> None: + """Persist the full POST body; announce the path once per match folder.""" + try: + with self.dump_lock: + path = append_gsi_payload(match_id, payload) + if match_id not in self._dump_announced: + self._dump_announced.add(match_id) + print(f"[gsi] dumping payloads -> {path}", flush=True) + except OSError as e: + print(f"[gsi] dump failed: {e}", flush=True) + def track(self, match_id: str, state: str, key: str) -> dict | None: """Follow the draft from here to the end, recording every reveal.""" if not self.busy.acquire(blocking=False): @@ -225,6 +246,10 @@ def main() -> None: print() watcher = Watcher(cfg, calibrated, verbose) + if "--dump-gsi" in args: + watcher.dump_payloads = True + if "--no-dump-gsi" in args: + watcher.dump_payloads = False if "--states" in args: names = args[args.index("--states") + 1].split(",") @@ -251,6 +276,7 @@ def main() -> None: ) print(f"listening on http://127.0.0.1:{port}/ (Ctrl+C to stop)") print(f"trigger states: {', '.join(sorted(watcher.trigger_states))}") + print(f"gsi dump : {'on -> samples/raw//gsi.jsonl' if watcher.dump_payloads else 'off'}") if calibrated: print(f"template library: {len(watcher.library)} entries") print("waiting for Dota 2 ... (needs -gamestateintegration launch option)")