Skip to content

Commit e940ef8

Browse files
fix: probe compiled RIMAPI DLL before Workshop (#70)
* fix: probe compiled RIMAPI DLL before Workshop Restore run-metadata pins to the compiled fork checkout. Probe RIMAPI_DLL_PATH, then RIMAPI_FORK_PATH, then the sibling of the RLE git toplevel, and only then the Workshop overlay. Fork commit uses the same env plus git toplevel sibling so site-packages installs still record the pin. Co-authored-by: Jason <jkbennitt@users.noreply.github.com> * test: give fork-commit fixtures distinct SHAs Identical marker files produced the same short hash, so the env-over-sibling assertion could not distinguish the two repos. Co-authored-by: Jason <jkbennitt@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Jason <jkbennitt@users.noreply.github.com>
1 parent 8bea8f6 commit e940ef8

7 files changed

Lines changed: 364 additions & 26 deletions

File tree

‎.env.example‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ OPENROUTER_API_KEY=
2424

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

2832
# --- MCP HTTP host (coding-agent harnesses: grok-build, opencode, ...) ---
2933
# Default: bind 127.0.0.1, ephemeral port, advertise http://127.0.0.1:<port>/mcp

‎CLAUDE.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ cp ../../1.6/Assemblies/RIMAPI.dll \
3232

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

35+
**AppSprout runs pin the compiled checkout, not Workshop.** Set both:
36+
37+
```bash
38+
RIMAPI_DLL_PATH=/path/to/RIMAPI/1.6/Assemblies/RIMAPI.dll
39+
RIMAPI_FORK_PATH=/path/to/RIMAPI
40+
```
41+
42+
`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).
43+
3544
### RIMAPI gotchas
3645

3746
- RIMAPI only starts serving **after the map loads** (not on the main menu)
@@ -81,6 +90,8 @@ The `.env` file controls which LLM provider is used. Key fields:
8190
| `MODEL` | Model name as the provider expects it | `unsloth/nvidia-nemotron-3-nano-4b` |
8291
| `PROVIDER_BASE_URL` | API base URL (required for LM Studio and OpenRouter) | `http://localhost:1234/v1` |
8392
| `RIMAPI_URL` | RIMAPI mod URL | `http://localhost:8765` |
93+
| `RIMAPI_DLL_PATH` | Compiled `RIMAPI.dll` for run metadata (preferred over Workshop) | `../RIMAPI/1.6/Assemblies/RIMAPI.dll` |
94+
| `RIMAPI_FORK_PATH` | RIMAPI fork checkout (DLL probe + `rimapi_fork_commit`) | `../RIMAPI` |
8495
| `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` |
8596
| `MCP_BIND_HOST` / `MCP_ADVERTISE_HOST` / `MCP_PORT` | Optional MCP listen overrides (also `--harness-opt mcp_*`) | `0.0.0.0` / `host.docker.internal` / `8766` |
8697

‎CONTRIBUTING.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ uv pip install git+https://github.com/AppSprout-dev/rle-harness-opencode
8888
python scripts/run_scenario.py crashlanded_survival --harness opencode --model openai/gpt-4o --ticks 10
8989
```
9090

91+
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).
92+
9193
### Testing a harness plugin
9294

9395
External plugins depend on RLE core and use `rle.testing`:

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ You need four things set up:
103103
3. **Python 3.14+** with [uv](https://docs.astral.sh/uv/)
104104
4. **Save file** — `rle_crashlanded_v1` in RimWorld's save folder (the scenario auto-loads it)
105105

106-
> **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.
106+
> **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.
107107
108108
### Verify
109109

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# RIMAPI path probe
2+
3+
Run metadata must pin the **compiled** AppSprout-dev/RIMAPI checkout, not the Steam Workshop DLL.
4+
5+
## Known pins
6+
7+
| Source | DLL SHA-256 prefix | Fork commit | Role |
8+
|--------|--------------------|-------------|------|
9+
| Compiled checkout (`../RIMAPI/1.6/Assemblies/RIMAPI.dll`) | `BFC9DD53…` | `b6c5003` | Source of truth |
10+
| Steam Workshop overlay | `73E659E8…` | — | Flash drift; not SoT |
11+
12+
Do not re-hash these pins to "discover" them. AppSprout runs set `RIMAPI_DLL_PATH` and `RIMAPI_FORK_PATH` to the compiled checkout.
13+
14+
## Probe order
15+
16+
`collect_metadata()` in `src/rle/tracking/metadata.py` records `rimapi_dll_path`, `rimapi_dll_sha256`, and `rimapi_fork_commit`.
17+
18+
DLL (first existing file wins):
19+
20+
1. `$RIMAPI_DLL_PATH`
21+
2. `$RIMAPI_FORK_PATH/1.6/Assemblies/RIMAPI.dll`
22+
3. Sibling of the RLE git toplevel: `../RIMAPI/1.6/Assemblies/RIMAPI.dll`
23+
4. Workshop path (optional last fallback)
24+
25+
Fork commit (first checkout with `.git` wins; empty string if none):
26+
27+
1. `$RIMAPI_FORK_PATH`
28+
2. Sibling of `git rev-parse --show-toplevel` (not only `Path(__file__).parents[3]`, which is empty from site-packages / `.venv`)
29+
30+
## Box draft
31+
32+
The requested operator memo path `/workspace/drafts/2026-09-07-rimapi-path-probe.md` is outside this repository and will be written separately.

‎src/rle/tracking/metadata.py‎

Lines changed: 114 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,12 @@
2727
# baseline no longer banks free process points; weights redistributed.
2828
SCORING_VERSION = "1.2"
2929

30-
# Conventional install path for the RIMAPI Workshop mod we deploy our fork DLL
31-
# over. Best-effort — if Steam lives elsewhere set the RIMAPI_DLL_PATH env var.
32-
_RIMAPI_DLL_DEFAULT_PATH = Path(
30+
# Compiled fork layout relative to a RIMAPI checkout root.
31+
_RIMAPI_DLL_RELATIVE = Path("1.6") / "Assemblies" / "RIMAPI.dll"
32+
33+
# Steam Workshop install we sometimes overlay. Last-resort fallback only —
34+
# Workshop is not source of truth (Flash can drift the Workshop DLL).
35+
_RIMAPI_DLL_WORKSHOP_FALLBACK = Path(
3336
"C:/Steam/steamapps/workshop/content/294100/3593423732/1.6/Assemblies/RIMAPI.dll",
3437
)
3538

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

8588

89+
def _is_packaged_install(path: Path) -> bool:
90+
"""True when *path* lives in a venv/site-packages install, not a checkout."""
91+
return "site-packages" in path.parts or ".venv" in path.parts
92+
93+
94+
def _checkout_root_from_source_file(file_path: Path) -> Path | None:
95+
"""RLE checkout root inferred from ``src/rle/tracking/metadata.py``.
96+
97+
Returns None for packaged installs (site-packages / .venv): ``parents[3]``
98+
is not the RLE repo in those layouts.
99+
"""
100+
if _is_packaged_install(file_path):
101+
return None
102+
try:
103+
candidate = file_path.resolve().parents[3]
104+
except IndexError:
105+
return None
106+
if (candidate / ".git").exists() or (candidate / "src" / "rle").is_dir():
107+
return candidate
108+
return None
109+
110+
111+
def _rle_checkout_root() -> Path | None:
112+
"""Git toplevel of the RLE checkout, if findable.
113+
114+
Prefers ``git rev-parse --show-toplevel`` from the process cwd (works when
115+
RLE is imported from site-packages/.venv but launched from the checkout).
116+
Falls back to ``__file__`` only when that path still looks like a source
117+
tree — not a packaged install.
118+
"""
119+
raw = _git("rev-parse", "--show-toplevel")
120+
if raw:
121+
return Path(raw)
122+
return _checkout_root_from_source_file(Path(__file__))
123+
124+
125+
def _sibling_rimapi_root() -> Path | None:
126+
"""``../RIMAPI`` next to the RLE checkout, or None if the checkout is unknown."""
127+
checkout = _rle_checkout_root()
128+
if checkout is None:
129+
return None
130+
return checkout.parent / "RIMAPI"
131+
132+
133+
def _first_existing_file(candidates: list[Path]) -> Path | None:
134+
seen: set[str] = set()
135+
for candidate in candidates:
136+
key = os.fspath(candidate)
137+
if key in seen:
138+
continue
139+
seen.add(key)
140+
if candidate.is_file():
141+
return candidate
142+
return None
143+
144+
145+
def _rimapi_dll_candidates() -> list[Path]:
146+
"""Deterministic DLL probe order. First existing file wins.
147+
148+
1. ``$RIMAPI_DLL_PATH``
149+
2. ``$RIMAPI_FORK_PATH/1.6/Assemblies/RIMAPI.dll``
150+
3. sibling checkout ``../RIMAPI/1.6/Assemblies/RIMAPI.dll``
151+
4. Workshop path (optional last fallback; not source of truth)
152+
"""
153+
candidates: list[Path] = []
154+
dll_override = os.environ.get("RIMAPI_DLL_PATH")
155+
if dll_override:
156+
candidates.append(Path(dll_override))
157+
fork_override = os.environ.get("RIMAPI_FORK_PATH")
158+
if fork_override:
159+
candidates.append(Path(fork_override) / _RIMAPI_DLL_RELATIVE)
160+
sibling = _sibling_rimapi_root()
161+
if sibling is not None:
162+
candidates.append(sibling / _RIMAPI_DLL_RELATIVE)
163+
candidates.append(_RIMAPI_DLL_WORKSHOP_FALLBACK)
164+
return candidates
165+
166+
86167
def _rimapi_dll_path() -> Path | None:
87-
"""Resolve the deployed RIMAPI DLL path (env override → Workshop default)."""
88-
override = os.environ.get("RIMAPI_DLL_PATH")
168+
"""Resolve the RIMAPI DLL, preferring a compiled fork over Workshop."""
169+
return _first_existing_file(_rimapi_dll_candidates())
170+
171+
172+
def _rimapi_fork_candidates() -> list[Path]:
173+
"""Fork checkout probe order. First path with a ``.git`` dir wins.
174+
175+
1. ``$RIMAPI_FORK_PATH``
176+
2. sibling of the RLE git toplevel (``../RIMAPI``), not only ``__file__``
177+
"""
178+
candidates: list[Path] = []
179+
override = os.environ.get("RIMAPI_FORK_PATH")
89180
if override:
90-
candidate = Path(override)
91-
return candidate if candidate.is_file() else None
92-
return (
93-
_RIMAPI_DLL_DEFAULT_PATH if _RIMAPI_DLL_DEFAULT_PATH.is_file() else None
94-
)
181+
candidates.append(Path(override))
182+
sibling = _sibling_rimapi_root()
183+
if sibling is not None:
184+
candidates.append(sibling)
185+
return candidates
95186

96187

97188
def _rimapi_fork_commit() -> str:
98189
"""HEAD short SHA of the local RIMAPI fork checkout, if findable.
99190
100-
Honors $RIMAPI_FORK_PATH; otherwise checks the conventional sibling repo
101-
location (../RIMAPI relative to this RLE checkout). Empty string when the
102-
fork isn't reachable from the runtime environment.
191+
Honors ``$RIMAPI_FORK_PATH`` and the sibling of the RLE git toplevel.
192+
Empty string when the fork isn't reachable from the runtime environment.
103193
"""
104-
override = os.environ.get("RIMAPI_FORK_PATH")
105-
candidates = [Path(override)] if override else []
106-
candidates.append(Path(__file__).resolve().parents[3] / "RIMAPI")
107-
for fork_path in candidates:
108-
if (fork_path / ".git").exists():
109-
try:
110-
return subprocess.check_output(
111-
["git", "-C", str(fork_path), "rev-parse", "--short", "HEAD"],
112-
stderr=subprocess.DEVNULL, text=True,
113-
).strip()
114-
except (subprocess.CalledProcessError, FileNotFoundError):
115-
return ""
194+
for fork_path in _rimapi_fork_candidates():
195+
if not (fork_path / ".git").exists():
196+
continue
197+
try:
198+
return subprocess.check_output(
199+
["git", "-C", str(fork_path), "rev-parse", "--short", "HEAD"],
200+
stderr=subprocess.DEVNULL,
201+
text=True,
202+
).strip()
203+
except (subprocess.CalledProcessError, FileNotFoundError):
204+
return ""
116205
return ""
117206

118207

0 commit comments

Comments
 (0)