Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ OPENROUTER_API_KEY=

# --- RIMAPI (RimWorld mod) ---
RIMAPI_URL=http://localhost:8765
# AppSprout runs: point these at the compiled fork checkout (not Workshop).
# Summaries record rimapi_dll_path, rimapi_dll_sha256, and rimapi_fork_commit.
# RIMAPI_DLL_PATH=../RIMAPI/1.6/Assemblies/RIMAPI.dll
# RIMAPI_FORK_PATH=../RIMAPI

# --- MCP HTTP host (coding-agent harnesses: grok-build, opencode, ...) ---
# Default: bind 127.0.0.1, ephemeral port, advertise http://127.0.0.1:<port>/mcp
Expand Down
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ cp ../../1.6/Assemblies/RIMAPI.dll \

The upstream Workshop DLL is backed up as `RIMAPI.dll.upstream-backup` in the same folder.

**AppSprout runs pin the compiled checkout, not Workshop.** Set both:

```bash
RIMAPI_DLL_PATH=/path/to/RIMAPI/1.6/Assemblies/RIMAPI.dll
RIMAPI_FORK_PATH=/path/to/RIMAPI
```

`collect_metadata()` probes `RIMAPI_DLL_PATH` → `{RIMAPI_FORK_PATH}/1.6/Assemblies/RIMAPI.dll` → sibling `../RIMAPI/1.6/Assemblies/RIMAPI.dll` → Workshop last. Workshop is not source of truth. Run summaries record `rimapi_dll_path`, `rimapi_dll_sha256`, and `rimapi_fork_commit` (compiled pin: `BFC9DD53…` @ `b6c5003`; Workshop `73E659E8…` is Flash drift). See [docs/2026-09-07-rimapi-path-probe.md](docs/2026-09-07-rimapi-path-probe.md).

### RIMAPI gotchas

- RIMAPI only starts serving **after the map loads** (not on the main menu)
Expand Down Expand Up @@ -81,6 +90,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` for run metadata (preferred over Workshop) | `../RIMAPI/1.6/Assemblies/RIMAPI.dll` |
| `RIMAPI_FORK_PATH` | RIMAPI fork checkout (DLL probe + `rimapi_fork_commit`) | `../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` |

Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ uv pip install git+https://github.com/AppSprout-dev/rle-harness-opencode
python scripts/run_scenario.py crashlanded_survival --harness opencode --model openai/gpt-4o --ticks 10
```

AppSprout live runs set `RIMAPI_DLL_PATH` and `RIMAPI_FORK_PATH` to the compiled RIMAPI checkout (not Workshop). Summaries record that path, the DLL SHA, and the fork commit — see [CLAUDE.md](CLAUDE.md) and [docs/2026-09-07-rimapi-path-probe.md](docs/2026-09-07-rimapi-path-probe.md).

### Testing a harness plugin

External plugins depend on RLE core and use `rle.testing`:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. See [CLAUDE.md](CLAUDE.md) for instructions on building and deploying our fork DLL. AppSprout runs set `RIMAPI_DLL_PATH` and `RIMAPI_FORK_PATH` to the compiled checkout; summaries record that path, the DLL SHA, and the fork commit. Workshop is not source of truth.

### Verify

Expand Down
32 changes: 32 additions & 0 deletions docs/2026-09-07-rimapi-path-probe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# RIMAPI path probe

Run metadata must pin the **compiled** AppSprout-dev/RIMAPI checkout, not the Steam Workshop DLL.

## Known pins

| Source | DLL SHA-256 prefix | Fork commit | Role |
|--------|--------------------|-------------|------|
| Compiled checkout (`../RIMAPI/1.6/Assemblies/RIMAPI.dll`) | `BFC9DD53…` | `b6c5003` | Source of truth |
| Steam Workshop overlay | `73E659E8…` | — | Flash drift; not SoT |

Do not re-hash these pins to "discover" them. AppSprout runs set `RIMAPI_DLL_PATH` and `RIMAPI_FORK_PATH` to the compiled checkout.

## Probe order

`collect_metadata()` in `src/rle/tracking/metadata.py` records `rimapi_dll_path`, `rimapi_dll_sha256`, and `rimapi_fork_commit`.

DLL (first existing file wins):

1. `$RIMAPI_DLL_PATH`
2. `$RIMAPI_FORK_PATH/1.6/Assemblies/RIMAPI.dll`
3. Sibling of the RLE git toplevel: `../RIMAPI/1.6/Assemblies/RIMAPI.dll`
4. Workshop path (optional last fallback)

Fork commit (first checkout with `.git` wins; empty string if none):

