diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..bc6d888
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+.venv/*
+.diambra/credentials
+logs/agentm.log
+*.pyc
+agentm/data/agentM.db
diff --git a/README.md b/README.md
index 87a90bb..1b2dd7c 100644
--- a/README.md
+++ b/README.md
@@ -1,92 +1,92 @@
-
-
-
-
-๐น๏ธ Agent M
-A DIAMBRA Utility by mscrnt
-
----
-
-**Agent M** is a retro-styled launcher for [DIAMBRA Arena](https://www.diambra.ai/), designed to make agent training and packaging effortless. It wraps the official `diambra` CLI with a powerful terminal UI (TUI) powered by [Textual](https://textual.textualize.io/), giving both beginners and power users a guided experience for environment setup, wrapper configuration, and agent submission.
-
----
-
-## ๐ Features
-
-- ๐ฆ **Project scaffolding**: Quickly generate ready-to-train agent folders
-- ๐ฎ **Environment launcher**: Easily configure and run DIAMBRA games
-- ๐ง **Training config wizard**: Guide users through wrappers, hyperparameters, and PPO settings
-- ๐ **Evaluation screen**: Run saved agents interactively
-- ๐ ๏ธ **Docker + submission helper**: Build, tag, and submit your agent
-- ๐ **Developer ID tracking**: All submissions include a unique developer ID (`mscrnt-0001`)
-
----
-
-## ๐ Folder Structure
-
-```text
-agent_m/
-โโโ agentm/
-โ โโโ __init__.py # Contains __version__, __developer_id__
-โ โโโ main.py # Entry point
-โ โโโ app.py # Textual App class
-โ โโโ views/ # Screens: home, config, training, eval
-โ โโโ components/ # Reusable widgets
-โ โโโ logic/ # Non-UI logic: Docker, config, submit
-โ โโโ assets/ # ASCII, icons, splash art
-โโโ logo.png # Project icon
-โโโ README.md
-โโโ requirements.txt
-โโโ pyproject.toml (optional)
-โโโ dist/ # Built .exe from PyInstaller
-````
-
----
-
-## ๐งฐ Requirements
-
-Install dependencies:
-
-```bash
-pip install -r requirements.txt
-```
-
-> Includes: `textual`, `rich`, `diambra-arena`, `pyyaml`, `typer`
-
----
-
-## โถ๏ธ Running Agent M
-
-```bash
-python -m agentm
-```
-
----
-
-## ๐ฆ Build as Executable
-
-Using [PyInstaller](https://pyinstaller.org/):
-
-```bash
-pyinstaller --onefile agentm/main.py --name agentm --add-data "agentm/assets:splash"
-```
-
-The final `.exe` will be located in the `dist/` directory.
-
----
-
-## ๐งพ Developer Attribution
-
-All agents submitted through Agent M include a hardcoded developer ID:
-
-```python
-__developer_id__ = "mscrnt-0001"
-```
-
-This allows future reward attribution or referral tracking.
-
----
-
-## ๐ฃ License
-
-[MIT](LICENSE) โ Created by [mscrnt](https://github.com/mscrnt)
+
+
+
+
+๐น๏ธ Agent M
+A DIAMBRA Utility by mscrnt
+
+---
+
+**Agent M** is a retro-styled launcher for [DIAMBRA Arena](https://www.diambra.ai/), designed to make agent training and packaging effortless. It wraps the official `diambra` CLI with a powerful terminal UI (TUI) powered by [Textual](https://textual.textualize.io/), giving both beginners and power users a guided experience for environment setup, wrapper configuration, and agent submission.
+
+---
+
+## ๐ Features
+
+- ๐ฆ **Project scaffolding**: Quickly generate ready-to-train agent folders
+- ๐ฎ **Environment launcher**: Easily configure and run DIAMBRA games
+- ๐ง **Training config wizard**: Guide users through wrappers, hyperparameters, and PPO settings
+- ๐ **Evaluation screen**: Run saved agents interactively
+- ๐ ๏ธ **Docker + submission helper**: Build, tag, and submit your agent
+- ๐ **Developer ID tracking**: All submissions include a unique developer ID (`mscrnt-0001`)
+
+---
+
+## ๐ Folder Structure
+
+```text
+agent_m/
+โโโ agentm/
+โ โโโ __init__.py # Contains __version__, __developer_id__
+โ โโโ main.py # Entry point
+โ โโโ app.py # Textual App class
+โ โโโ views/ # Screens: home, config, training, eval
+โ โโโ components/ # Reusable widgets
+โ โโโ logic/ # Non-UI logic: Docker, config, submit
+โ โโโ assets/ # ASCII, icons, splash art
+โโโ logo.png # Project icon
+โโโ README.md
+โโโ requirements.txt
+โโโ pyproject.toml (optional)
+โโโ dist/ # Built .exe from PyInstaller
+````
+
+---
+
+## ๐งฐ Requirements
+
+Install dependencies:
+
+```bash
+pip install -r requirements.txt
+```
+
+> Includes: `textual`, `rich`, `diambra-arena`, `pyyaml`, `typer`
+
+---
+
+## โถ๏ธ Running Agent M
+
+```bash
+python -m agentm
+```
+
+---
+
+## ๐ฆ Build as Executable
+
+Using [PyInstaller](https://pyinstaller.org/):
+
+```bash
+pyinstaller --onefile agentm/main.py --name agentm --add-data "agentm/assets:splash"
+```
+
+The final `.exe` will be located in the `dist/` directory.
+
+---
+
+## ๐งพ Developer Attribution
+
+All agents submitted through Agent M include a hardcoded developer ID:
+
+```python
+__developer_id__ = "mscrnt-0001"
+```
+
+This allows future reward attribution or referral tracking.
+
+---
+
+## ๐ฃ License
+
+[MIT](LICENSE) โ Created by [mscrnt](https://github.com/mscrnt)
diff --git a/agentm/__init__.py b/agentm/__init__.py
index e69de29..278c861 100644
--- a/agentm/__init__.py
+++ b/agentm/__init__.py
@@ -0,0 +1,11 @@
+from pathlib import Path
+
+__version__ = "0.1.0"
+__developer_id__ = "mscrnt-0001"
+
+# Always resolve to the root of the project (1 level up from /agentm/)
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+DIAMBRA_CREDENTIALS_PATH = PROJECT_ROOT / ".diambra" / "credentials"
+DIAMBRA_CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
+if not DIAMBRA_CREDENTIALS_PATH.exists():
+ DIAMBRA_CREDENTIALS_PATH.touch()
diff --git a/agentm/app.py b/agentm/app.py
index e69de29..19e0e73 100644
--- a/agentm/app.py
+++ b/agentm/app.py
@@ -0,0 +1,34 @@
+from textual.app import App
+from agentm.views.home import HomeView
+from agentm.views.login import LoginView
+from agentm import DIAMBRA_CREDENTIALS_PATH
+from agentm.utils.logger import log_with_caller
+from agentm.logic.db import initialize_database
+
+class AgentMApp(App):
+ CSS_PATH = "assets/styles.tcss"
+
+ def on_mount(self) -> None:
+ """Called when the app starts."""
+ log_with_caller("debug", "App mounted. Initializing database...")
+ initialize_database()
+
+ log_with_caller("debug", "Checking for credentials...")
+
+ if DIAMBRA_CREDENTIALS_PATH.exists():
+ token = DIAMBRA_CREDENTIALS_PATH.read_text().strip()
+
+ if token and len(token) > 10:
+ log_with_caller("info", "Found populated DIAMBRA credentials. Launching Home.")
+ self.push_screen(HomeView())
+ return
+ else:
+ log_with_caller("warning", "Token file exists but appears empty or malformed.")
+
+ log_with_caller("info", "No valid credentials found. Launching Login screen.")
+ self.push_screen(LoginView())
+
+ async def on_login_view_login_success(self, message: LoginView.LoginSuccess) -> None:
+ """Handle successful login event and switch to Home screen."""
+ log_with_caller("info", "Handling LoginSuccess event. Launching Home.")
+ self.push_screen(HomeView())
diff --git a/agentm/assets/game_images/doapp.jpg b/agentm/assets/game_images/doapp.jpg
new file mode 100644
index 0000000..f1375f9
Binary files /dev/null and b/agentm/assets/game_images/doapp.jpg differ
diff --git a/agentm/assets/game_images/kof98umh.jpg b/agentm/assets/game_images/kof98umh.jpg
new file mode 100644
index 0000000..592d85d
Binary files /dev/null and b/agentm/assets/game_images/kof98umh.jpg differ
diff --git a/agentm/assets/game_images/mvsc.jpg b/agentm/assets/game_images/mvsc.jpg
new file mode 100644
index 0000000..94acb72
Binary files /dev/null and b/agentm/assets/game_images/mvsc.jpg differ
diff --git a/agentm/assets/game_images/samsh5sp.jpg b/agentm/assets/game_images/samsh5sp.jpg
new file mode 100644
index 0000000..ccb8400
Binary files /dev/null and b/agentm/assets/game_images/samsh5sp.jpg differ
diff --git a/agentm/assets/game_images/sfiii3n.jpg b/agentm/assets/game_images/sfiii3n.jpg
new file mode 100644
index 0000000..00eb360
Binary files /dev/null and b/agentm/assets/game_images/sfiii3n.jpg differ
diff --git a/agentm/assets/game_images/soulclbr.jpg b/agentm/assets/game_images/soulclbr.jpg
new file mode 100644
index 0000000..fb5b784
Binary files /dev/null and b/agentm/assets/game_images/soulclbr.jpg differ
diff --git a/agentm/assets/game_images/tektagt.jpg b/agentm/assets/game_images/tektagt.jpg
new file mode 100644
index 0000000..79c699f
Binary files /dev/null and b/agentm/assets/game_images/tektagt.jpg differ
diff --git a/agentm/assets/game_images/umk3.jpg b/agentm/assets/game_images/umk3.jpg
new file mode 100644
index 0000000..551b185
Binary files /dev/null and b/agentm/assets/game_images/umk3.jpg differ
diff --git a/agentm/assets/game_images/xmvsf.jpg b/agentm/assets/game_images/xmvsf.jpg
new file mode 100644
index 0000000..3f058df
Binary files /dev/null and b/agentm/assets/game_images/xmvsf.jpg differ
diff --git a/agentm/assets/logo.txt b/agentm/assets/logo.txt
new file mode 100644
index 0000000..90c2f3b
--- /dev/null
+++ b/agentm/assets/logo.txt
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+ %@%%%%%@%#
+ #%%%%%%%%%%%%%%%%%%%%%%%%%
+ %%%%%%%%%%*++++++++++++%%%%%%%%%%@
+ %%%%%%%++++++++++++++++++++++++++%%%%%%%
+ %%%%%%++++++++++++++++++++++++++++++++++%%%%%@
+ %%%%%+++++++++++++++++++++++++++++++++++++++*%%%%%
+ %%%%%+++++++++@@@@@@@@@@@++++++++++++++++++++++++%%%%%
+ %%%%%+++++++++++@@@@@@@@@@@++++++++++++++++++++++++++%%%%@
+ %%%%++++++++++@@@@@@@@@@@@@@++*@@@@@@@@+++++++++++++++++%%%%
+ %%%%++++++++++++@@@@@@@@@@@@@@++@@@@@@@@@++++++++++++++++++%%%%#
+ %%%%+++++++++++++@@@@@@@@@@@@@@+=@@@@@@@@@+++@@@@@@+++++++++++%%%@
+ %%%%++++++++++++++@@@@@@@@@@@@@@++@@@@@@@@@+++@@@@@@++++++++++++%%%%
+ %%%%+++++++++++++++++++++++++++++++@@@@@@@@@++@@@@@@@++@@@@@@+++++%%%@
+ @%%%++++++%@@@@@@@@@@@@@@@@@@@@@@@+*@@@@@@@@@+*@@@@@@@+*@@@@@@@+++++%%%
+ %%%+++++++@@@@@@@@@@@@@@@@@@@@@@@++@@@@@@@@@@+*@@@@@@@++@@@@@@@++++++%%%
+ %%%%++++++*@@@@@@@@@@@@@@@@@@@@@@@++@@@@@@@@@@+*@@@@@@@+=@@@@@@@++++++%%%%
+ %%%+++++++@@@@@@@@@@@@@@@@@@@@@@@@++@@@@@@@@@@+*@@@@@@@+@@@@@@@@+++++++%%%
+ %%%%++++++=@@@@@@@@@@@@@@@@@@@@@@@@++@@@@@@@@@@++@@@@@@@+@@@@@@@@+++++++%%%@
+ %%%+++++++@@@@@@@@@@@@+++++++++++++++@@@@@@@@@@++@@@@@@@+@@@@@@@@+++++++#%%%
+ %%%++++++=@@@@@@@@@@@@+++@@@@@@@@@@@@@@@@@@@@@@+=@@@@@@%+@@@@@@@@++++++++%%%
+ %%%++++++@@@@@@@@@@@@@+++@@@@@@@@@@@@@@@@@@@@@++@@@@@@@++@@@@@@@@++++++++%%%
+ #%%%+++++@@@@@@@@@@@@@@++++++++*+++==@@@@@@@@=++@@@@@@@++@@@@@@@++++++++++%%%
+ %%%++++++@@@@@@@@@@@@@@@@@@@@@@@@@#++++++++++++++++++++++++++++++++++++++%%%
+ %%%+++++++@@@@@@@@@@@@@@@@@@@@@@@@@@+++@@@@@@@@@@@@@@@@@@@@@@@@++++++++++%%%
+ %%%+++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@++@@@@@@@@@@@@@@@@@@@@@@@+++++++++%%%%
+ %%%%++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@+@@@@@@@@@@@@@@@@@@@@@@@+++++++++%%%%
+ %%%+++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@+@@@@@@@@@@@@@@@@@@@@@@+++++++++%%%
+ %%%%++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@=++++++++%%%%
+ %%%++++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@++++++++%%%%
+ @%%%++++++++++++++%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@++++++++++%%%
+ %%%%+++++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@=++++++++++%%%@
+ %%%%++++++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+++++++++++%%%@
+ %%%%++++++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@++++++++++++%%%@
+ @%%%%++++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+++++++++++%%%%#
+ %%%%++++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@++++++++++%%%%
+ %%%%%++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@++++++++%%%%@
+ %%%%%*+++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@++++++%%%%%
+ %%%%%%+++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+++%%%%%%
+ %%%%%%%+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%%%%@
+ %%%%%%%%@@@@@@@@@@@@@@@@@@@@@@@@%%%%%%%%
+ @%%%%%%%%%%%@@@@@@@@@@%%%%%%%%%%%@
+ #%%%%%%%%%%%%%%%%%%%%%%%%#
+ *@%@@@@*
+
+
+
+
+
+
diff --git a/agentm/assets/styles.tcss b/agentm/assets/styles.tcss
new file mode 100644
index 0000000..0faa80e
--- /dev/null
+++ b/agentm/assets/styles.tcss
@@ -0,0 +1,150 @@
+/* === Global App Styles === */
+
+Screen {
+ background: #0e0e0e;
+ color: #f0f0f0;
+}
+
+/* === Resets === */
+
+* {
+ padding: 0 1;
+ border: none;
+}
+
+/* === Headers === */
+
+Header, .header {
+ dock: top;
+ height: 3;
+ content-align: center middle;
+ background: #1f1f1f;
+ color: #ed7d3a;
+ text-style: bold;
+ padding: 1 2;
+}
+
+/* === Buttons === */
+
+Button {
+ background: #1f1f1f;
+ color: #ed7d3a;
+ border: solid #ed7d3a;
+ padding: 1 2;
+ margin: 1;
+ content-align: center middle;
+}
+
+Button:hover {
+ background: #ed7d3a;
+ color: #0e0e0e;
+}
+
+Button:focus {
+ background: #2a2a2a;
+ border: solid #ed7d3a;
+}
+
+/* === Inputs === */
+
+Input {
+ background: #1f1f1f;
+ color: #f0f0f0;
+ border: solid #3a9bed;
+}
+
+Input:focus {
+ background: #2a2a2a;
+ border: solid #ed7d3a;
+}
+
+/* === Alerts === */
+
+.success {
+ color: #4cd964;
+}
+
+.warning {
+ color: #ed7d3a;
+}
+
+.error {
+ color: red;
+}
+
+/* === Login Form === */
+
+#login_form {
+ layout: vertical;
+ align-horizontal: center;
+ align-vertical: middle;
+ width: 60%;
+ height: auto;
+ padding: 2 4;
+ border: solid #3a9bed;
+}
+
+#status_message {
+ color: #ed7d3a;
+ padding: 1;
+}
+
+#pw_row {
+ layout: horizontal;
+ padding: 0;
+}
+
+#pw_row > * {
+ margin-right: 1;
+}
+
+#pw_row > *:last-child {
+ margin-right: 0;
+}
+
+/* === Loading Overlay === */
+
+#loading_overlay {
+ dock: top;
+ background: #1f1f1f;
+ color: #f0f0f0;
+ padding: 1 2;
+ text-style: bold;
+ content-align: center middle;
+}
+
+/* === Game Layout === */
+
+.centered_layout {
+ layout: vertical;
+ align-horizontal: center;
+ align-vertical: middle;
+ padding: 2;
+ width: 100%;
+}
+
+.rom_rows_container {
+ layout: vertical;
+ align-horizontal: center;
+}
+
+.rom_row {
+ layout: horizontal;
+ align-horizontal: center;
+ align-vertical: middle;
+ padding: 1 2;
+ width: 100%;
+}
+
+.confirm_button {
+ align-horizontal: center;
+ align-vertical: top;
+ margin-top: 1;
+ width: auto;
+}
+
+.game_info {
+ padding: 1 2;
+ width: 100%;
+ color: #ed7d3a;
+}
diff --git a/agentm/logic/db.py b/agentm/logic/db.py
new file mode 100644
index 0000000..03ba7a2
--- /dev/null
+++ b/agentm/logic/db.py
@@ -0,0 +1,27 @@
+import sqlite3
+from pathlib import Path
+
+CACHE_DB_PATH = Path("agentm/data/agentM.db")
+CACHE_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
+
+def get_db_conn():
+ return sqlite3.connect(CACHE_DB_PATH)
+
+def initialize_database():
+ with get_db_conn() as conn:
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS roms (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL,
+ rom_file TEXT NOT NULL UNIQUE,
+ game_id TEXT NOT NULL,
+ sha256 TEXT,
+ difficulty_min INTEGER,
+ difficulty_max INTEGER,
+ characters TEXT,
+ keywords TEXT,
+ verified BOOLEAN NOT NULL DEFAULT 0,
+ verified_at TEXT
+ );
+ """)
+ conn.commit()
\ No newline at end of file
diff --git a/agentm/logic/db_functions.py b/agentm/logic/db_functions.py
new file mode 100644
index 0000000..fbcd433
--- /dev/null
+++ b/agentm/logic/db_functions.py
@@ -0,0 +1,120 @@
+import json
+from datetime import datetime
+from agentm.logic.db import get_db_conn
+
+
+def get_cached_rom(rom_file: str) -> dict | None:
+ """
+ Retrieve verified ROM metadata from the database by ROM filename.
+
+ Args:
+ rom_file: The filename of the ROM (e.g., 'sfiii3n.zip').
+
+ Returns:
+ A dictionary of ROM metadata if verified, otherwise None.
+ """
+ with get_db_conn() as conn:
+ cur = conn.execute("""
+ SELECT sha256, verified_at, title, game_id,
+ difficulty_min, difficulty_max, characters, keywords
+ FROM roms WHERE rom_file = ? AND verified = 1
+ """, (rom_file,))
+ row = cur.fetchone()
+
+ if row:
+ return {
+ "sha256": row[0],
+ "verified": True,
+ "verified_at": row[1],
+ "title": row[2],
+ "rom_file": rom_file,
+ "game_id": row[3],
+ "difficulty_min": row[4],
+ "difficulty_max": row[5],
+ "characters": json.loads(row[6]) if row[6] else [],
+ "keywords": json.loads(row[7]) if row[7] else [],
+ }
+ return None
+
+
+def get_all_verified_roms() -> list[dict]:
+ """
+ Return a list of all verified ROMs as dictionaries.
+
+ Returns:
+ A list of dictionaries containing ROM metadata.
+ """
+ with get_db_conn() as conn:
+ cur = conn.execute("""
+ SELECT sha256, verified_at, title, game_id, rom_file,
+ difficulty_min, difficulty_max, characters, keywords
+ FROM roms WHERE verified = 1
+ ORDER BY title ASC
+ """)
+ rows = cur.fetchall()
+
+ return [
+ {
+ "sha256": row[0],
+ "verified": True,
+ "verified_at": row[1],
+ "title": row[2],
+ "game_id": row[3],
+ "rom_file": row[4],
+ "difficulty_min": row[5],
+ "difficulty_max": row[6],
+ "characters": json.loads(row[7]) if row[7] else [],
+ "keywords": json.loads(row[8]) if row[8] else [],
+ }
+ for row in rows
+ ]
+
+
+def upsert_rom_record(
+ title: str,
+ rom_file: str,
+ game_id: str,
+ sha256: str,
+ difficulty_min: int = None,
+ difficulty_max: int = None,
+ characters: list[str] = None,
+ keywords: list[str] = None
+):
+ """
+ Insert or replace a verified ROM entry in the database.
+
+ Args:
+ title: Game title.
+ rom_file: ROM file name.
+ game_id: Game ID used by DIAMBRA.
+ sha256: SHA256 checksum of the ROM.
+ difficulty_min: Minimum difficulty.
+ difficulty_max: Maximum difficulty.
+ characters: List of characters.
+ keywords: List of keywords.
+ """
+ with get_db_conn() as conn:
+ conn.execute("""
+ INSERT OR REPLACE INTO roms (
+ title,
+ rom_file,
+ game_id,
+ sha256,
+ difficulty_min,
+ difficulty_max,
+ characters,
+ keywords,
+ verified,
+ verified_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
+ """, (
+ title,
+ rom_file,
+ game_id,
+ sha256,
+ difficulty_min,
+ difficulty_max,
+ json.dumps(characters or []),
+ json.dumps(keywords or []),
+ datetime.utcnow().isoformat()
+ ))
diff --git a/agentm/logic/diambra_login.py b/agentm/logic/diambra_login.py
new file mode 100644
index 0000000..8ebd1ec
--- /dev/null
+++ b/agentm/logic/diambra_login.py
@@ -0,0 +1,28 @@
+import requests
+from agentm import DIAMBRA_CREDENTIALS_PATH
+from agentm.utils.logger import log_with_caller
+
+LOGIN_API = "https://api.diambra.ai/api/auth/token/"
+
+def login_to_diambra(email: str, password: str) -> str:
+ """Log in to DIAMBRA and retrieve API token from /v1alpha1/token."""
+ payload = {
+ "username": email,
+ "password": password
+ }
+
+ response = requests.post("https://api.diambra.ai/api/v1alpha1/token", json=payload)
+ response.raise_for_status()
+
+ token = response.json().get("token")
+ if not token:
+ raise Exception("Login succeeded but no token returned.")
+
+ log_with_caller("info", f"Successfully retrieved DIAMBRA token: {token[:6]}...")
+ return token
+
+def save_diambra_token(token: str):
+ """Writes the token to the .diambra/credentials file with no trailing newline."""
+ DIAMBRA_CREDENTIALS_PATH.write_bytes(token.encode("utf-8"))
+ log_with_caller("info", f"Saved DIAMBRA token to: {DIAMBRA_CREDENTIALS_PATH}")
+
diff --git a/agentm/logic/roms.py b/agentm/logic/roms.py
new file mode 100644
index 0000000..ca59129
--- /dev/null
+++ b/agentm/logic/roms.py
@@ -0,0 +1,127 @@
+# -*- coding: utf-8 -*-
+"""
+agentm.logic.roms
+This module handles the verification and caching of ROM files for the AgentM application.
+It interacts with the DIAMBRA CLI to check ROM validity and updates the database accordingly.
+It also provides functions to extract game information from the DIAMBRA CLI output.
+"""
+
+import os
+import subprocess
+import re
+
+from agentm.logic.db_functions import get_cached_rom, upsert_rom_record, get_all_verified_roms
+from agentm.utils.logger import log_with_caller
+
+ROM_FOLDER = "agentm/roms"
+
+GAME_FILES = {
+ "Dead Or Alive ++": "doapp.zip",
+ "The King of Fighters '98 UM": "kof98umh.zip",
+ "Marvel vs. Capcom": "mvsc.zip",
+ "Samurai Shodown V Special": "samsh5sp.zip",
+ "Street Fighter III: 3rd Strike": "sfiii3n.zip",
+ "Soul Calibur": "soulclbr.zip",
+ "Tekken Tag Tournament": "tektagt.zip",
+ "Ultimate Mortal Kombat 3": "umk3.zip",
+ "X-Men vs. Street Fighter": "xmvsf.zip",
+}
+
+def get_image_path(rom_file: str) -> str:
+ """Returns relative image path for a given ROM zip."""
+ base_name = os.path.splitext(rom_file)[0]
+ return f"agentm/assets/game_images/{base_name}.jpg"
+
+def get_verified_roms():
+ verified = []
+
+ log_with_caller("debug", f"Starting ROM verification loop for {len(GAME_FILES)} files")
+
+ # Call `list-roms` once for efficiency
+ list_result = subprocess.run(["diambra", "arena", "list-roms"], capture_output=True, text=True)
+ full_list_output = list_result.stdout
+
+ for title, rom_file in GAME_FILES.items():
+ rom_path = os.path.join(ROM_FOLDER, rom_file)
+ log_with_caller("debug", f"Checking ROM path: {rom_path}")
+
+ if not os.path.exists(rom_path):
+ log_with_caller("warning", f"ROM not found: {rom_file} (expected at {rom_path})")
+ continue
+
+ cached = get_cached_rom(rom_file)
+ if cached and cached["verified"]:
+ log_with_caller("info", f"โ Cached ROM is valid: {title} ({rom_file})")
+ verified.append(cached)
+ continue
+
+ log_with_caller("debug", f"No valid cache found. Verifying with DIAMBRA CLI: {rom_file}")
+ result = subprocess.run(
+ ["diambra", "arena", "check-roms", os.path.abspath(rom_path)],
+ capture_output=True,
+ text=True
+ )
+
+ log_with_caller("debug", f"DIAMBRA output for {rom_file}:\n{result.stdout.strip()}")
+
+ if "Correct ROM file" in result.stdout:
+ sha256_match = re.search(r"sha256\s*=\s*([a-f0-9]{64})", result.stdout, re.IGNORECASE)
+ sha256 = sha256_match.group(1) if sha256_match else ""
+ game_id = rom_file.replace(".zip", "")
+
+ block = extract_game_block(full_list_output, game_id)
+ difficulty_min = extract_line_int(block, "Difficulty levels", index=0)
+ difficulty_max = extract_line_int(block, "Difficulty levels", index=1)
+ characters = extract_line_list(block, "Characters list")
+ keywords = extract_line_list(block, "Search keywords")
+
+ upsert_rom_record(
+ title=title,
+ rom_file=rom_file,
+ game_id=game_id,
+ sha256=sha256,
+ difficulty_min=difficulty_min,
+ difficulty_max=difficulty_max,
+ characters=characters,
+ keywords=keywords
+ )
+
+ log_with_caller("info", f"โ Verified and cached: {title} ({rom_file})")
+ else:
+ log_with_caller("error", f"โ Invalid ROM: {title} ({rom_file})")
+
+ verified = get_all_verified_roms()
+ log_with_caller("info", f"ROM verification completed: {len(verified)} valid game(s)")
+ return verified
+
+
+def extract_game_block(output: str, game_id: str) -> str:
+ """Extracts the block of text for the given game_id from `list-roms` output."""
+ lines = output.splitlines()
+ block = []
+ inside = False
+ for line in lines:
+ if f"game_id: {game_id.lower()}" in line.lower():
+ inside = True
+ elif inside and line.strip() == "":
+ break
+ if inside:
+ block.append(line)
+ return "\n".join(block)
+
+
+def extract_line_list(block: str, label: str) -> list[str]:
+ match = re.search(rf"{re.escape(label)}:\s*(\[[^\]]*\])", block)
+ if not match:
+ return []
+ try:
+ return eval(match.group(1), {"__builtins__": None}, {})
+ except Exception:
+ return []
+
+
+def extract_line_int(block: str, label: str, index: int = 0) -> int:
+ match = re.search(rf"{re.escape(label)}:\s*Min\s*(\d+)\s*-\s*Max\s*(\d+)", block)
+ if match:
+ return int(match.group(index + 1))
+ return 0
diff --git a/agentm/main.py b/agentm/main.py
index e69de29..e96eb69 100644
--- a/agentm/main.py
+++ b/agentm/main.py
@@ -0,0 +1,7 @@
+# agentm/main.py
+
+from agentm.app import AgentMApp
+
+if __name__ == "__main__":
+ app = AgentMApp()
+ app.run()
diff --git a/agentm/roms/doapp.zip b/agentm/roms/doapp.zip
new file mode 100644
index 0000000..ab234b8
Binary files /dev/null and b/agentm/roms/doapp.zip differ
diff --git a/agentm/roms/kof98umh.zip b/agentm/roms/kof98umh.zip
new file mode 100644
index 0000000..c57a085
Binary files /dev/null and b/agentm/roms/kof98umh.zip differ
diff --git a/agentm/roms/mvsc.zip b/agentm/roms/mvsc.zip
new file mode 100644
index 0000000..2fe7b7e
Binary files /dev/null and b/agentm/roms/mvsc.zip differ
diff --git a/agentm/roms/qsound_hle.zip b/agentm/roms/qsound_hle.zip
new file mode 100644
index 0000000..2fa7829
Binary files /dev/null and b/agentm/roms/qsound_hle.zip differ
diff --git a/agentm/roms/samsh5sp.zip b/agentm/roms/samsh5sp.zip
new file mode 100644
index 0000000..356b3dd
Binary files /dev/null and b/agentm/roms/samsh5sp.zip differ
diff --git a/agentm/roms/sfiii3n.zip b/agentm/roms/sfiii3n.zip
new file mode 100644
index 0000000..911b6a6
Binary files /dev/null and b/agentm/roms/sfiii3n.zip differ
diff --git a/agentm/roms/soulclbr.zip b/agentm/roms/soulclbr.zip
new file mode 100644
index 0000000..1626ce6
Binary files /dev/null and b/agentm/roms/soulclbr.zip differ
diff --git a/agentm/roms/tektagt.zip b/agentm/roms/tektagt.zip
new file mode 100644
index 0000000..a366bd7
Binary files /dev/null and b/agentm/roms/tektagt.zip differ
diff --git a/agentm/roms/umk3.zip b/agentm/roms/umk3.zip
new file mode 100644
index 0000000..1e9e770
Binary files /dev/null and b/agentm/roms/umk3.zip differ
diff --git a/agentm/roms/xmvsf.zip b/agentm/roms/xmvsf.zip
new file mode 100644
index 0000000..8dbf2d2
Binary files /dev/null and b/agentm/roms/xmvsf.zip differ
diff --git a/agentm/utils/logger.py b/agentm/utils/logger.py
new file mode 100644
index 0000000..4b87646
--- /dev/null
+++ b/agentm/utils/logger.py
@@ -0,0 +1,81 @@
+import os
+import logging
+import inspect
+from logging.handlers import RotatingFileHandler
+from rich.logging import RichHandler
+
+DEFAULT_UNIFIED_LOGFILE = "logs/agentm.log"
+DEFAULT_LOG_LEVEL = os.environ.get("AGENTM_LOG_LEVEL", "DEBUG")
+
+
+def get_logger(name="AGENTM", level=None, max_bytes=5 * 1024 * 1024, backup_count=5):
+ logger = logging.getLogger(name)
+
+ if logger.handlers:
+ return logger # Already configured
+
+ level = level or DEFAULT_LOG_LEVEL
+ if isinstance(level, str):
+ level = getattr(logging, level.upper(), logging.INFO)
+
+ logger.setLevel(level)
+
+ # ๐ Rich Console Handler
+ console_handler = RichHandler(
+ rich_tracebacks=True,
+ markup=True,
+ show_time=True,
+ show_level=True,
+ show_path=False,
+ )
+ logger.addHandler(console_handler)
+
+ # ๐ก Unified File Logger
+ log_dir = os.getenv("AGENTM_LOG_DIR", "logs")
+ try:
+ os.makedirs(log_dir, exist_ok=True)
+ unified_path = os.path.join(log_dir, "agentm.log")
+ file_handler = RotatingFileHandler(unified_path, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8")
+ file_formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s", "%Y-%m-%d %H:%M:%S")
+ file_handler.setFormatter(file_formatter)
+ logger.addHandler(file_handler)
+ except Exception:
+ logger.warning("โ ๏ธ File logging disabled: could not create log directory or file.")
+
+ return logger
+
+
+def get_module_logger(level=None):
+ module_name = inspect.stack()[1].frame.f_globals.get("__name__", "unknown")
+ return get_logger(name="AGENTM", level=level) # Use unified logger name
+
+
+def log_with_caller(level: str, message: str):
+ stack = inspect.stack()
+
+ callee = stack[1]
+ callee_func = callee.function
+ callee_module = callee.frame.f_globals.get("__name__", "unknown")
+
+ caller_func = "unknown"
+ caller_module = "unknown"
+ for frame in stack[2:]:
+ if frame.function not in {"wrapper", "inner", ""}:
+ caller_func = frame.function
+ caller_module = frame.frame.f_globals.get("__name__", "unknown")
+ break
+
+ logger = get_logger("AGENTM")
+ full_message = (
+ f"{message} โ {callee_module}.{callee_func} "
+ f"โ called by {caller_module}.{caller_func}"
+ )
+ getattr(logger, level.lower())(full_message)
+
+
+def set_global_log_level(level: str):
+ resolved_level = getattr(logging, level.upper(), logging.INFO)
+ logging.getLogger().setLevel(resolved_level)
+ for name in logging.root.manager.loggerDict:
+ logging.getLogger(name).setLevel(resolved_level)
+ log_with_caller("info", f"๐ง Global log level set to {level.upper()}")
diff --git a/agentm/views/home.py b/agentm/views/home.py
index e69de29..ab3f2c7 100644
--- a/agentm/views/home.py
+++ b/agentm/views/home.py
@@ -0,0 +1,152 @@
+from textual.screen import Screen
+from textual.widgets import Static, Button
+from textual.containers import Vertical, Horizontal
+from textual.message import Message
+from textual.reactive import reactive
+from textual.widget import Widget
+import asyncio
+from math import ceil
+
+from agentm.utils.logger import log_with_caller
+from agentm.logic.roms import get_verified_roms, GAME_FILES
+
+
+class GameSelected(Message):
+ def __init__(self, sender: Widget, metadata: dict):
+ self.metadata = metadata
+ super().__init__(sender)
+
+
+class ProgressWidget(Widget):
+ message = reactive("Loading...")
+
+ def render(self) -> str:
+ return f"[bold cyan]{self.message}[/bold cyan]"
+
+
+class GameAccordion(Vertical):
+ def __init__(self, title: str, rom_file: str, metadata: dict, parent_view):
+ safe_id = rom_file.replace(".", "_").replace("-", "_")
+ super().__init__(id=f"accordion_{safe_id}")
+ self.title = title
+ self.rom_file = rom_file
+ self.safe_id = safe_id
+ self.metadata = metadata
+ self.parent_view = parent_view
+
+ def compose(self):
+ yield Button(f"{self.title}", id=f"btn_{self.safe_id}", classes="game_button")
+
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
+ if event.button.id == f"btn_{self.safe_id}":
+ await self.display_info()
+
+ async def display_info(self):
+ log_with_caller("debug", f"Showing shared info for {self.rom_file}")
+ meta = self.metadata
+
+ info_lines = [
+ f"[b]Title:[/b] {meta['title']}",
+ f"[b]Game ID:[/b] {meta['game_id']}",
+ f"[b]Difficulty:[/b] {meta.get('difficulty_min')} - {meta.get('difficulty_max')}",
+ f"[b]Characters:[/b] {', '.join(meta.get('characters', []))}",
+ f"[b]Keywords:[/b] {', '.join(meta.get('keywords', []))}",
+ f"[b]SHA256:[/b] {meta['sha256']}",
+ ]
+
+ self.parent_view.shared_info_box.update("\n".join(info_lines))
+ self.parent_view.shared_confirm_button.label = f"โ
Confirm {meta['title']}"
+ self.parent_view.selected_game = meta
+
+
+class HomeView(Screen):
+ BINDINGS = [("escape", "app.quit", "Quit")]
+
+ def compose(self):
+ self.logo = Static(
+ "[b bright_magenta]\n\n"
+ " โโโโโโ โโโโโโโ โโโโโโโโ โโโโ โโโ โโโโโโโโโ โโโโ โโโโ\n"
+ "โโโโโโโโ โโโโโโโโ โโโโโโโโ โโโโโ โโโ โโโโโโโโโ โโโโโ โโโโโ\n"
+ "โโโโโโโโ โโโ โโโโ โโโโโโ โโโโโโโโโ โโโ โโโโโโโโโโโ\n"
+ "โโโโโโโโ โโโ โโโ โโโโโโ โโโโโโโโโ โโโ โโโโโโโโโโโ\n"
+ "โโโ โโโ โโโโโโโโโ โโโโโโโโ โโโ โโโโโ โโโ โโโ โโโ โโโ\n"
+ "โโโ โโโ โโโโโโโ โโโโโโโโ โโโ โโโโ โโโ โโโ โโโ โโโ\n[/]",
+ classes="header",
+ expand=True,
+ )
+ self.progress_text = ProgressWidget()
+
+ yield Vertical(
+ self.logo,
+ Static("[bold green]LOADING...[/bold green]", expand=True),
+ self.progress_text,
+ id="loading_container"
+ )
+
+ async def on_mount(self):
+ log_with_caller("debug", "HomeView mounted. Starting ROM verification.")
+ self.selected_game = None
+ self.run_worker(self.run_verification, thread=True, exclusive=True, name="rom-verification")
+
+ def run_verification(self):
+ total = len(GAME_FILES)
+ verified_roms = get_verified_roms()
+
+ for idx, rom in enumerate(verified_roms, start=1):
+ self.app.call_from_thread(
+ lambda title=rom['title'], idx=idx: setattr(
+ self.progress_text, "message",
+ f"Processing {title} ({idx}/{total})"
+ )
+ )
+ import time
+ time.sleep(0.01)
+
+ self.app.call_from_thread(lambda: self.display_verified_roms(verified_roms))
+
+ async def display_verified_roms(self, verified_roms):
+ log_with_caller("info", f"ROM verification complete. Total: {len(verified_roms)}")
+ self.query_one("#loading_container").remove()
+
+ self.shared_info_box = Static("", id="game_info_box", classes="game_info")
+ self.shared_confirm_button = Button("โ
Confirm", id="confirm_button", classes="confirm_button")
+
+ await self.mount(
+ Vertical(
+ Static("๐ฎ Welcome to Agent M", classes="header"),
+ Static(
+ "This is an unofficial DIAMBRA launcher to help you easily train, evaluate, and submit RL agents for fighting games.\n\n"
+ "Verified ROMs are shown below. Click one to view game info and confirm selection.",
+ classes="body"
+ ),
+ Static(f"โ
Found {len(verified_roms)} valid ROM(s).", id="status_message"),
+ Vertical(
+ Horizontal(id="rom_row_1", classes="rom_row"),
+ Horizontal(id="rom_row_2", classes="rom_row"),
+ id="rom_rows",
+ classes="rom_rows_container"
+ ),
+ self.shared_info_box,
+ self.shared_confirm_button,
+ id="home_screen_container",
+ classes="centered_layout"
+ )
+ )
+
+ per_row = ceil(len(verified_roms) / 2)
+ rows = [verified_roms[:per_row], verified_roms[per_row:]]
+
+ for i, row in enumerate(rows, start=1):
+ rom_row = self.query_one(f"#rom_row_{i}", Horizontal)
+ for rom in row:
+ await rom_row.mount(GameAccordion(
+ title=rom["title"],
+ rom_file=rom["rom_file"],
+ metadata=rom,
+ parent_view=self
+ ))
+
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
+ if event.button.id == "confirm_button" and self.selected_game:
+ await self.app.push_screen("training", self.selected_game)
+
diff --git a/agentm/views/login.py b/agentm/views/login.py
new file mode 100644
index 0000000..e0e77a0
--- /dev/null
+++ b/agentm/views/login.py
@@ -0,0 +1,68 @@
+from textual.screen import Screen
+from textual.widgets import Input, Button, Static
+from textual.containers import Vertical, Horizontal
+from textual.message import Message
+from textual import events
+from rich.text import Text
+from agentm.logic.diambra_login import login_to_diambra, save_diambra_token
+from agentm.utils.logger import log_with_caller
+
+
+class LoginView(Screen):
+ class LoginSuccess(Message):
+ """Message indicating login success."""
+
+ def compose(self):
+ yield Vertical(
+ Static("๐ Login to DIAMBRA", classes="header"),
+ Input(placeholder="Email", id="email_input"),
+ Horizontal(
+ Input(placeholder="Password", password=True, id="password_input"),
+ Button("๐", id="toggle_pw", variant="primary"),
+ id="pw_row"
+ ),
+ Button("Login", id="login_button"),
+ Static("", id="status_message"),
+ Static(
+ Text.from_markup(
+ "Don't have an account? [bold blue link=https://old.dev.diambra.ai/register]Register here[/]"
+ ),
+ id="register_link"
+ ),
+ id="login_form"
+ )
+
+ def on_button_pressed(self, event: Button.Pressed) -> None:
+ if event.button.id == "toggle_pw":
+ password_input = self.query_one("#password_input", Input)
+ password_input.password = not password_input.password
+ password_input.refresh() # Force UI to update
+ return
+
+ if event.button.id == "login_button":
+ email_input = self.query_one("#email_input", Input)
+ password_input = self.query_one("#password_input", Input)
+ status = self.query_one("#status_message", Static)
+
+ email = email_input.value.strip()
+ password = password_input.value.strip()
+
+ if not email or not password:
+ status.update("[red]โ Email and password required.[/red]")
+ return
+
+ status.update("๐ Logging in...")
+ self.set_interval(0.1, lambda: self.perform_login(email, password), name="login_task")
+
+ def perform_login(self, email: str, password: str):
+ status = self.query_one("#status_message", Static)
+
+ try:
+ token = login_to_diambra(email, password)
+ save_diambra_token(token)
+ status.update("[green]โ
Login successful![/green]")
+ log_with_caller("info", "User logged in successfully.")
+ self.post_message(self.LoginSuccess())
+ except Exception as e:
+ status.update(f"[red]โ {str(e)}[/red]")
+ log_with_caller("error", f"Login failed: {e}")
diff --git a/requirements.txt b/requirements.txt
index e69de29..7a6e454 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -0,0 +1,10 @@
+textual
+rich
+typer
+diambra
+diambra-arena
+pyyaml
+stable-baselines3
+tensorboard
+requests
+sqlite3
\ No newline at end of file