From 351eb63ad145f0b9ea6af24c9d4c7dcaeae45046 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 7 Sep 2026 22:10:14 +0000 Subject: [PATCH] fix: prefer compiled RIMAPI Assemblies for run metadata pin Probe RIMAPI_DLL_PATH, then the fork checkout Assemblies (1.6/1.5), then ../RIMAPI next to the RLE git toplevel, and only then Workshop. Resolve rimapi_fork_commit via RIMAPI_FORK_PATH or git show-toplevel so site-packages installs still find a sibling checkout. Co-authored-by: Jason --- .env.example | 7 ++ CLAUDE.md | 18 +++- CONTRIBUTING.md | 19 ++++ README.md | 2 +- src/rle/tracking/metadata.py | 135 ++++++++++++++++++----- tests/unit/test_metadata.py | 202 +++++++++++++++++++++++++++++++++++ 6 files changed, 354 insertions(+), 29 deletions(-) diff --git a/.env.example b/.env.example index 5e52c6a..cfd57b5 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,13 @@ OPENROUTER_API_KEY= # --- RIMAPI (RimWorld mod) --- RIMAPI_URL=http://localhost:8765 +# Run-summary pin (path + sha256 + fork commit). Workshop is not source of truth. +# AppSprout: point both at the compiled AppSprout-dev/RIMAPI checkout (rle-testing). +# Unset: probe $RIMAPI_FORK_PATH Assemblies, then ../RIMAPI next to the RLE git +# toplevel, then the Steam Workshop path as a last-resort OSS fallback. +# RIMAPI_DLL_PATH=/path/to/RIMAPI/1.6/Assemblies/RIMAPI.dll +# RIMAPI_FORK_PATH=/path/to/RIMAPI + # --- MCP HTTP host (coding-agent harnesses: grok-build, opencode, ...) --- # Default: bind 127.0.0.1, ephemeral port, advertise http://127.0.0.1:/mcp # Container-reachable (agent in Docker, RimWorld/RLE on the host — NOT --docker): diff --git a/CLAUDE.md b/CLAUDE.md index ad1a3f9..e31cf02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,13 +25,25 @@ git checkout rle-testing cd Source/RIMAPI dotnet build RimApi.csproj -c Release-1.6 -# Deploy DLL over Workshop install (close RimWorld first!) +# Deploy DLL over Workshop install (close RimWorld first!) so the *game* loads the fork. cp ../../1.6/Assemblies/RIMAPI.dll \ "C:/Steam/steamapps/workshop/content/294100/3593423732/1.6/Assemblies/RIMAPI.dll" ``` The upstream Workshop DLL is backed up as `RIMAPI.dll.upstream-backup` in the same folder. +**Pin (source of truth):** AppSprout runs hash the compiled checkout, not Workshop. +Set both in `.env` after the build (Workshop remains a last-resort metadata fallback only): + +```bash +RIMAPI_DLL_PATH=/path/to/RIMAPI/1.6/Assemblies/RIMAPI.dll +RIMAPI_FORK_PATH=/path/to/RIMAPI +``` + +`collect_metadata()` records `rimapi_dll_path`, `rimapi_dll_sha256`, and +`rimapi_fork_commit`. Probe order when env is unset: fork Assemblies (`1.6` then +`1.5`) → sibling `../RIMAPI` next to the RLE git toplevel → Workshop path. + ### RIMAPI gotchas - RIMAPI only starts serving **after the map loads** (not on the main menu) @@ -81,6 +93,8 @@ The `.env` file controls which LLM provider is used. Key fields: | `MODEL` | Model name as the provider expects it | `unsloth/nvidia-nemotron-3-nano-4b` | | `PROVIDER_BASE_URL` | API base URL (required for LM Studio and OpenRouter) | `http://localhost:1234/v1` | | `RIMAPI_URL` | RIMAPI mod URL | `http://localhost:8765` | +| `RIMAPI_DLL_PATH` | Compiled `RIMAPI.dll` to pin in run summaries (AppSprout SoT; not Workshop) | `/path/to/RIMAPI/1.6/Assemblies/RIMAPI.dll` | +| `RIMAPI_FORK_PATH` | AppSprout-dev/RIMAPI checkout root (`rle-testing`) for `rimapi_fork_commit` | `/path/to/RIMAPI` | | `MCP_CONTAINER_REACHABLE` | Bind MCP on `0.0.0.0:8766`, advertise `http://host.docker.internal:8766/mcp` for Docker agents (host RimWorld). Not `--docker`. | `true` | | `MCP_BIND_HOST` / `MCP_ADVERTISE_HOST` / `MCP_PORT` | Optional MCP listen overrides (also `--harness-opt mcp_*`) | `0.0.0.0` / `host.docker.internal` / `8766` | @@ -466,7 +480,7 @@ docker/ We contribute upstream to IlyaChichkov/RIMAPI. PRs #52-54, #60, #63, #65 all merged. -The `rle-testing` branch tracks upstream develop. We always build from `rle-testing` and deploy the DLL to the Workshop folder — this is our active development workflow. +The `rle-testing` branch tracks upstream develop. We always **build** from `rle-testing`. Copying that DLL into the Workshop folder is how RimWorld loads the fork; **Workshop is not source of truth** for run metadata. Pin `RIMAPI_DLL_PATH` + `RIMAPI_FORK_PATH` at the compiled checkout so summaries record that path, sha256, and fork commit. To restore the original Workshop DLL: rename `RIMAPI.dll.upstream-backup` back to `RIMAPI.dll` in `C:\Steam\steamapps\workshop\content\294100\3593423732\1.6\Assemblies\`. ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5d8e74b..67d846f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -72,6 +72,25 @@ python scripts/check_harness_boundary.py `--smoke-test` always uses mock LLMs. To exercise a real model against the fake game state, run a real harness with `--ticks` small against a live RIMAPI instead (below). +### RIMAPI DLL pin (run metadata) + +Live-run summaries record `rimapi_dll_path`, `rimapi_dll_sha256`, and +`rimapi_fork_commit`. **Steam Workshop is not the source of truth.** AppSprout +runs should pin the compiled [AppSprout-dev/RIMAPI](https://github.com/AppSprout-dev/RIMAPI) +checkout (`rle-testing`): + +```bash +# .env — compiled Assemblies, not the Workshop copy +RIMAPI_DLL_PATH=/path/to/RIMAPI/1.6/Assemblies/RIMAPI.dll +RIMAPI_FORK_PATH=/path/to/RIMAPI +``` + +When those are unset, metadata probes `{RIMAPI_FORK_PATH}/1.6/Assemblies/RIMAPI.dll` +(then `1.5`), then `../RIMAPI` next to `git rev-parse --show-toplevel` of the RLE +checkout, and only then the conventional Workshop path as a last-resort OSS +fallback. Copying a build into the Workshop folder is still how RimWorld loads +the fork; it is not what summaries should treat as SoT. + ### Live game test ```bash diff --git a/README.md b/README.md index 46e9e4b..043aca0 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ You need four things set up: 3. **Python 3.14+** with [uv](https://docs.astral.sh/uv/) 4. **Save file** — `rle_crashlanded_v1` in RimWorld's save folder (the scenario auto-loads it) -> **RIMAPI note:** The Workshop version may not have our contributed endpoints yet. See [CLAUDE.md](CLAUDE.md) for instructions on building and deploying our fork DLL. +> **RIMAPI note:** The Workshop version may not have our contributed endpoints yet. AppSprout source of truth is the compiled [AppSprout-dev/RIMAPI](https://github.com/AppSprout-dev/RIMAPI) checkout (`rle-testing`), not Steam Workshop. Set `RIMAPI_DLL_PATH` (the built `1.6/Assemblies/RIMAPI.dll`) and `RIMAPI_FORK_PATH` (the fork repo root) so run summaries record path, sha256, and fork commit. Unset, RLE probes those compiled Assemblies (fork env, then `../RIMAPI` next to the RLE git toplevel) and only then a Workshop path as a last-resort OSS fallback. See [CLAUDE.md](CLAUDE.md) for build/deploy steps. ### Verify diff --git a/src/rle/tracking/metadata.py b/src/rle/tracking/metadata.py index 337f186..c23129a 100644 --- a/src/rle/tracking/metadata.py +++ b/src/rle/tracking/metadata.py @@ -27,11 +27,16 @@ # baseline no longer banks free process points; weights redistributed. SCORING_VERSION = "1.2" -# Conventional install path for the RIMAPI Workshop mod we deploy our fork DLL -# over. Best-effort — if Steam lives elsewhere set the RIMAPI_DLL_PATH env var. -_RIMAPI_DLL_DEFAULT_PATH = Path( +# Last-resort OSS fallback only. AppSprout source of truth is the compiled +# checkout (AppSprout-dev/RIMAPI, branch rle-testing), not Steam Workshop. +# Prefer $RIMAPI_DLL_PATH / $RIMAPI_FORK_PATH, then a sibling ../RIMAPI build. +_RIMAPI_DLL_WORKSHOP_FALLBACK = Path( "C:/Steam/steamapps/workshop/content/294100/3593423732/1.6/Assemblies/RIMAPI.dll", ) +_RIMAPI_ASSEMBLY_RELATIVE = ( + Path("1.6") / "Assemblies" / "RIMAPI.dll", + Path("1.5") / "Assemblies" / "RIMAPI.dll", +) def collect_metadata( @@ -83,36 +88,114 @@ def file_sha256(path: Path | None) -> str | None: return None +def _env_path(name: str) -> Path | None: + raw = os.environ.get(name, "").strip() + return Path(raw).expanduser() if raw else None + + +def _git_show_toplevel(start: Path) -> Path | None: + try: + out = subprocess.check_output( + ["git", "-C", str(start), "rev-parse", "--show-toplevel"], + stderr=subprocess.DEVNULL, + text=True, + ).strip() + except (subprocess.CalledProcessError, FileNotFoundError, OSError): + return None + return Path(out) if out else None + + +def _rle_git_toplevel() -> Path | None: + """RLE checkout root via git, not ``Path(__file__).parents[3]``. + + ``__file__`` is under site-packages when RLE is installed into a venv, so + walking parents of this module does not find the repo. Prefer cwd, then + this file's directory (source checkouts), and ask git for the toplevel. + """ + for start in (Path.cwd(), Path(__file__).resolve().parent): + toplevel = _git_show_toplevel(start) + if toplevel is not None: + return toplevel + return None + + +def _rimapi_sibling_checkout() -> Path | None: + """``../RIMAPI`` next to the RLE git toplevel, when that directory exists.""" + toplevel = _rle_git_toplevel() + if toplevel is None: + return None + sibling = toplevel.parent / "RIMAPI" + return sibling if sibling.is_dir() else None + + +def _dlls_under_fork(fork_root: Path) -> tuple[Path, ...]: + return tuple(fork_root / relative for relative in _RIMAPI_ASSEMBLY_RELATIVE) + + +def _first_existing_file(*candidates: Path) -> Path | None: + for path in candidates: + if path.is_file(): + return path.resolve() + return None + + def _rimapi_dll_path() -> Path | None: - """Resolve the deployed RIMAPI DLL path (env override → Workshop default).""" - override = os.environ.get("RIMAPI_DLL_PATH") - if override: - candidate = Path(override) - return candidate if candidate.is_file() else None - return ( - _RIMAPI_DLL_DEFAULT_PATH if _RIMAPI_DLL_DEFAULT_PATH.is_file() else None - ) + """Resolve the RIMAPI DLL to hash for run metadata. + + Probe order (first existing file wins): + + 1. ``$RIMAPI_DLL_PATH`` — explicit pin; if set but missing, return None + (do not silently fall through to Workshop) + 2. ``$RIMAPI_FORK_PATH/{1.6,1.5}/Assemblies/RIMAPI.dll`` + 3. Sibling checkout ``{RLE git toplevel}/../RIMAPI/{1.6,1.5}/Assemblies/RIMAPI.dll`` + 4. Steam Workshop path — last-resort OSS fallback, not AppSprout SoT + """ + override = _env_path("RIMAPI_DLL_PATH") + if override is not None: + return override.resolve() if override.is_file() else None + + candidates: list[Path] = [] + fork_override = _env_path("RIMAPI_FORK_PATH") + if fork_override is not None: + candidates.extend(_dlls_under_fork(fork_override)) + + sibling = _rimapi_sibling_checkout() + if sibling is not None: + candidates.extend(_dlls_under_fork(sibling)) + + candidates.append(_RIMAPI_DLL_WORKSHOP_FALLBACK) + return _first_existing_file(*candidates) + + +def _rimapi_fork_candidates() -> list[Path]: + candidates: list[Path] = [] + override = _env_path("RIMAPI_FORK_PATH") + if override is not None: + candidates.append(override) + sibling = _rimapi_sibling_checkout() + if sibling is not None and sibling not in candidates: + candidates.append(sibling) + return candidates def _rimapi_fork_commit() -> str: """HEAD short SHA of the local RIMAPI fork checkout, if findable. - Honors $RIMAPI_FORK_PATH; otherwise checks the conventional sibling repo - location (../RIMAPI relative to this RLE checkout). Empty string when the - fork isn't reachable from the runtime environment. + Honors ``$RIMAPI_FORK_PATH``. Otherwise resolves a sibling ``../RIMAPI`` + from the RLE git toplevel (``git rev-parse --show-toplevel``), not from + ``Path(__file__).parents[3]``. Empty string when the fork isn't reachable. """ - override = os.environ.get("RIMAPI_FORK_PATH") - candidates = [Path(override)] if override else [] - candidates.append(Path(__file__).resolve().parents[3] / "RIMAPI") - for fork_path in candidates: - if (fork_path / ".git").exists(): - try: - return subprocess.check_output( - ["git", "-C", str(fork_path), "rev-parse", "--short", "HEAD"], - stderr=subprocess.DEVNULL, text=True, - ).strip() - except (subprocess.CalledProcessError, FileNotFoundError): - return "" + for fork_path in _rimapi_fork_candidates(): + if not (fork_path / ".git").exists(): + continue + try: + return subprocess.check_output( + ["git", "-C", str(fork_path), "rev-parse", "--short", "HEAD"], + stderr=subprocess.DEVNULL, + text=True, + ).strip() + except (subprocess.CalledProcessError, FileNotFoundError, OSError): + return "" return "" diff --git a/tests/unit/test_metadata.py b/tests/unit/test_metadata.py index 1a19672..bbf572d 100644 --- a/tests/unit/test_metadata.py +++ b/tests/unit/test_metadata.py @@ -3,8 +3,12 @@ from __future__ import annotations import hashlib +import subprocess from pathlib import Path +import pytest + +from rle.tracking import metadata as metadata_mod from rle.tracking.metadata import ( SCORING_VERSION, collect_metadata, @@ -79,3 +83,201 @@ def test_collect_metadata_dll_path_and_hash_pair_consistently() -> None: assert isinstance(digest, str) assert len(digest) == 64 int(digest, 16) # raises if not hex + + +def _write_dll(path: Path, payload: bytes = b"compiled-rimapi") -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(payload) + return path + + +def _init_git_repo(path: Path) -> str: + path.mkdir(parents=True, exist_ok=True) + subprocess.run( + ["git", "init", "-b", "main"], + cwd=path, + check=True, + capture_output=True, + text=True, + ) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], + cwd=path, + check=True, + capture_output=True, + text=True, + ) + subprocess.run( + ["git", "config", "user.name", "RLE Test"], + cwd=path, + check=True, + capture_output=True, + text=True, + ) + subprocess.run( + ["git", "config", "commit.gpgsign", "false"], + cwd=path, + check=True, + capture_output=True, + text=True, + ) + (path / "README").write_text("fork\n", encoding="utf-8") + subprocess.run( + ["git", "add", "README"], + cwd=path, + check=True, + capture_output=True, + text=True, + ) + subprocess.run( + ["git", "commit", "-m", "init"], + cwd=path, + check=True, + capture_output=True, + text=True, + ) + return subprocess.check_output( + ["git", "rev-parse", "--short", "HEAD"], + cwd=path, + text=True, + ).strip() + + +@pytest.fixture +def isolated_pin(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Clear pin env and disable real-checkout / Workshop probes.""" + monkeypatch.delenv("RIMAPI_DLL_PATH", raising=False) + monkeypatch.delenv("RIMAPI_FORK_PATH", raising=False) + monkeypatch.setattr(metadata_mod, "_rle_git_toplevel", lambda: None) + monkeypatch.setattr( + metadata_mod, + "_RIMAPI_DLL_WORKSHOP_FALLBACK", + tmp_path / "workshop-missing" / "RIMAPI.dll", + ) + return tmp_path + + +def test_dll_path_honors_explicit_env_over_compiled_and_workshop( + isolated_pin: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + pinned = _write_dll(isolated_pin / "pinned" / "RIMAPI.dll", b"explicit") + fork = isolated_pin / "fork" + _write_dll(fork / "1.6" / "Assemblies" / "RIMAPI.dll", b"fork-build") + workshop = _write_dll(isolated_pin / "workshop" / "RIMAPI.dll", b"workshop") + monkeypatch.setenv("RIMAPI_DLL_PATH", str(pinned)) + monkeypatch.setenv("RIMAPI_FORK_PATH", str(fork)) + monkeypatch.setattr(metadata_mod, "_RIMAPI_DLL_WORKSHOP_FALLBACK", workshop) + + found = metadata_mod._rimapi_dll_path() + assert found == pinned.resolve() + + +def test_dll_path_explicit_env_missing_does_not_fall_through( + isolated_pin: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + fork = isolated_pin / "fork" + _write_dll(fork / "1.6" / "Assemblies" / "RIMAPI.dll", b"fork-build") + workshop = _write_dll(isolated_pin / "workshop" / "RIMAPI.dll", b"workshop") + monkeypatch.setenv("RIMAPI_DLL_PATH", str(isolated_pin / "absent.dll")) + monkeypatch.setenv("RIMAPI_FORK_PATH", str(fork)) + monkeypatch.setattr(metadata_mod, "_RIMAPI_DLL_WORKSHOP_FALLBACK", workshop) + + assert metadata_mod._rimapi_dll_path() is None + + +def test_dll_path_prefers_fork_env_assemblies_over_sibling_and_workshop( + isolated_pin: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + fork = isolated_pin / "compiled-fork" + fork_dll = _write_dll(fork / "1.6" / "Assemblies" / "RIMAPI.dll", b"fork16") + sibling = isolated_pin / "RIMAPI" + _write_dll(sibling / "1.6" / "Assemblies" / "RIMAPI.dll", b"sibling") + workshop = _write_dll(isolated_pin / "workshop" / "RIMAPI.dll", b"workshop") + monkeypatch.setenv("RIMAPI_FORK_PATH", str(fork)) + monkeypatch.setattr(metadata_mod, "_rle_git_toplevel", lambda: isolated_pin / "RLE") + monkeypatch.setattr(metadata_mod, "_RIMAPI_DLL_WORKSHOP_FALLBACK", workshop) + (isolated_pin / "RLE").mkdir() + + assert metadata_mod._rimapi_dll_path() == fork_dll.resolve() + + +def test_dll_path_fork_env_falls_back_to_1_5_assemblies( + isolated_pin: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + fork = isolated_pin / "compiled-fork" + dll = _write_dll(fork / "1.5" / "Assemblies" / "RIMAPI.dll", b"fork15") + monkeypatch.setenv("RIMAPI_FORK_PATH", str(fork)) + + assert metadata_mod._rimapi_dll_path() == dll.resolve() + + +def test_dll_path_prefers_sibling_compiled_over_workshop( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + rle = tmp_path / "RLE" + fork = tmp_path / "RIMAPI" + sibling_dll = _write_dll(fork / "1.6" / "Assemblies" / "RIMAPI.dll", b"sibling") + workshop = _write_dll(tmp_path / "workshop" / "RIMAPI.dll", b"workshop") + _init_git_repo(rle) + monkeypatch.delenv("RIMAPI_DLL_PATH", raising=False) + monkeypatch.delenv("RIMAPI_FORK_PATH", raising=False) + monkeypatch.chdir(rle) + monkeypatch.setattr(metadata_mod, "_RIMAPI_DLL_WORKSHOP_FALLBACK", workshop) + + assert metadata_mod._rimapi_dll_path() == sibling_dll.resolve() + + +def test_dll_path_workshop_is_last_resort_only( + isolated_pin: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + workshop = _write_dll(isolated_pin / "workshop" / "RIMAPI.dll", b"workshop") + monkeypatch.setattr(metadata_mod, "_RIMAPI_DLL_WORKSHOP_FALLBACK", workshop) + + assert metadata_mod._rimapi_dll_path() == workshop.resolve() + + +def test_dll_path_returns_none_when_nothing_findable(isolated_pin: Path) -> None: + assert metadata_mod._rimapi_dll_path() is None + + +def test_fork_commit_honors_rimapi_fork_path( + isolated_pin: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + fork = isolated_pin / "compiled-fork" + sha = _init_git_repo(fork) + monkeypatch.setenv("RIMAPI_FORK_PATH", str(fork)) + + assert metadata_mod._rimapi_fork_commit() == sha + + +def test_fork_commit_resolves_sibling_via_rle_git_toplevel( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + rle = tmp_path / "RLE" + fork = tmp_path / "RIMAPI" + _init_git_repo(rle) + sha = _init_git_repo(fork) + monkeypatch.delenv("RIMAPI_DLL_PATH", raising=False) + monkeypatch.delenv("RIMAPI_FORK_PATH", raising=False) + monkeypatch.chdir(rle) + + assert metadata_mod._rimapi_fork_commit() == sha + + +def test_fork_commit_empty_when_fork_unreachable(isolated_pin: Path) -> None: + assert metadata_mod._rimapi_fork_commit() == "" + + +def test_collect_metadata_records_compiled_dll_pin( + isolated_pin: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + payload = b"appsprout-compiled" + dll = _write_dll(isolated_pin / "1.6" / "Assemblies" / "RIMAPI.dll", payload) + fork_sha = _init_git_repo(isolated_pin) + monkeypatch.setenv("RIMAPI_DLL_PATH", str(dll)) + monkeypatch.setenv("RIMAPI_FORK_PATH", str(isolated_pin)) + + md = collect_metadata() + assert md["rimapi_dll_path"] == str(dll.resolve()) + assert md["rimapi_dll_sha256"] == hashlib.sha256(payload).hexdigest() + assert md["rimapi_fork_commit"] == fork_sha