Skip to content

Commit c9b5147

Browse files
fix: stage pinned docker/saves into AppData before native game/load (#76)
Native POST /game/load reads RimWorld AppData Saves, not docker/saves. The YAML pin only hashed the repo mirror, so #75's Crashlanded seed (SimpleResearchBench + Smithing) could be ignored in favor of a stale April AppData file. Copy the canonical save when the live hash diverges and fail closed if the pin cannot be satisfied. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Jason <jkbennitt@users.noreply.github.com>
1 parent fa5b43f commit c9b5147

13 files changed

Lines changed: 603 additions & 61 deletions

File tree

‎.env.example‎

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

2525
# --- RIMAPI (RimWorld mod) ---
2626
RIMAPI_URL=http://localhost:8765
27+
# Optional override for RimWorld's Saves folder (native load staging).
28+
# Default: OS AppData / Application Support / ~/.config/unity3d/.../Saves
29+
# RLE_RIMWORLD_SAVES=
2730
# AppSprout runs: point these at the compiled fork checkout (not Workshop).
2831
# Summaries record rimapi_dll_path, rimapi_dll_sha256, and rimapi_fork_commit.
2932
# RIMAPI_DLL_PATH=../RIMAPI/1.6/Assemblies/RIMAPI.dll

‎CLAUDE.md‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Four things must be set up before RLE can run against a live game:
99
1. **RimWorld** — Steam install at `C:\Steam\steamapps\common\RimWorld\` (or wherever Steam is)
1010
2. **Harmony + RIMAPI mods** — Subscribe on Steam Workshop, then **enable both** in the in-game Mods menu. Load order: Harmony → Core → Royalty → RIMAPI. RIMAPI exposes REST API on `:8765` + SSE events.
1111
3. **LLM provider** — [LM Studio](https://lmstudio.ai/) (local, port 1234) or [OpenRouter](https://openrouter.ai/) (cloud)
12-
4. **Save file** — `rle_crashlanded_v1` save must exist in RimWorld's save folder (`C:\Users\<you>\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\Saves\`). The scenario auto-loads it.
12+
4. **Save file** — `rle_crashlanded_v1` is pinned in `docker/saves/` (YAML `save_sha256`). Native runs stage that file into RimWorld AppData Saves (`C:\Users\<you>\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\Saves\`) when the live hash diverges, then `POST /game/load` by name. Docker entrypoint already symlinks `/opt/saves`.
1313

1414
### RIMAPI mod setup (critical)
1515

@@ -272,11 +272,12 @@ Tick-specific priorities injected into all Felix agents (other harnesses get no
272272
### Save Loading + Item Setup
273273

274274
`run_scenario.py` automatically:
275-
1. Loads the scenario's save file (`rle_crashlanded_v1` seeds a built `SimpleResearchBench` at (128,136) and queues `Smithing`; the other five saves are derived from this base)
276-
2. Polls until game is ready (colonist_count > 0)
277-
3. Unforbids all starting items (via `POST /api/v1/things/set-forbidden`)
278-
4. Runs any `setup_commands` declared in the scenario YAML (spawn_pawn, spawn_item, change_weather, drop_pod)
279-
5. Unpauses game at speed 3 (if `--no-pause`)
275+
1. Native only: `ensure_live_save()` copies `docker/saves/<name>.rws` into RimWorld AppData Saves when the live SHA ≠ YAML `save_sha256` (fail closed). Docker already symlinks `/opt/saves`. Summaries record `live_save_sha256` + `live_save_copied`.
276+
2. Loads the scenario's save file (`rle_crashlanded_v1` seeds a built `SimpleResearchBench` at (128,136) and queues `Smithing`; the other five saves are derived from this base)
277+
3. Polls until game is ready (colonist_count > 0)
278+
4. Unforbids all starting items (via `POST /api/v1/things/set-forbidden`)
279+
5. Runs any `setup_commands` declared in the scenario YAML (spawn_pawn, spawn_item, change_weather, drop_pod)
280+
6. Unpauses game at speed 3 (if `--no-pause`)
280281

281282
### Regenerating scenario saves
282283

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ You need four things set up:
101101
1. **RimWorld** (Steam) with **Harmony** and **[RIMAPI](https://github.com/IlyaChichkov/RIMAPI)** mods subscribed and **enabled** in the Mods menu. Load order: Harmony → Core → (DLCs) → RIMAPI.
102102
2. **LLM provider** — [LM Studio](https://lmstudio.ai/) (local, free) or [OpenRouter](https://openrouter.ai/) (cloud)
103103
3. **Python 3.14+** with [uv](https://docs.astral.sh/uv/)
104-
4. **Save file** — `rle_crashlanded_v1` in RimWorld's save folder (the scenario auto-loads it). The Crashlanded seed includes a built `SimpleResearchBench` so research can leave the 7/31 starting floor.
104+
4. **Save file** — `rle_crashlanded_v1` is pinned in `docker/saves/` (YAML `save_sha256`). Native runs copy it into RimWorld's AppData Saves when the live hash diverges, then auto-load by name. The Crashlanded seed includes a built `SimpleResearchBench` so research can leave the 7/31 starting floor.
105105

106106
> **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

‎saves/README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,8 @@ gunzip -k rle_crashlanded_v1.rws.gz
3030
cp rle_crashlanded_v1.rws ~/.config/unity3d/Ludeon\ Studios/RimWorld\ by\ Ludeon\ Studios/Saves/
3131
```
3232

33-
`run_scenario.py` auto-loads the save by name — just make sure the `.rws` file exists in the saves folder.
33+
`run_scenario.py` auto-loads the save by name. Native (non-docker) runs
34+
stage `docker/saves/<name>.rws` into RimWorld's AppData Saves folder when
35+
the live file's SHA-256 does not match the scenario YAML pin, then fail
36+
closed if the copy cannot satisfy the pin. Docker entrypoint already
37+
symlinks `/opt/saves` into the container Saves folder.

‎scripts/create_scenario_saves.py‎

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,37 +21,17 @@
2121

2222
import argparse
2323
import asyncio
24-
import os
2524
import shutil
2625
import sys
2726
from pathlib import Path
2827
from typing import Any
2928

3029
from rle.rimapi.client import RimAPIClient
30+
from rle.scenarios.loader import default_rimworld_saves_dir
3131

3232
DOCKER_SAVES_DIR = Path(__file__).resolve().parent.parent / "docker" / "saves"
3333
BASE_SAVE = "rle_crashlanded_v1"
3434

35-
36-
def _default_rimworld_save_dir() -> Path:
37-
"""Return RimWorld's default save directory for the current OS."""
38-
if sys.platform == "win32":
39-
user_profile = Path(os.environ.get("USERPROFILE", ""))
40-
return (
41-
user_profile / "AppData" / "LocalLow" / "Ludeon Studios"
42-
/ "RimWorld by Ludeon Studios" / "Saves"
43-
)
44-
if sys.platform == "darwin":
45-
return (
46-
Path.home() / "Library" / "Application Support"
47-
/ "RimWorld" / "Saves"
48-
)
49-
# Linux/other
50-
return (
51-
Path.home() / ".config" / "unity3d" / "Ludeon Studios"
52-
/ "RimWorld by Ludeon Studios" / "Saves"
53-
)
54-
5535
# Colony center (from the base save)
5636
COLONY_X, COLONY_Z = 132, 137
5737

@@ -576,7 +556,7 @@ def main() -> None:
576556

577557
save_dir = (
578558
Path(args.save_dir) if args.save_dir
579-
else _default_rimworld_save_dir()
559+
else default_rimworld_saves_dir()
580560
)
581561
if not save_dir.exists():
582562
print(

‎scripts/run_benchmark.py‎

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from rle.orchestration.save_loader import load_save_and_settle
3131
from rle.rimapi.client import RimAPIClient
3232
from rle.scenarios.evaluator import ScenarioEvaluator
33-
from rle.scenarios.loader import list_scenarios
33+
from rle.scenarios.loader import LiveSavePinError, LiveSaveStatus, list_scenarios
3434
from rle.scenarios.schema import ScenarioConfig
3535
from rle.scoring.composite import CompositeScorer
3636
from rle.scoring.delta import PairedResult, print_paired_leaderboard
@@ -67,16 +67,44 @@ class RunError(RuntimeError):
6767
"""A harness could not be constructed for this run."""
6868

6969

70-
async def _load_save(client: RimAPIClient, config: RLEConfig, scenario: ScenarioConfig) -> bool:
71-
"""Load + settle the scenario save. Returns False when the run must be skipped."""
70+
async def _load_save(
71+
client: RimAPIClient,
72+
config: RLEConfig,
73+
scenario: ScenarioConfig,
74+
*,
75+
stage_live: bool = False,
76+
) -> tuple[bool, LiveSaveStatus | None]:
77+
"""Load + settle the scenario save.
78+
79+
Returns ``(ok, live_save)``. ``ok`` is False when the run must be skipped
80+
for a transient load failure. ``LiveSavePinError`` is not caught — a
81+
native pin miss fails closed instead of loading a stale AppData file.
82+
"""
7283
if not scenario.save_name:
73-
return True
84+
return True, None
7485
try:
75-
await load_save_and_settle(client, config.rimapi_url, scenario.save_name)
86+
loaded = await load_save_and_settle(
87+
client, config.rimapi_url, scenario.save_name,
88+
save_sha256=scenario.save_sha256,
89+
stage_live=stage_live,
90+
)
91+
except LiveSavePinError:
92+
raise
7693
except Exception as e:
7794
logger.warning("Could not load save %s: %s", scenario.save_name, e)
78-
return False
79-
return True
95+
return False, None
96+
if loaded.live_save is not None:
97+
logger.info(
98+
"live_save_sha256=%s copied=%s",
99+
loaded.live_save.live_save_sha256,
100+
loaded.live_save.copied,
101+
)
102+
print(
103+
f" live save {scenario.save_name}: "
104+
f"sha256={loaded.live_save.live_save_sha256} "
105+
f"copied={loaded.live_save.copied}",
106+
)
107+
return True, loaded.live_save
80108

81109

82110
def _harness_failed(event_log: EventLog | None, start_index: int) -> bool:
@@ -287,9 +315,17 @@ async def _run_ablation( # noqa: PLR0913
287315
for scenario in scenarios:
288316
for run_id in range(num_runs):
289317
run_label = f" (run {run_id + 1}/{num_runs})" if num_runs > 1 else ""
290-
if not use_mock_rimapi and not await _load_save(client, config, scenario):
291-
print(f" SKIP {scenario.name}{run_label} ({tag}): save load failed")
292-
continue
318+
if not use_mock_rimapi:
319+
loaded_ok, _live = await _load_save(
320+
client, config, scenario,
321+
stage_live=not args.docker,
322+
)
323+
if not loaded_ok:
324+
print(
325+
f" SKIP {scenario.name}{run_label} "
326+
f"({tag}): save load failed",
327+
)
328+
continue
293329

294330
print(f" {scenario.name}{run_label} ({tag})...")
295331
options = {**harness_options, "exclude_agent": exclude}
@@ -477,9 +513,15 @@ async def main(args: argparse.Namespace) -> None: # noqa: PLR0912, PLR0915
477513
for run_id in range(num_runs):
478514
run_label = f" (run {run_id + 1}/{num_runs})" if num_runs > 1 else ""
479515

480-
if not use_mock_rimapi and not await _load_save(client, config, scenario):
481-
print(f" SKIP {scenario.name}{run_label}: save load failed")
482-
continue
516+
live_save: LiveSaveStatus | None = None
517+
if not use_mock_rimapi:
518+
loaded_ok, live_save = await _load_save(
519+
client, config, scenario,
520+
stage_live=not args.docker,
521+
)
522+
if not loaded_ok:
523+
print(f" SKIP {scenario.name}{run_label}: save load failed")
524+
continue
483525

484526
print(
485527
f"\nRunning: {scenario.name} ({scenario.difficulty}) "
@@ -499,6 +541,9 @@ async def main(args: argparse.Namespace) -> None: # noqa: PLR0912, PLR0915
499541
except RunError as exc:
500542
exit_with_harness_error(exc)
501543
return
544+
if live_save is not None:
545+
result["live_save_sha256"] = live_save.live_save_sha256
546+
result["live_save_copied"] = live_save.copied
502547
results.append(result)
503548
if paired:
504549
paired.agent_scores.append(result["score"])
@@ -512,7 +557,11 @@ async def main(args: argparse.Namespace) -> None: # noqa: PLR0912, PLR0915
512557

513558
# Baseline run (reload same save, unmanaged colony)
514559
if paired is not None:
515-
if not await _load_save(client, config, scenario):
560+
reload_ok, _live = await _load_save(
561+
client, config, scenario,
562+
stage_live=not args.docker,
563+
)
564+
if not reload_ok:
516565
logger.warning("Could not reload save for baseline")
517566
print(f" baseline{run_label}...")
518567
baseline = await _run_scenario(

‎scripts/run_scenario.py‎

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from rle.rimapi.client import RimAPIClient
3131
from rle.rimapi.sse_client import RimAPISSEClient
3232
from rle.scenarios.evaluator import ScenarioEvaluator
33-
from rle.scenarios.loader import list_scenarios, load_scenario
33+
from rle.scenarios.loader import LiveSavePinError, list_scenarios, load_scenario
3434
from rle.scoring.composite import CompositeScorer
3535
from rle.scoring.recorder import TimeSeriesRecorder
3636
from rle.tracking.cost_tracker import create_cost_tracker, fetch_billed_costs
@@ -83,10 +83,17 @@ def _build_run_summary( # noqa: PLR0913
8383
cost_snapshot_dict: dict[str, object],
8484
event_summary_dict: dict[str, object] | None,
8585
billed_cost_dict: dict[str, object] | None = None,
86+
live_save_sha256: str | None = None,
87+
live_save_copied: bool | None = None,
8688
) -> dict[str, object]:
8789
"""Compose the per-scenario summary JSON (metadata + config + result)."""
8890
summary: dict[str, object] = {
89-
**collect_metadata(random_seed=args.seed, harness_describe=harness_describe),
91+
**collect_metadata(
92+
random_seed=args.seed,
93+
harness_describe=harness_describe,
94+
live_save_sha256=live_save_sha256,
95+
live_save_copied=live_save_copied,
96+
),
9097
"scenario": scenario_name,
9198
"scenario_save_name": scenario_save_name,
9299
"harness": harness_name,
@@ -210,17 +217,33 @@ async def main(args: argparse.Namespace) -> None:
210217
sse = RimAPISSEClient(config.rimapi_url)
211218
sse_task = asyncio.create_task(sse.listen())
212219

220+
live_save_sha256: str | None = None
221+
live_save_copied: bool | None = None
213222
async with RimAPIClient(config.rimapi_url) as client:
214-
# Load the scenario's save file for a consistent starting state
223+
# Load the scenario's save file for a consistent starting state.
224+
# Native path: stage docker/saves → AppData when the live hash ≠ pin
225+
# (Docker entrypoint already symlinks /opt/saves; this CLI is native).
215226
if scenario.save_name:
216227
print(f"Loading save: {scenario.save_name}")
217228
try:
218-
unforbid_count = await load_save_and_settle(
229+
loaded = await load_save_and_settle(
219230
client, config.rimapi_url, scenario.save_name,
231+
save_sha256=scenario.save_sha256,
232+
stage_live=True,
220233
)
221-
if unforbid_count:
222-
print(f"Unforbid {unforbid_count} items.")
234+
if loaded.live_save is not None:
235+
live_save_sha256 = loaded.live_save.live_save_sha256
236+
live_save_copied = loaded.live_save.copied
237+
print(
238+
f"Live save sha256={live_save_sha256} "
239+
f"copied={live_save_copied}",
240+
)
241+
if loaded.unforbid_count:
242+
print(f"Unforbid {loaded.unforbid_count} items.")
223243
print("Save loaded, game ready.")
244+
except LiveSavePinError as e:
245+
print(f"ERROR: Refusing to load save '{scenario.save_name}': {e}")
246+
raise SystemExit(1) from e
224247
except Exception as e:
225248
print(f"Warning: Could not load save '{scenario.save_name}': {e}")
226249
print("Continuing with current game state...")
@@ -353,6 +376,8 @@ async def main(args: argparse.Namespace) -> None:
353376
billed_cost_dict=(
354377
billed_report.model_dump() if billed_report else None
355378
),
379+
live_save_sha256=live_save_sha256,
380+
live_save_copied=live_save_copied,
356381
)
357382
summary_path = output_dir / f"{scenario_path.stem}_summary.json"
358383
summary_path.write_text(json.dumps(summary, indent=2, default=str))

‎src/rle/orchestration/save_loader.py‎

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212

1313
import asyncio
1414
import logging
15+
from dataclasses import dataclass
1516

1617
from rle.docker import wait_for_rimapi
1718
from rle.rimapi.client import RimAPIClient
19+
from rle.scenarios.loader import LiveSaveStatus, ensure_live_save
1820

1921
logger = logging.getLogger(__name__)
2022

@@ -25,20 +27,45 @@
2527
MAX_POLLS = 30
2628

2729

30+
@dataclass(frozen=True)
31+
class LoadSettleResult:
32+
"""Outcome of ``load_save_and_settle`` (unforbid count + optional staging)."""
33+
34+
unforbid_count: int
35+
live_save: LiveSaveStatus | None = None
36+
37+
2838
async def load_save_and_settle(
2939
client: RimAPIClient,
3040
rimapi_url: str,
3141
save_name: str,
3242
*,
3343
unforbid_items: bool = True,
3444
rimapi_timeout_s: float = 30.0,
35-
) -> int:
45+
save_sha256: str | None = None,
46+
stage_live: bool = False,
47+
) -> LoadSettleResult:
3648
"""Load ``save_name`` and block until the colony is stable.
3749
38-
Returns the number of starting items unforbidden (0 when disabled).
39-
Raises whatever ``load_game`` / ``wait_for_rimapi`` raise so callers can
40-
decide whether to skip the run.
50+
When ``stage_live`` is True and ``save_sha256`` is set (native path),
51+
copy ``docker/saves/<name>.rws`` into RimWorld AppData Saves if the live
52+
file does not already match the pin. Docker skips this — the entrypoint
53+
already symlinks ``/opt/saves``. Staging failures raise
54+
``LiveSavePinError`` (fail closed) before ``game/load``.
55+
56+
Returns ``LoadSettleResult`` (unforbid count + live-save status).
57+
Raises whatever ``load_game`` / ``wait_for_rimapi`` / staging raise so
58+
callers can decide whether to skip the run.
4159
"""
60+
live_save: LiveSaveStatus | None = None
61+
if stage_live and save_sha256:
62+
live_save = ensure_live_save(save_name, save_sha256)
63+
logger.info(
64+
"live_save_sha256=%s copied=%s path=%s",
65+
live_save.live_save_sha256,
66+
live_save.copied,
67+
live_save.live_path,
68+
)
4269
await client.load_game(save_name)
4370
await wait_for_rimapi(rimapi_url, timeout=rimapi_timeout_s)
4471
stable_count = 0
@@ -60,6 +87,6 @@ async def load_save_and_settle(
6087
else:
6188
logger.warning("Save %s never reported a stable population; continuing", save_name)
6289
if not unforbid_items:
63-
return 0
90+
return LoadSettleResult(unforbid_count=0, live_save=live_save)
6491
count = await client.unforbid_all_items()
65-
return int(count or 0)
92+
return LoadSettleResult(unforbid_count=int(count or 0), live_save=live_save)

0 commit comments

Comments
 (0)