diff --git a/CHANGELOG.md b/CHANGELOG.md index b89232b..23fa387 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to BOT-MMORPG-AI will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed +- Recordings no longer disappear from the Train tab: the bundled + `versions/0.01/1-collect_data.py` now honors the `--out` argument the + Tauri shell passes, so data lands in `datasets///` where + the dataset scanner looks (issues #57, #60, #63, #65). +- Training no longer crashes with + `Target size (... 39) must be the same as input size (... 29)` when + mouse recording is enabled. `2-train_model.py` now auto-detects the + output-head size from the dataset's action vector (29 without mouse, + 39 with), and `--num-actions` defaults to `0` (auto) (issue #64). + ## [1.0.0] - 2026-02-09 ### Added diff --git a/docs/installer/04-bug-index.md b/docs/installer/04-bug-index.md index 364191a..372171e 100644 --- a/docs/installer/04-bug-index.md +++ b/docs/installer/04-bug-index.md @@ -208,6 +208,38 @@ The PowerShell driver script computes the wrong driver path under PROD. not `$INSTDIR\resources\drivers\`). - **See:** `05-case-studies.md` Bug #5 (`9d8a16f`). +### "Recording saved" but no dataset appears in the Train tab + +The Rust shell spawns `1-collect_data.py` with +`--out /datasets//` so the recording lands where +`modelhub/tauri.py::_scan_datasets_fs` looks. The **legacy** +`versions/0.01/1-collect_data.py` had no argument parser, so it ignored +`--out` entirely and wrote to a bare `datasets/` folder (driven only by +`BOTMMO_OUTPUT_DIR`). Result: recording "succeeds" but the file lands +outside `datasets//` and never shows up in the UI. + +- **File:** `versions/0.01/1-collect_data.py` (`_resolve_output_dir`) +- **Fix:** parse `--out` (with `parse_known_args` so future flags don't + crash the recorder) and use it as the output dir, falling back to + `BOTMMO_OUTPUT_DIR` then the default. Keeps the writer, the session + bookkeeping, and `_scan_datasets_fs` all pointed at the same path. +- **Issues:** #57, #60, #63, #65. + +### Training crash: `Target size (... 39) must be the same as input size (... 29)` + +`BCEWithLogitsLoss` failed in `2-train_model.py` because the dataset's +action vector was 39 wide (29 keyboard+gamepad **plus** 10 mouse values +when mouse recording is on) while the model's output head was hard-sized +to 29 via the `--num-actions` default. + +- **File:** `versions/0.01/2-train_model.py` (`GameplayDataset.num_actions`, + `main()` head-size resolution) +- **Fix:** auto-detect the head size from the dataset + (`self.actions.shape[1]`) and default `--num-actions` to `0` (auto). A + positive `--num-actions` still overrides for advanced use. The model + head now always matches the recorded action width (29 or 39). +- **Issue:** #64. + ## UI-layer symptoms (frontend can't reach the backend) ### Notification "Dismiss" / "×" / "Later" buttons appear inert diff --git a/versions/0.01/1-collect_data.py b/versions/0.01/1-collect_data.py index f9a7b2c..ebb412d 100644 --- a/versions/0.01/1-collect_data.py +++ b/versions/0.01/1-collect_data.py @@ -2,6 +2,7 @@ import sys import time import signal +import argparse from pathlib import Path import cv2 @@ -188,8 +189,35 @@ def save_chunk(path: Path, data: list) -> None: print(f"[SAVED] {path} ({len(data)} samples)") -def main(): - out_path, idx = next_available_file(OUTPUT_DIR, FILE_PREFIX) +def _resolve_output_dir(argv=None) -> Path: + """Decide where to write the .npy chunks. + + Priority (highest first): + 1. ``--out PATH`` on the command line. The Tauri shell spawns us + with ``--out /datasets//`` so the + recording lands exactly where the Train tab's scanner + (``modelhub/tauri.py::_scan_datasets_fs``) looks. Before this + was honored, the flag was silently ignored and recordings + landed in a bare ``datasets/`` folder that never showed up in + the UI -- the "dataset not appearing after recording" bug + (issues #57, #60, #63, #65). + 2. ``BOTMMO_OUTPUT_DIR`` environment variable (legacy launcher). + 3. The default ``datasets/`` relative to the working directory. + + Unknown args are tolerated (parse_known_args) so a future caller can + pass extra flags without crashing the recorder mid-launch. + """ + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--out", "--output", dest="out", default=None) + args, _unknown = parser.parse_known_args(argv) + if args.out: + return Path(args.out) + return OUTPUT_DIR + + +def main(argv=None): + output_dir = _resolve_output_dir(argv) + out_path, idx = next_available_file(output_dir, FILE_PREFIX) # Mouse recording support (issues #21, #33, #36) mouse_enabled = os.getenv("BOTMMO_CAPTURE_MOUSE", "").lower() == "true" @@ -202,7 +230,7 @@ def main(): print(f"[Collector] CaptureRegion: {CAPTURE_REGION}") print(f"[Collector] Resolution : {RESOLUTION}") print(f"[Collector] Mouse Record : {'ENABLED' if mouse_enabled else 'DISABLED'}") - print(f"[Collector] Output Dir : {OUTPUT_DIR.resolve()}") + print(f"[Collector] Output Dir : {output_dir.resolve()}") print(f"[Collector] Output File : {out_path.name}") print("=================================================") if not mouse_enabled: @@ -346,7 +374,7 @@ def _handle_sigint(signum, frame): save_chunk(out_path, training_data) training_data = [] idx += 1 - out_path = OUTPUT_DIR / f"{FILE_PREFIX}-{idx}.npy" + out_path = output_dir / f"{FILE_PREFIX}-{idx}.npy" finally: # Save remainder so stopping from launcher doesn't lose data diff --git a/versions/0.01/2-train_model.py b/versions/0.01/2-train_model.py index 24d259f..5979f97 100644 --- a/versions/0.01/2-train_model.py +++ b/versions/0.01/2-train_model.py @@ -202,11 +202,23 @@ def _load_data(self): self.frames = np.array(all_frames) self.actions = np.array(all_actions, dtype=np.float32) + # Auto-detected width of the action vector. This is the single + # source of truth for sizing the model's output head. When mouse + # recording is enabled, collect_data appends 10 extra values + # (x, y, dx, dy, vx, vy, lmb, rmb, mmb, scroll) so the vector is + # 39 wide instead of 29 (keyboard=9 + gamepad=20). The trainer + # must size the head to match or BCEWithLogitsLoss raises + # "Target size ... must be the same as input size" (issue #64). + self.num_actions = ( + self.actions.shape[1] if self.actions.ndim == 2 else len(self.actions[0]) + ) + dt = time.time() - t0 LOG.info(f"[Dataset] Loaded {len(self.frames)} sample(s) in {dt:.1f}s") LOG.info(f"[Dataset] Skipped files: {skipped_files}, skipped items: {skipped_items}") LOG.info(f"[Dataset] Frame shape: {self.frames[0].shape}") LOG.info(f"[Dataset] Action shape: {self.actions[0].shape}") + LOG.info(f"[Dataset] Auto-detected num_actions: {self.num_actions}") def __len__(self) -> int: return max(0, len(self.frames) - self.seq_len + 1) @@ -430,7 +442,12 @@ def main(argv=None) -> int: ) parser.add_argument("--lr", type=float, default=1e-4, help="Learning rate") parser.add_argument("--seq-len", type=int, default=4, help="Sequence length for temporal models") - parser.add_argument("--num-actions", type=int, default=29, help="Number of output actions") + parser.add_argument( + "--num-actions", + type=int, + default=0, + help="Number of output actions (0 = auto-detect from data; issue #64)", + ) parser.add_argument("--val-split", type=float, default=0.1, help="Validation split ratio") parser.add_argument("--no-pretrained", action="store_true", help="Don't use pretrained weights") parser.add_argument("--cpu", action="store_true", help="Force CPU training") @@ -537,11 +554,22 @@ def main(argv=None) -> int: if val_size > 0 else None ) + # Resolve the output-head size. Prefer the value auto-detected from + # the dataset so the model head always matches the recorded action + # vector (29 keyboard+gamepad, or 39 when mouse recording is on). + # A positive --num-actions overrides it for advanced use. This fixes + # the "Target size ... must be the same as input size" crash (#64). + num_actions = args.num_actions if args.num_actions > 0 else dataset.num_actions + LOG.info(f"Action vector size: {num_actions}") + if num_actions > 29: + mouse_extra = num_actions - 29 + LOG.info(f" (keyboard=9 + gamepad=20 + mouse={mouse_extra})") + # Create model LOG.info(f"Creating model: {args.model}") model = get_model( args.model, - num_actions=args.num_actions, + num_actions=num_actions, temporal_frames=seq_len, pretrained=not args.no_pretrained, )