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
12 changes: 12 additions & 0 deletions kits/mario/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,18 @@ npm run dev # against a console at the same origin, or set a proxy for /a
npm run build # what the image serves at /kits/mario
```

### The package's configuration, objective and evidence

`plugin/config.yaml` is the reflex's configuration, versioned with the package: the instructions,
the gate, the encoder, the tunables the rendering reads (the enemy and gap horizons, the tall-wall
height, the measured take-off windows) and the objective (pass on `cleared`, a failure per life
lost, `level_x` as the locus, `observations/` as the evidence). The environment archives every
frame it shows under `observations/` beside the live `frame.jpg`, and `plugin/tools/evidence.py`
cuts that archive into a contact sheet around each failure. `plugin/ledger.jsonl` records every
version with its evidence and verdict. An outer harness (the `plugins/calibrate` package in this
repository) reads all of it to improve the configuration one version at a time; the design is
`docs/dual-loop.md` in the System One Harness repository.

## Credits

- The game is **Full Screen Mario**, played at [supermarioplay.com](https://supermarioplay.com/game/mario.html?v=1.0.1). Mario and its characters belong to Nintendo; this kit plays the page as a person would and ships none of the game's files.
Expand Down
29 changes: 29 additions & 0 deletions kits/mario/plugin/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# The Super Mario reflex's configuration: everything that shapes its decisions except the model
# and the game. Versioned with the package; the outer loop (docs/dual-loop.md in the harness repo)
# changes it one thing at a time, with the evidence in ledger.jsonl beside it.
version: 1
goal: "Play the level: keep running right, jump over enemies, gaps and pipes, hit the question blocks for coins, and get as far as you can."
instructions: |
You play a side-scrolling platform game as Mario, deciding several times a second. Each action sets the keys for the next moment and they stay set until you change them: run_right holds right and run; jump_right holds right, run and jump, and a jump held in the air goes again the moment Mario lands, so holding it hops him along; jump holds jump alone; walk_left holds left; wait lets every key go. Keep moving right. The state ends with a line beginning "Now:" that says what the measured facts call for at this moment; follow it.

Facts, measured on this game: a decision lands a fraction of a second after the state you read, and at a full run Mario covers 2 to 3 tiles in that time. A jump held for one decision clears 3 tiles, for two decisions 4 tiles; jump_right pressed again on the ground is a new jump, and held through a landing it is a new jump at once. A running jump lands about 9 tiles on. An enemy is jumped at a run with the take-off 1.5 to 4 tiles before it; under a row of blocks an earlier jump hits the blocks and drops Mario onto it, so when that take-off cannot be hit, stop with walk_left and jump_right when it is 1 to 2.5 tiles away, which always works. An enemy walking at a standing Mario is jumped with jump_right when it is 1 to 2.5 tiles away. An enemy on a ledge above walks off its edge and drops onto whoever runs under it: wait for it to come down, then jump_right over it. A pipe 4 tiles tall is cleared only at a full run, leaving the ground 2 to 3 tiles before it with the jump held for three decisions; from standing or at a jog it is never cleared, so if Mario is stopped at one, walk_left for four decisions, then run_right, then jump_right; but if an enemy is walking at his back, walking left meets it: stand and take jump (the standing jump) when it is 2 tiles behind him, it passes under, and only then walk_left. A step one tile tall is hopped with jump_right; a stair is a hop per step. Where the ground drops away ahead, jump_right from the edge lands past the drop, and walking off the edge lands in a slot with no way out. A gap is crossed by a running jump from its edge, and a jump still in the air with a gap coming is kept held so the next hop goes the moment Mario lands; a standing jump falls in; enemies waiting where the jump would land walk to the gap and fall in if Mario stands at the edge. A question block pays a coin when hit from below by a jump started 1 to 1.5 tiles before it at a run.

When Mario has just died, wait. Finish when the level is cleared. Escalate when Mario has no lives left.
gate: {read: 0.5, write: 0.7, destructive: 0.9}
encoder: {history_steps: 3}
tunables:
enemy_horizon_tiles: 16 # how far ahead an enemy is named
gap_horizon_tiles: 14 # how far ahead a gap is named and the jump timed
tall_wall_tiles: 4 # a wall this tall is cleared only at a full run
pipe_takeoff_tiles: [1.8, 3.4] # measured: where the jump over a 4-tile pipe leaves the ground
enemy_takeoff_tiles: [1.5, 6.5] # measured: where the jump over an enemy at a run may leave the ground, in the open
enemy_takeoff_under_blocks_tiles: [1.5, 4.0] # measured: under the block row, farther hits the blocks and drops onto it
archive_frames: true # keep every frame the page shows under observations/
objective:
pass: "cleared == true"
failure: "lives decreased"
metrics:
- {field: cleared, better: true}
- {field: level_x, better: higher}
locus: [level_x]
evidence: observations/
1 change: 1 addition & 0 deletions kits/mario/plugin/ledger.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"version": 1, "at": "2026-09-20", "change": "the released environment and instructions (starter-kit 28ce645)", "channel": "told,shown", "evidence": ["the manual loop of 2026-09-20: recordings and contact sheets of attempts 22 to 42"], "runs": ["local attempts 34, 41, 42; one hosted run on hr-test"], "metrics_before": null, "metrics_after": {"pass": 1.0, "failures": 4.3, "runs": 3}, "verdict": "kept", "note": "pipes cleared only at a full run from a measured take-off; the ground read from Mario's level; a held jump goes again on landing; the nearest thing ahead decides the advice"}
2 changes: 1 addition & 1 deletion kits/mario/plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "mario-env",
"version": "0.3.2",
"version": "0.4.0",
"description": "Full Screen Mario as an environment for the System One base: the game's state as literal text each step, the keys it listens to as actions, and the browser's frame for the page to show.",
"homepage": "https://github.com/HarnessRouter/starter-kit",
"license": "SEE LICENSE IN ../LICENSE.md"
Expand Down
55 changes: 45 additions & 10 deletions kits/mario/plugin/server/mario_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import asyncio
import base64
import json
import glob
import os
import pathlib
Expand All @@ -38,7 +39,25 @@
"so holding it hops him along; jump holds jump alone; walk_left holds left; wait lets every key go. The state says where Mario is, what he is doing, what is ahead and behind with distances in "
"tiles, and which keys are held. Keep moving right and jump over what is within one step. Finish when "
"the level is cleared. Escalate when Mario has no lives left.")
TALL = 4 # a wall this tall is cleared only at a run
TALL = 4 # a wall this tall is cleared only at a run (the default; config.yaml tunables override)
TUNABLES = {"enemy_horizon_tiles": 16, "gap_horizon_tiles": 14, "tall_wall_tiles": TALL, "pipe_takeoff_tiles": [1.8, 3.4],
"enemy_takeoff_tiles": [1.5, 6.5], "enemy_takeoff_under_blocks_tiles": [1.5, 4.0], "archive_frames": True}


def load_tunables() -> dict:
"""The package's config.yaml tunables over the defaults: the numbers the rendering reads, which
the outer loop changes one at a time (docs/dual-loop.md in the harness repo)."""
tun = dict(TUNABLES)
for cand in (os.environ.get("SYSTEMONE_CONFIG"), str(pathlib.Path(__file__).resolve().parents[1] / "config.yaml")):
if cand and os.path.exists(cand):
try:
import yaml
d = yaml.safe_load(open(cand)) or {}
tun.update({k: v for k, v in (d.get("tunables") or {}).items() if k in tun})
except Exception: # noqa: BLE001 - a bad file leaves the defaults
pass
break
return tun
# The frame the kit's page shows: Chrome's own screencast, a JPEG for every third frame the game
# draws (about twenty a second), each written whole to frame.jpg. The page reads that file.
SCREENCAST = {"format": "jpeg", "quality": 45, "maxWidth": 960, "maxHeight": 600, "everyNthFrame": 3}
Expand All @@ -63,7 +82,8 @@
if (!c.alive || c === player || c.title === undefined) return;
if (['Coin','Mushroom','FireFlower','Star','Vine','Text','Shell','Fireball'].indexOf(c.title) >= 0) return;
var dx = (c.left - p.right) / T, dy = (p.bottom - c.bottom) / T, back = (p.left - c.right) / T;
if (dx >= 0 && dx <= 16) enemies.push({kind: c.title, dx: Math.round(dx * 10) / 10, dy: Math.round(dy * 10) / 10, dir: (c.xvel || 0) < 0 ? 'toward' : 'away'});
var EH = (window.__s1tun && window.__s1tun.enemy_horizon_tiles) || 16;
if (dx >= 0 && dx <= EH) enemies.push({kind: c.title, dx: Math.round(dx * 10) / 10, dy: Math.round(dy * 10) / 10, dir: (c.xvel || 0) < 0 ? 'toward' : 'away'});
else if (back >= 0 && back <= 8) behind.push({kind: c.title, dx: Math.round(back * 10) / 10, dy: Math.round(dy * 10) / 10, dir: (c.xvel || 0) > 0 ? 'toward' : 'away'});
});
// the ground ahead as a profile from where Mario stands: each quarter tile, the highest surface
Expand Down Expand Up @@ -165,6 +185,9 @@ def __init__(self):
self._session = None
self._cdp = None
self.frame_path = workspace_root() / "frame.jpg"
self.tun = load_tunables()
self.archive = (workspace_root() / "observations") if self.tun.get("archive_frames") else None
self._archived = 0
self.started_at = time.time()
self.steps = 0
self.frames = 0
Expand Down Expand Up @@ -210,10 +233,21 @@ def _on_frame(self, ev: dict, session_id=None) -> None:
(a temp file renamed over it, so a reader never sees half a picture), and the frame is
acknowledged, without which Chrome stops sending."""
try:
data = base64.b64decode(ev["data"])
tmp = self.frame_path.with_suffix(".jpg.tmp")
tmp.write_bytes(base64.b64decode(ev["data"]))
tmp.write_bytes(data)
os.replace(tmp, self.frame_path)
self.frames += 1
if self.archive is not None and self._archived < 12000:
# the observation archive the outer loop reads: every frame the page showed, with
# its time, under observations/ (about 7 KB a frame, eighteen a second)
if self._archived == 0:
self.archive.mkdir(parents=True, exist_ok=True)
name = f"{self._archived:06d}.jpg"
(self.archive / name).write_bytes(data)
with open(self.archive / "frames.jsonl", "a") as f:
f.write(json.dumps({"t": round(time.time(), 3), "file": name}) + "\n")
self._archived += 1
except Exception: # noqa: BLE001 - a missed frame is a missed frame, never a failed step
pass
self._loop.create_task(self._cdp.cdp_client.send.Page.screencastFrameAck(
Expand Down Expand Up @@ -320,7 +354,7 @@ async def go():
if isinstance(st, dict) and st.get("ready"):
break
await asyncio.sleep(0.25)
await self._eval("window.unpause && unpause(); 'ok'")
await self._eval(f"window.__s1tun = {json.dumps(self.tun)}; window.unpause && unpause(); 'ok'")
try:
await self._screencast()
except Exception: # noqa: BLE001 - already running across the navigation
Expand Down Expand Up @@ -445,15 +479,15 @@ def _now(self, st: dict, reach: float, en: list, gaps: list, walls: list, blocks
things.append((walls[0]["dx"], "wall"))
if drops and drops[0]["dx"] <= 12:
things.append((drops[0]["dx"], "drop"))
if gaps and gaps[0]["dx"] <= 14:
if gaps and gaps[0]["dx"] <= self.tun["gap_horizon_tiles"]:
things.append((gaps[0]["dx"], "gap"))
if near is not None and near["dx"] <= 9:
things.append((near["dx"], "enemy"))
things.sort()
kind = things[0][1] if things else None
if kind == "wall":
w = walls[0]; w_next = w["dx"] - lag
if w["height"] >= TALL:
if w["height"] >= self.tun["tall_wall_tiles"]:
# measured: the pipe is cleared at near full speed (4.9 and up) with the jump held
# long; at a jog the apex is level with its top and the side stops him (recorded)
w = walls[0]; w_next = w["dx"] - lag
Expand All @@ -465,9 +499,10 @@ def _now(self, st: dict, reach: float, en: list, gaps: list, walls: list, blocks
return "too slow for the pipe from here: walk_left for two decisions, then run_right to full speed and jump_right at 2 to 3 tiles."
if not full:
return "run_right to full speed; jump_right when the pipe is about 5 tiles ahead and Mario is running flat out."
if 1.8 <= w_next <= 3.4:
lo_t, hi_t = self.tun["pipe_takeoff_tiles"]
if lo_t <= w_next <= hi_t:
return "jump_right now, and keep it held for three decisions: the pipe's take-off point is here."
if w_next < 1.8:
if w_next < lo_t:
return "jump_right now and hold it three decisions."
return "run_right toward the pipe; jump_right when it is about 5 tiles ahead at this speed."
if w_next <= 1.5:
Expand All @@ -486,7 +521,7 @@ def _now(self, st: dict, reach: float, en: list, gaps: list, walls: list, blocks
# when the next state would already be past it; under the blocks a miss is a death,
# so there the stop and the standing jump (8 of 8) take over instead
under_blocks = any(o["dx"] <= near["dx"] + 1 for o in overhead)
lo, hi = (1.5, 4.0) if under_blocks else (1.5, 6.5)
lo, hi = self.tun["enemy_takeoff_under_blocks_tiles"] if under_blocks else self.tun["enemy_takeoff_tiles"]
if lo <= takeoff <= hi:
return "jump_right now over the enemy: this is the take-off."
if takeoff > hi:
Expand Down Expand Up @@ -582,7 +617,7 @@ def _describe(self, st: dict) -> dict:
if walls:
w = walls[0]
need = ("a jump held for three decisions from a run, leaving the ground 2 to 3 tiles before it; from against it or from standing it is never cleared"
if w["height"] >= TALL else "a jump held for two decisions" if w["height"] >= 3 else "a hop (jump_right)" if w["height"] <= 1 else "a jump")
if w["height"] >= self.tun["tall_wall_tiles"] else "a jump held for two decisions" if w["height"] >= 3 else "a hop (jump_right)" if w["height"] <= 1 else "a jump")
parts.append(f"Wall ahead: {w['kind']} {w['dx']} tiles ahead, {w['height']} tiles tall; it takes {need}.")
else:
parts.append("No pipe or wall within 8 tiles.")
Expand Down
107 changes: 107 additions & 0 deletions kits/mario/plugin/tools/evidence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""The evidence renderer for this environment: a contact sheet around each failure.

evidence.py <trace.json> <observations dir> <out dir>

Reads the run's trace and the archived frames (observations/NNNNNN.jpg with frames.jsonl of
timestamps), finds each failure (a life lost), and writes failure_N.jpg: nine frames from one
second before the last live decision to a second and a half after, captioned with the step, the
action and the state. With Pillow missing it writes failure_N.html instead, the same frames as
images. The outer loop reads these; nothing here is specific to the calibration method.
"""
import json
import os
import re
import sys


def _frames(obs_dir):
idx = os.path.join(obs_dir, "frames.jsonl")
rows = []
if os.path.exists(idx):
for line in open(idx):
try:
d = json.loads(line)
rows.append((float(d["t"]), os.path.join(obs_dir, d["file"])))
except (ValueError, KeyError):
continue
else:
for name in sorted(os.listdir(obs_dir)):
m = re.match(r"^(\d+(?:\.\d+)?)\.jpg$", name)
if m:
rows.append((float(m.group(1)), os.path.join(obs_dir, name)))
rows.sort()
return rows


def _failures(trace):
steps = trace.get("steps") or []
prev = None
last_live = None
out = []
for st in steps:
f = ((st.get("result") or {}).get("fields") or {})
lives = f.get("lives")
if lives is not None and prev is not None and lives < prev:
src = last_live or st
out.append(src)
if lives is not None:
prev = lives
if f.get("lives") is not None and not f.get("restarting") and not f.get("dead"):
last_live = st
return out


def _nearest(frames, t):
best = None
for ft, path in frames:
if best is None or abs(ft - t) < abs(best[0] - t):
best = (ft, path)
return best


def main(argv):
trace_path, obs_dir, out_dir = argv[1], argv[2], argv[3]
trace = json.load(open(trace_path))
frames = _frames(obs_dir)
os.makedirs(out_dir, exist_ok=True)
fails = _failures(trace)
try:
from PIL import Image, ImageDraw
except ImportError:
Image = None
written = []
for n, st in enumerate(fails, 1):
t0 = float(st.get("started_at") or 0)
times = [t0 - 1.0 + i * 0.3125 for i in range(9)]
picks = [_nearest(frames, t) for t in times] if frames else []
text = str((st.get("result") or {}).get("text") or "")
cap = f"failure {n} before step {st.get('index')}: {st.get('action')}: {text[:200]}"
if Image and picks:
tiles = []
for ft, path in picks:
im = Image.open(path).convert("RGB")
im.thumbnail((400, 250))
tiles.append((ft - t0, im))
w, h = tiles[0][1].size
sheet = Image.new("RGB", (w * 3, (h + 22) * 3 + 24), "white")
d = ImageDraw.Draw(sheet)
d.text((6, 4), cap[:180], fill="black")
for i, (dt, im) in enumerate(tiles):
x, y = (i % 3) * w, 24 + (i // 3) * (h + 22)
sheet.paste(im, (x, y))
d.text((x + 4, y + h + 4), f"t{dt:+.1f}s", fill="black")
out = os.path.join(out_dir, f"failure_{n}.jpg")
sheet.save(out, quality=80)
else:
out = os.path.join(out_dir, f"failure_{n}.html")
imgs = "".join(f'<figure><img src="{os.path.relpath(p, out_dir)}" width="300"><figcaption>t{ft - t0:+.1f}s</figcaption></figure>' for ft, p in picks)
open(out, "w").write(f"<h3>{cap}</h3><div style='display:grid;grid-template-columns:repeat(3,1fr)'>{imgs}</div>")
written.append({"failure": n, "step": st.get("index"), "action": st.get("action"), "file": out, "text": text[:300]})
json.dump(written, open(os.path.join(out_dir, "failures.json"), "w"), indent=1)
print(f"{len(written)} failures rendered into {out_dir}")
return 0


if __name__ == "__main__":
sys.exit(main(sys.argv))
Loading
Loading