Persist full GSI payloads to per-match JSONL for later analysis.

Default-on dump keeps every POST body under samples/raw/<matchid>/gsi.jsonl, with CLI overrides to disable when disk use is a concern.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-26 12:16:47 +08:00
co-authored by Cursor
parent 2fb298ebf1
commit 7dc3e515e9
7 changed files with 61 additions and 4 deletions
+2 -1
View File
@@ -30,7 +30,7 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
运行时产物(**勿提交**,见 `.gitignore`): 运行时产物(**勿提交**,见 `.gitignore`):
- `samples/raw/<matchid>/` — GSI 会话截图;手动 `capture.py` 可写在 `raw/` 根下 - `samples/raw/<matchid>/` — GSI 会话截图`gsi.jsonl`;手动 `capture.py` 可写在 `raw/` 根下
- `preview/` — 标定 / sheet 预览 - `preview/` — 标定 / sheet 预览
- `results/` — 每局 JSON - `results/` — 每局 JSON
- `failures/``--truth` 调试用错识裁切 - `failures/``--truth` 调试用错识裁切
@@ -39,6 +39,7 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
``` ```
Dota 2 GSI → gsi_watch.py (:3223) Dota 2 GSI → gsi_watch.py (:3223)
→ 全量 payload → samples/raw/<matchid>/gsi.jsonl(可关)
→ DraftSession 轮询截屏 → samples/raw/<matchid>/ → DraftSession 轮询截屏 → samples/raw/<matchid>/
→ recognize_image (CDN 模板 + 可选天梯遮罩) → recognize_image (CDN 模板 + 可选天梯遮罩)
→ roles / grid / modes(辅助) → roles / grid / modes(辅助)
+5
View File
@@ -4,6 +4,11 @@
## [Unreleased] ## [Unreleased]
### Added
- GSI 全量落盘:默认把每包 POST 追加到 `samples/raw/<matchid>/gsi.jsonl`
`gsi.dump_payloads` / `--dump-gsi` / `--no-dump-gsi`)。
### Changed ### Changed
- 项目定名:**上分帝** / **Climperor**(目录 `climperor`),由 `dota2-draft-vision` 迁出独立仓库。 - 项目定名:**上分帝** / **Climperor**(目录 `climperor`),由 `dota2-draft-vision` 迁出独立仓库。
+8
View File
@@ -76,6 +76,12 @@ Valve 已明确关闭普通玩家视角的实时 draft
GSI 会话写入 `samples/raw/{matchid}/`;手动 `capture.py` 仍写 `samples/raw/` 根目录。 GSI 会话写入 `samples/raw/{matchid}/`;手动 `capture.py` 仍写 `samples/raw/` 根目录。
结果 JSON 仍为 `results/draft_<时间戳>.json`(内含 `match_id`)。 结果 JSON 仍为 `results/draft_<时间戳>.json`(内含 `match_id`)。
**⑥ GSI 全量落盘**
`gsi.dump_payloads`(默认开)把每包 POST body 追加到 `samples/raw/{matchid}/gsi.jsonl`
行格式 `{"t": <unix>, "payload": <原文>}`。内容上限仍是 cfg 订阅字段 + 普通玩家视角;
不能靠落盘补出双方 pick。CLI`--dump-gsi` / `--no-dump-gsi`
--- ---
## 3. 关键实测(2026-07-25 ## 3. 关键实测(2026-07-25
@@ -174,6 +180,7 @@ climperor/
| `gsi.revise_gain` | 选人阶段改判所需分数增益 | 0.15 | | `gsi.revise_gain` | 选人阶段改判所需分数增益 | 0.15 |
| `gsi.strategy_tail_polls` | 决策阶段继续视觉轮询 | 8 | | `gsi.strategy_tail_polls` | 决策阶段继续视觉轮询 | 8 |
| `gsi.strategy_gsi_wait` | 视觉结束后等待本人 GSI 英雄 | 3.0 | | `gsi.strategy_gsi_wait` | 视觉结束后等待本人 GSI 英雄 | 3.0 |
| `gsi.dump_payloads` | 全量 GSI JSONL 落盘 | true |
--- ---
@@ -182,6 +189,7 @@ climperor/
``` ```
Dota 2 (-gamestateintegration) Dota 2 (-gamestateintegration)
→ POST → gsi_watch.py :3223 → POST → gsi_watch.py :3223
→ samples/raw/{matchid}/gsi.jsonl(可选)
→ DraftSession 轮询 recognize + roles/grid/modes → DraftSession 轮询 recognize + roles/grid/modes
→ results/draft_*.json → results/draft_*.json
``` ```
+1 -1
View File
@@ -34,7 +34,7 @@ python gsi_watch.py
``` ```
进入英雄选择后自动跟踪;凑齐 10 人或超时后写出 `results/draft_<时间戳>.json`(该目录已 gitignore)。 进入英雄选择后自动跟踪;凑齐 10 人或超时后写出 `results/draft_<时间戳>.json`(该目录已 gitignore)。
会话截图按对局落在 `samples/raw/<matchid>/` 会话截图按对局落在 `samples/raw/<matchid>/`;默认同时把每包 GSI JSON 追加到同目录 `gsi.jsonl``--no-dump-gsi` 可关)
> 主菜单收不到 GSI 是正常的:客户端**第一次载入对局后**才开始推送。看到 `[gsi] connected` 才算链路通。 > 主菜单收不到 GSI 是正常的:客户端**第一次载入对局后**才开始推送。看到 `[gsi] connected` 才算链路通。
+16
View File
@@ -10,6 +10,7 @@ Notes:
- Frames are saved as PNG at native resolution, named cap_HHMMSS.png. - Frames are saved as PNG at native resolution, named cap_HHMMSS.png.
""" """
import json
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
@@ -19,6 +20,7 @@ import mss
import numpy as np import numpy as np
RAW_DIR = Path(__file__).parent / "samples" / "raw" RAW_DIR = Path(__file__).parent / "samples" / "raw"
GSI_JSONL = "gsi.jsonl"
def raw_dir_for_match(match_id: str | None = None) -> Path: 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 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": <unix seconds>, "payload": <original body>}.
"""
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: def grab_frame(sct=None) -> np.ndarray:
"""Grab the primary monitor as a BGR image.""" """Grab the primary monitor as a BGR image."""
if sct is None: if sct is None:
+2 -1
View File
@@ -119,7 +119,8 @@
"strategy_tail_polls": 8, "strategy_tail_polls": 8,
"strategy_gsi_wait": 3.0, "strategy_gsi_wait": 3.0,
"capture_interval": 1.0, "capture_interval": 1.0,
"target_slots": 10 "target_slots": 10,
"dump_payloads": true
}, },
"calibrated_from": "draft_141704.png" "calibrated_from": "draft_141704.png"
} }
+27 -1
View File
@@ -6,6 +6,8 @@ Usage:
python gsi_watch.py --once # capture+recognize right now, no GSI python gsi_watch.py --once # capture+recognize right now, no GSI
python gsi_watch.py --port 3223 python gsi_watch.py --port 3223
python gsi_watch.py --states HERO_SELECTION,STRATEGY_TIME 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, 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 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_<ts>.json.
If config.json has no calibrated slots yet the watcher runs in capture-only If config.json has no calibrated slots yet the watcher runs in capture-only
mode: it still saves frames to samples/raw/<matchid>/ so you can calibrate from them. mode: it still saves frames to samples/raw/<matchid>/ so you can calibrate from them.
With gsi.dump_payloads (default on), every POST body is appended to
samples/raw/<matchid>/gsi.jsonl for later analysis.
""" """
import json import json
@@ -25,7 +30,7 @@ from pathlib import Path
import mss 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 common import ROOT, load_config, load_template_library
from draft_session import HERO_SELECTION, DraftSession, describe, gsi_slot, loc from draft_session import HERO_SELECTION, DraftSession, describe, gsi_slot, loc
from recognize import recognize_image from recognize import recognize_image
@@ -52,6 +57,7 @@ class Watcher:
gsi = cfg.get("gsi", {}) gsi = cfg.get("gsi", {})
self.trigger_states = set(gsi.get("trigger_states", ["DOTA_GAMERULES_STATE_STRATEGY_TIME"])) self.trigger_states = set(gsi.get("trigger_states", ["DOTA_GAMERULES_STATE_STRATEGY_TIME"]))
self.interval = gsi.get("capture_interval", 1.0) 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.library = load_template_library() if calibrated else []
self.last_state: str | None = None self.last_state: str | None = None
@@ -59,7 +65,9 @@ class Watcher:
self.handled_matches: set[str] = set() self.handled_matches: set[str] = set()
self.connected = False self.connected = False
self.busy = threading.Lock() self.busy = threading.Lock()
self.dump_lock = threading.Lock()
self.self_info: dict = {} self.self_info: dict = {}
self._dump_announced: set[str] = set()
def on_payload(self, payload: dict) -> None: def on_payload(self, payload: dict) -> None:
if not self.connected: if not self.connected:
@@ -79,6 +87,8 @@ class Watcher:
state = (payload.get("map") or {}).get("game_state") state = (payload.get("map") or {}).get("game_state")
match_id = str((payload.get("map") or {}).get("matchid") or "no-match") match_id = str((payload.get("map") or {}).get("matchid") or "no-match")
self.last_match_id = match_id self.last_match_id = match_id
if self.dump_payloads:
self._dump_gsi(match_id, payload)
if state is None: if state is None:
# main menu / no active match # main menu / no active match
if self.last_state is not None: if self.last_state is not None:
@@ -108,6 +118,17 @@ class Watcher:
return return
threading.Thread(target=self.track, args=(match_id, state, key), daemon=True).start() 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: def track(self, match_id: str, state: str, key: str) -> dict | None:
"""Follow the draft from here to the end, recording every reveal.""" """Follow the draft from here to the end, recording every reveal."""
if not self.busy.acquire(blocking=False): if not self.busy.acquire(blocking=False):
@@ -225,6 +246,10 @@ def main() -> None:
print() print()
watcher = Watcher(cfg, calibrated, verbose) 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: if "--states" in args:
names = args[args.index("--states") + 1].split(",") 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"listening on http://127.0.0.1:{port}/ (Ctrl+C to stop)")
print(f"trigger states: {', '.join(sorted(watcher.trigger_states))}") print(f"trigger states: {', '.join(sorted(watcher.trigger_states))}")
print(f"gsi dump : {'on -> samples/raw/<matchid>/gsi.jsonl' if watcher.dump_payloads else 'off'}")
if calibrated: if calibrated:
print(f"template library: {len(watcher.library)} entries") print(f"template library: {len(watcher.library)} entries")
print("waiting for Dota 2 ... (needs -gamestateintegration launch option)") print("waiting for Dota 2 ... (needs -gamestateintegration launch option)")