1. `$RIMAPI_FORK_PATH`
2. Sibling of `git rev-parse --show-toplevel` (not only `Path(__file__).parents[3]`, which is empty from site-packages / `.venv`)

## Box draft

The requested operator memo path `/workspace/drafts/2026-09-07-rimapi-path-probe.md` is outside this repository and will be written separately.
139 changes: 114 additions & 25 deletions src/rle/tracking/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@
# 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(
# Compiled fork layout relative to a RIMAPI checkout root.
_RIMAPI_DLL_RELATIVE = Path("1.6") / "Assemblies" / "RIMAPI.dll"

# Steam Workshop install we sometimes overlay. Last-resort fallback only —
# Workshop is not source of truth (Flash can drift the Workshop DLL).
_RIMAPI_DLL_WORKSHOP_FALLBACK = Path(
"C:/Steam/steamapps/workshop/content/294100/3593423732/1.6/Assemblies/RIMAPI.dll",
)

Expand Down Expand Up @@ -83,36 +86,122 @@ def file_sha256(path: Path | None) -> str | None:
return None


def _is_packaged_install(path: Path) -> bool:
"""True when *path* lives in a venv/site-packages install, not a checkout."""
return "site-packages" in path.parts or ".venv" in path.parts


def _checkout_root_from_source_file(file_path: Path) -> Path | None:
"""RLE checkout root inferred from ``src/rle/tracking/metadata.py``.

Returns None for packaged installs (site-packages / .venv): ``parents[3]``
is not the RLE repo in those layouts.
"""
if _is_packaged_install(file_path):
return None
try:
candidate = file_path.resolve().parents[3]
except IndexError:
return None
if (candidate / ".git").exists() or (candidate / "src" / "rle").is_dir():
return candidate
return None


def _rle_checkout_root() -> Path | None:
"""Git toplevel of the RLE checkout, if findable.

Prefers ``git rev-parse --show-toplevel`` from the process cwd (works when
RLE is imported from site-packages/.venv but launched from the checkout).
Falls back to ``__file__`` only when that path still looks like a source
tree — not a packaged install.
"""
raw = _git("rev-parse", "--show-toplevel")
if raw:
return Path(raw)
return _checkout_root_from_source_file(Path(__file__))


def _sibling_rimapi_root() -> Path | None:
"""``../RIMAPI`` next to the RLE checkout, or None if the checkout is unknown."""
checkout = _rle_checkout_root()
if checkout is None:
return None
return checkout.parent / "RIMAPI"


def _first_existing_file(candidates: list[Path]) -> Path | None:
seen: set[str] = set()
for candidate in candidates:
key = os.fspath(candidate)
if key in seen:
continue
seen.add(key)
if candidate.is_file():
return candidate
return None


def _rimapi_dll_candidates() -> list[Path]:
"""Deterministic DLL probe order. First existing file wins.

1. ``$RIMAPI_DLL_PATH``
2. ``$RIMAPI_FORK_PATH/1.6/Assemblies/RIMAPI.dll``
3. sibling checkout ``../RIMAPI/1.6/Assemblies/RIMAPI.dll``
4. Workshop path (optional last fallback; not source of truth)
"""
candidates: list[Path] = []
dll_override = os.environ.get("RIMAPI_DLL_PATH")
if dll_override:
candidates.append(Path(dll_override))
fork_override = os.environ.get("RIMAPI_FORK_PATH")
if fork_override:
candidates.append(Path(fork_override) / _RIMAPI_DLL_RELATIVE)
sibling = _sibling_rimapi_root()
if sibling is not None:
candidates.append(sibling / _RIMAPI_DLL_RELATIVE)
candidates.append(_RIMAPI_DLL_WORKSHOP_FALLBACK)
return candidates


def _rimapi_dll_path() -> Path | None:
"""Resolve the deployed RIMAPI DLL path (env override → Workshop default)."""
override = os.environ.get("RIMAPI_DLL_PATH")
"""Resolve the RIMAPI DLL, preferring a compiled fork over Workshop."""
return _first_existing_file(_rimapi_dll_candidates())


def _rimapi_fork_candidates() -> list[Path]:
"""Fork checkout probe order. First path with a ``.git`` dir wins.

1. ``$RIMAPI_FORK_PATH``
2. sibling of the RLE git toplevel (``../RIMAPI``), not only ``__file__``
"""
candidates: list[Path] = []
override = os.environ.get("RIMAPI_FORK_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
)
candidates.append(Path(override))
sibling = _sibling_rimapi_root()
if sibling is not None:
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`` and the sibling of the RLE git toplevel.
Empty string when the fork isn't reachable from the runtime environment.
"""
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):
return ""
return ""


Expand Down
Loading
Loading