diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index daedbb0..1769f6e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,6 +22,12 @@ jobs:
steps:
- uses: actions/checkout@v4
+ - name: Setup Node for cross-language digest tests
+ uses: actions/setup-node@v7
+ with:
+ # Keep aligned with OpenLegged/BotLab's .nvmrc.
+ node-version: '24.21.0'
+
- name: Set up uv with Python ${{ matrix.python-version }}
uses: astral-sh/setup-uv@v5
with:
diff --git a/README.md b/README.md
index f730c79..142bd1d 100644
--- a/README.md
+++ b/README.md
@@ -113,6 +113,17 @@ resource preset fields.
| `botlab ui status` | Show the status of the bundled UI snapshot. |
| `botlab docs` | Print where the bundled docs (component reference, decision table, error guide) live in this install. |
+Resource names are identities: `Sensor(robot, name="Depth")` refers to the
+robot's declared ID even when `Robot(..., name="G1")` has a separate MJCF path.
+The CLI validates that reference without treating `G1` as a file.
+
+For MJCF robot and scene resources, validation collects XML includes and file
+assets (including compiler `meshdir`, `texturedir`, and `assetdir`) into the
+release asset inventory. Relative dependencies may live in sibling directories
+inside the project; missing files, cycles and dependencies escaping the project
+are reported before packaging. Native model transforms preserve that dependency
+layout, so terrain meshes do not need to be copied beneath the robot directory.
+
## Standalone site (`botlab build --site
`)
`botlab build --site docs/demo` writes a directory you can push to any static
@@ -122,7 +133,7 @@ host — GitHub Pages included — next to the `release.zip`:
docs/demo/
├── index.html ← the embed runtime entry, renamed
├── assets/, wasm/ ← the runtime closure scanned from the bundled UI snapshot
-├── project/ ← the whole project directory (robot MJCF, meshes, …)
+├── project/ ← declared source files and asset dependencies (MJCF, meshes, …)
└── release.zip ← fetched by the page itself
```
@@ -131,6 +142,13 @@ Every runtime URL is page-relative, so the site works from a sub-path such as
runtime is single-threaded). Visitors can drag the robot, drive it with the
keyboard, and switch policies without installing anything.
+The site copies only the source files and asset paths recorded in the release,
+including the collected MJCF dependency closure. Unrelated files such as virtual
+environments, logs, downloads and previous site outputs are not scanned or copied.
+Missing declared files and symlinks escaping the project fail before site files
+are written. You can build directly from the project root, including when the
+output lives below it; no staging copy is needed.
+
The project must be self-contained — every resource under the project
directory. A package that still needs a live-Python (`modify=…`) controller is
rejected with a hint, matching what the embed runtime itself would do.
diff --git a/docs/component-reference.md b/docs/component-reference.md
index 17c305a..cbbc84c 100644
--- a/docs/component-reference.md
+++ b/docs/component-reference.md
@@ -21,7 +21,7 @@
| 调用 | 维度 | 备注 |
|------|------|------|
-| `robot.base_angular_velocity(scale=, source=)` | 3 | |
+| `robot.base_angular_velocity(scale=, source=)` | 3 | 自由根关节读取机体系角速度,无需再次逆旋转 |
| `robot.base_linear_velocity(scale=, source=)` | 3 | |
| `robot.projected_gravity(target=)` | 3 | `target="root"` 或 body 名 |
| `robot.base_height()` | 1 | |
diff --git a/pyproject.toml b/pyproject.toml
index 66f59a4..c13bad7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,6 +25,7 @@ classifiers = [
]
dependencies = [
"PyYAML>=6.0",
+ "rfc8785>=0.1.4,<0.2",
]
[project.urls]
diff --git a/src/botlab/_mjcf.py b/src/botlab/_mjcf.py
new file mode 100644
index 0000000..151529c
--- /dev/null
+++ b/src/botlab/_mjcf.py
@@ -0,0 +1,103 @@
+"""Local MJCF dependency closure shared by validation and native staging."""
+from __future__ import annotations
+
+import os
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+from ._host import BotLabError
+
+
+def mjcf_dependencies(source: Path, *, root: Path | None = None, transform=None,
+ entry_transform=None, _model_stack=()) -> tuple[dict[Path, ET.Element], set[Path]]:
+ """Collect includes and file assets without walking unrelated directories.
+
+ Include lookup follows MuJoCo: main XML directory first, then the including
+ directory. Asset paths use the main XML's compiler directories. When packaging,
+ ``root`` bounds both lexical paths and symlink targets to the project.
+ """
+ source = Path(os.path.abspath(source))
+ if source.resolve() in _model_stack:
+ raise BotLabError(f'MJCF attached model cycle: "{source}"')
+ base = source.parent
+ documents: dict[Path, ET.Element] = {}
+ assets: set[Path] = set()
+ compiler: dict[str, str] = {}
+ elements: list[ET.Element] = []
+
+ def checked(path: Path) -> Path:
+ path = Path(os.path.abspath(path))
+ if root is not None and (not path.is_relative_to(root.resolve())
+ or not path.resolve().is_relative_to(root.resolve())):
+ raise BotLabError(f'MJCF dependency escapes the project: "{path}"')
+ if not path.is_file():
+ raise BotLabError(f'MJCF dependency does not exist: "{path}"')
+ return path
+
+ def visit(path: Path, stack: tuple[Path, ...]):
+ path = checked(path)
+ if path.resolve() in {item.resolve() for item in stack}:
+ raise BotLabError(f'MJCF include cycle: {" -> ".join(map(str, (*stack, path)))}')
+ if path in documents:
+ return
+ text = path.read_text(encoding="utf-8")
+ if transform is not None:
+ text = transform(text)
+ if path == source and entry_transform is not None:
+ text = entry_transform(text)
+ try:
+ document = ET.fromstring(text)
+ except ET.ParseError as error:
+ raise BotLabError(f'Invalid MJCF XML "{path}": {error}') from error
+ documents[path] = document
+ for element in document.iter():
+ if element.tag == "include":
+ file = element.get("file")
+ if not file:
+ raise BotLabError(f'MJCF include has no file in "{path}"')
+ if root is not None and Path(file).is_absolute():
+ raise BotLabError(f'MJCF include must be project-relative: "{file}"')
+ candidate = base / file
+ if not candidate.is_file():
+ candidate = path.parent / file
+ candidate = checked(candidate)
+ # Explicit main-relative paths preserve resolution after staging.
+ element.set("file", os.path.relpath(candidate, base))
+ visit(candidate, (*stack, path))
+ elif element.tag == "compiler":
+ compiler.update(element.attrib)
+ else:
+ elements.append(element)
+
+ visit(source, ())
+ for element in elements:
+ if element.tag not in {"mesh", "texture", "hfield", "skin", "model", "flexcomp"}:
+ continue
+ keys = ("file", "filename")
+ if element.tag == "texture":
+ keys += ("fileleft", "fileright", "fileup", "filedown", "filefront", "fileback")
+ for key in keys:
+ file = element.get(key)
+ if not file:
+ continue
+ if compiler.get("strippath") == "true":
+ file = file.replace("\\", "/").rsplit("/", 1)[-1]
+ directory = ""
+ if element.tag in {"mesh", "hfield"}:
+ directory = compiler.get("meshdir", compiler.get("assetdir", ""))
+ elif element.tag == "texture":
+ directory = compiler.get("texturedir", compiler.get("assetdir", ""))
+ if root is not None and (Path(directory).is_absolute() or Path(file).is_absolute()):
+ raise BotLabError(f'MJCF asset must be project-relative: "{directory}/{file}"')
+ candidate = checked(base / directory / file)
+ if element.tag == "model":
+ # Attached MJCF models have their own compiler/include scope.
+ nested, nested_assets = mjcf_dependencies(
+ candidate, root=root, transform=transform,
+ _model_stack=(*_model_stack, source.resolve()),
+ )
+ documents.update(nested)
+ assets.update(nested_assets)
+ else:
+ assets.add(candidate)
+ return documents, assets
diff --git a/src/botlab/_project.py b/src/botlab/_project.py
index 5b23c19..f296c5d 100644
--- a/src/botlab/_project.py
+++ b/src/botlab/_project.py
@@ -18,6 +18,7 @@
from pathlib import Path
from typing import Any
+import rfc8785
import yaml
from . import _host
@@ -146,49 +147,65 @@ def _project_relative_assets(project_dir: Path, source_files: dict[str, str], ma
assets: set[str] = set()
for resource in manifest["resources"]:
source = resource["source"]
+ if resource["kind"] == "sensor":
+ target = next((item for item in manifest["resources"] if item["id"] == source), None)
+ if target is None or target["kind"] != "robot":
+ raise BotLabError(f'Sensor {resource["id"]!r} must reference a declared robot, got {source!r}')
+ continue
if source.startswith(("http://", "https://", "data:", "procedural://")):
continue
if not _is_safe_archive_path(source):
raise BotLabError(f'Unsafe project asset path "{source}" in resource {resource["id"]!r}')
candidate = project_dir / source
+ if not candidate.resolve().is_relative_to(project_dir.resolve()):
+ raise BotLabError(f'Resource path "{source}" escapes the project.')
if not candidate.exists():
raise BotLabError(
f'Resource {resource["id"]!r} points at "{source}", which does not exist in the project.'
)
if candidate.is_file():
assets.add(source)
+ if resource["kind"] in ("robot", "scene") and candidate.suffix.lower() in (".xml", ".mjcf"):
+ from ._mjcf import mjcf_dependencies
+
+ documents, dependencies = mjcf_dependencies(candidate, root=project_dir)
+ assets.update(path.relative_to(project_dir).as_posix() for path in set(documents) | dependencies)
elif candidate.is_dir():
for item in sorted(candidate.rglob("*")):
if item.is_file():
+ if not item.resolve().is_relative_to(project_dir.resolve()):
+ raise BotLabError(f'Resource path "{item}" escapes the project.')
assets.add(item.relative_to(project_dir).as_posix())
else:
raise BotLabError(f'Resource path "{source}" is neither a file nor a directory.')
return sorted(assets)
-def _canonical_value(value: Any) -> Any:
- """Mirror the TS ``canonicalValue``: sort object keys recursively.
-
- JS ``Object.keys(value).sort()`` reorders every nested object, so the
- digest input is key-order-canonical on both sides.
- """
- if isinstance(value, dict):
- return {key: _canonical_value(value[key]) for key in sorted(value)}
- if isinstance(value, list):
- return [_canonical_value(item) for item in value]
- return value
-
-
def canonical_json(value: Any) -> str:
- """Serialize like JS ``JSON.stringify`` of the canonicalized digest input.
+ """Match existing browser canonicalValue + JSON.stringify bytes (release v3).
- ``pythonReleaseProjectDigest`` canonicalizes (recursively sorted object
- keys, finite numbers only) then digests ``JSON.stringify`` output.
- Python's ``json`` with ``separators=(',', ':')`` produces the same bytes
- as JS as long as floats render identically; integer-valued floats were
- normalized to ``int`` upstream (JS ``JSON.stringify(1.0)`` is ``"1"``).
+ JCS supplies ECMAScript number rendering. Objects retain JSON.stringify's
+ numeric-index-first enumeration, which differs from pure JCS key ordering.
"""
- return json.dumps(_canonical_value(value), ensure_ascii=False, separators=(",", ":"))
+ if isinstance(value, dict):
+ def key_order(key):
+ index = int(key) if key.isascii() and key.isdecimal() else -1
+ if 0 <= index < 2**32 - 1 and str(index) == key:
+ return (0, index)
+ return (1, key.encode("utf-16-be", errors="surrogatepass"))
+
+ return "{" + ",".join(canonical_json(key) + ":" + canonical_json(value[key])
+ for key in sorted(value, key=key_order)) + "}"
+ if isinstance(value, (list, tuple)):
+ return "[" + ",".join(map(canonical_json, value)) + "]"
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
+ try:
+ return rfc8785.dumps(float(value)).decode("utf-8")
+ except (ValueError, OverflowError) as error:
+ raise BotLabError("Release digest contains a non-finite number") from error
+ # Well-formed JSON.stringify escapes lone UTF-16 surrogates, not other Unicode.
+ text = json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
+ return "".join(f"\\u{ord(char):04x}" if 0xD800 <= ord(char) <= 0xDFFF else char for char in text)
def python_release_project_digest(manifest: dict, files: dict[str, str]) -> str:
diff --git a/src/botlab/_runtime/_host_runtime.py b/src/botlab/_runtime/_host_runtime.py
index 579f3ff..0ca8982 100644
--- a/src/botlab/_runtime/_host_runtime.py
+++ b/src/botlab/_runtime/_host_runtime.py
@@ -75,6 +75,7 @@ def __init__(self, physics, display=None, models=None, registry=None,
self._groups = []
self._plan_nodes = None
self._plan_output = {}
+ self._plan_robot_id = None
self._modifier = None
self._reset_callback = None
self._step_callback = None
@@ -153,6 +154,7 @@ def registerPolicyController(self, plan, modifier=None):
self._executor = executor
self._plan_nodes = nodes
self._plan_output = plan.get("output") or {}
+ self._plan_robot_id = plan.get("robot")
self._modifier = modifier
if modifier is not None:
self._attach_action_hook(modifier)
@@ -542,7 +544,9 @@ def _control_payload(self):
view = processor.get_substep_pd() if processor is not None else None
if view is None:
raise RuntimeError("[botlab] PolicyController PD output is unavailable")
- return None, view
+ # The PD processor emits robot-local indices, just like a controller
+ # group. Resolve all three absolute index arrays before worker stepping.
+ return self._physics.compose_payloads({self._plan_robot_id: ("pd", view)})
def _fill_parameters(self, name):
import botlab_host
diff --git a/src/botlab/_runtime/_observations.py b/src/botlab/_runtime/_observations.py
index 1e90d41..356dd41 100644
--- a/src/botlab/_runtime/_observations.py
+++ b/src/botlab/_runtime/_observations.py
@@ -1037,6 +1037,12 @@ def _base_angular_velocity(model, data, config):
if sensor_gyro is not None:
return sensor_gyro
if _has_root_free_joint(model, data):
+ # Match the browser contract: marked plans use MuJoCo's body-local
+ # rotational qvel; previously published unmarked plans keep their path.
+ if (config or {}).get("rootAngularVelocityFrame") == "body":
+ return _float32_slice(data.qvel, "data.qvel", 3, 6)
+ if "rootAngularVelocityFrame" in (config or {}):
+ raise _error("mujocoObservations", "rootAngularVelocityFrame must be body")
return _inverse_rotate_by_quaternion(
_float32_slice(data.qvel, "data.qvel", 3, 6),
_root_kinematic_quaternion(model, data),
diff --git a/src/botlab/_runtime/run.py b/src/botlab/_runtime/run.py
index da66213..4fbc6d7 100644
--- a/src/botlab/_runtime/run.py
+++ b/src/botlab/_runtime/run.py
@@ -113,62 +113,30 @@ def _robot_format(path):
def _load_model_with_transform(source, transform, mujoco_module, entry_transform=None):
- """Compile the entry XML with ``transform`` applied to every XML in its
- include chain — the browser does the same per VFS file (transforms.all).
-
- XMLs land in a temp directory with the include layout preserved; every
- other file of the model tree is symlinked so meshes still resolve.
- ``entry_transform`` runs after ``transform`` on the entry file only — the
- composed-scene injection point (the browser composes on the entry too).
- """
+ """Stage only the model dependency closure, preserving sibling directories."""
import os
- import re
- import shutil
import tempfile
+ import xml.etree.ElementTree as ET
+ from pathlib import Path
- model_dir = os.path.dirname(os.path.abspath(source))
- entry_name = os.path.basename(source)
- tmp_dir = tempfile.mkdtemp(prefix="botlab-model-")
- try:
- written = set()
-
- def process(xml_path, rel_name):
- if rel_name in written:
- return
- written.add(rel_name)
- with open(xml_path, encoding="utf-8") as handle:
- text = handle.read()
- text = transform(text)
- if entry_transform is not None and rel_name == entry_name:
- text = entry_transform(text)
- target = os.path.join(tmp_dir, rel_name)
- os.makedirs(os.path.dirname(target) or tmp_dir, exist_ok=True)
- with open(target, "w", encoding="utf-8") as handle:
- handle.write(text)
- for include in re.finditer(r" list[str]:
)
+
+def _site_project_files(config: dict[str, Any]) -> list[str]:
+ """Use the release inventory; never discover files by walking the project."""
+ project = config["project"]
+ sources = project.get("files")
+ assets = project.get("assetPaths")
+ if not isinstance(sources, list) or not isinstance(assets, list):
+ raise BotLabError("Release project files and assetPaths must be arrays; rebuild the release.")
+ paths = set()
+ for entry in sources:
+ if not isinstance(entry, dict):
+ raise BotLabError("Release project files entry must be an object.")
+ paths.add(_require_safe_site_path(entry.get("path"), "project files entry"))
+ for path in assets:
+ paths.add(_require_safe_site_path(path, "assetPaths entry"))
+ return sorted(paths)
+
+
def build_site_directory(project_dir: Path, package: bytes, out_dir: Path) -> dict[str, Any]:
"""Write the standalone site for one already-built release package."""
snapshot_dir = ensure_browser_runtime()
@@ -329,10 +346,13 @@ def add_copy(source: Path, target_relative: str) -> None:
add_copy(snapshot_dir / "embed.html", "index.html")
for file in sorted(closure):
add_copy(_contained_file(snapshot_dir, file, "UI snapshot input"), file)
- # The /project/ graft serves the whole project directory so MJCF mesh
- # includes resolve relative to their XML, exactly like `botlab run`.
- for relative in _iter_project_files(project_dir):
- add_copy(_contained_file(project_dir, relative, "Project asset"), f"{asset_base}/{relative}")
+ # Preserve paths within the declared dependency closure so MJCF includes
+ # resolve, without walking venvs, logs, downloads or previous site outputs.
+ for relative in _site_project_files(config):
+ source = _contained_file(project_dir, relative, "Project asset")
+ if not source.is_file():
+ raise BotLabError(f'Project asset is missing or is not a file: "{relative}".')
+ add_copy(source, f"{asset_base}/{relative}")
for source, target in copies:
target.parent.mkdir(parents=True, exist_ok=True)
diff --git a/src/botlab/sdk/_robot.py b/src/botlab/sdk/_robot.py
index 0c7535d..4d02559 100644
--- a/src/botlab/sdk/_robot.py
+++ b/src/botlab/sdk/_robot.py
@@ -169,7 +169,11 @@ def default_angles(self):
def base_angular_velocity(self, *, scale=None, source="root"):
return Signal("obs-base-ang-vel", "base-angular-velocity", _observation_data(
- {"dim": 3, "velocitySource": _non_empty_string(source, "velocity source")},
+ {
+ "dim": 3,
+ "velocitySource": _non_empty_string(source, "velocity source"),
+ "rootAngularVelocityFrame": "body",
+ },
scale=scale,
))
diff --git a/tests/runtime/test_host_runtime.py b/tests/runtime/test_host_runtime.py
index 0231ffd..9d90c0f 100644
--- a/tests/runtime/test_host_runtime.py
+++ b/tests/runtime/test_host_runtime.py
@@ -210,12 +210,14 @@ def get_substep_pd(self):
}
physics = SpyPhysics(stop_after=1)
+ physics.compose_payloads = mock.Mock(side_effect=lambda actions: (None, actions["robot-1"][1]))
session = _host_runtime.LocalRuntimeSession(
physics,
registry={**FAKE_REGISTRY, "proc-pd": PdStub},
)
self.addCleanup(session.close)
session.registerPolicyController({
+ "robot": "robot-1",
"nodes": [
{"id": "c", "type": "obs-constant", "data": {"values": [1.0, 2.0]}},
{"id": "pd", "type": "proc-pd", "data": {}},
@@ -233,6 +235,7 @@ def get_substep_pd(self):
_label, _steps, ctrl, substep_pd = pd_calls[0]
self.assertIsNone(ctrl)
self.assertEqual(substep_pd["target"], [0.5])
+ physics.compose_payloads.assert_called_once_with({"robot-1": ("pd", substep_pd)})
def test_missing_policy_output_is_explicit(self):
class NoOutputSource(ConstantSource):
diff --git a/tests/runtime/test_observations.py b/tests/runtime/test_observations.py
index 8515612..0d55b30 100644
--- a/tests/runtime/test_observations.py
+++ b/tests/runtime/test_observations.py
@@ -368,12 +368,42 @@ def value(self, node_type, node_data=None, **extra):
node_type, context(self.model, self.data, node_data, **extra)
)
- def test_obs_base_ang_vel_inverse_rotates_root_qvel(self):
- # R^T @ [0.1, 0, 0] for a 90 deg z quaternion = [0, -0.1, 0].
+ def test_obs_base_ang_vel_preserves_body_local_root_qvel(self):
+ # MuJoCo free-joint angular qvel is already body-local, unlike translation.
+ np.testing.assert_allclose(
+ self.value("obs-base-ang-vel", {"rootAngularVelocityFrame": "body"}),
+ [0.1, 0, 0], atol=1e-6
+ )
+
+ def test_unmarked_plan_preserves_legacy_angular_velocity(self):
np.testing.assert_allclose(
self.value("obs-base-ang-vel"), [0, -0.1, 0], atol=1e-6
)
+ def test_rejects_unsupported_angular_velocity_frame(self):
+ with self.assertRaisesRegex(ValueError, "rootAngularVelocityFrame must be body"):
+ self.value("obs-base-ang-vel", {"rootAngularVelocityFrame": "world"})
+
+ def test_root_angular_velocity_matches_mujoco_local_object_velocity(self):
+ for quat in ([HALF_SQRT2, 0, 0, HALF_SQRT2], [0.5, 0.5, 0.5, 0.5]):
+ with self.subTest(quat=quat):
+ model, data = make_forward_model(
+ quat=quat, qvel=[0.2, -0.3, 0.4, 0.7, -0.8, 0.9, 0]
+ )
+ velocity = np.zeros(6)
+ mujoco.mj_objectVelocity(
+ model, data, mujoco.mjtObj.mjOBJ_BODY, 1, velocity, 1
+ )
+ root = obs.get_mujoco_observation(
+ "obs-base-ang-vel", context(model, data, {"velocitySource": "root", "rootAngularVelocityFrame": "body"})
+ )
+ np.testing.assert_allclose(root, velocity[:3], atol=1e-6)
+ np.testing.assert_allclose(root, data.sensordata[4:7], atol=1e-6)
+ imu = obs.get_mujoco_observation(
+ "obs-imu", context(model, data, {"velocitySource": "root", "rootAngularVelocityFrame": "body"})
+ )
+ np.testing.assert_allclose(imu[4:], velocity[:3], atol=1e-6)
+
def test_obs_base_lin_vel_inverse_rotates_root_qvel(self):
np.testing.assert_allclose(
self.value("obs-base-lin-vel"), [0, -0.2, 0], atol=1e-6
@@ -402,7 +432,7 @@ def test_obs_projected_gravity_body_target(self):
def test_obs_imu_g1_reads_sensors(self):
# With a g1 profile the quaternion comes from the imu_quat sensor and
# the angular velocity from the imu_gyro sensor (body frame), which
- # differs from the kinematic inverse-rotation path.
+ # agrees with the root path when the site axes match the root body.
output = self.value("obs-imu", {"robot": "g1"})
gyro = self.data.sensordata[4:7]
np.testing.assert_allclose(output[:4], [HALF_SQRT2, 0, 0, HALF_SQRT2], atol=1e-6)
diff --git a/tests/test_headless_actuator_mapping.py b/tests/test_headless_actuator_mapping.py
new file mode 100644
index 0000000..bfa10da
--- /dev/null
+++ b/tests/test_headless_actuator_mapping.py
@@ -0,0 +1,71 @@
+"""Exercise compiled PolicyController mappings through the public native runner."""
+import numpy as np
+import pytest
+
+from botlab import open_headless
+from tests.conftest import MAIN
+
+
+def mapped_project(path, *, floating, order, mapping):
+ path.mkdir()
+ motors = [f'' for i in order]
+ root = '' if floating else ''
+ (path / 'robot.xml').write_text(f'''
+
+ {root}
+
+
+
+
+
+
+
+
+ {''.join(motors)}
+ ''')
+ config = dict(actionDimension=2, jointQposOffset=7 if floating else 0,
+ jointQvelOffset=6 if floating else 0, policyJointOrder=[0, 1],
+ motorJointOrder=[0, 1], defaultJointPositions=[0, 0], actionScale=1,
+ kp=[10, 20], kd=[0.5, 0.8], effortLimit=[5, 7])
+ if mapping is not None:
+ config['actuatorJointOrder'] = mapping
+ (path / 'controller.py').write_text('''from botlab import Robot, PolicyController, constant, project
+project("Actuator mapping", control_hz=50, physics_hz=200)
+robot = Robot("robot.xml", config=''' + repr(config) + ''')
+def create_controller(sim):
+ return PolicyController(robot, target=constant([0.2, -0.3]).action())
+''')
+ (path / 'main.py').write_text(MAIN)
+ return path
+
+
+def rollout(path):
+ states, forces, controls = [], [], []
+ with open_headless(path) as run:
+ for _ in range(25):
+ run.step()
+ data = run.physics.data
+ states.append(np.concatenate([data.qpos, data.qvel]))
+ forces.append(data.qfrc_actuator.copy())
+ controls.append(data.ctrl.copy())
+ return np.array(states), np.array(forces), np.array(controls)
+
+
+@pytest.mark.parametrize('floating', [False, True])
+def test_single_policy_actuator_mapping_preserves_joint_forces_and_trajectory(tmp_path, floating):
+ baseline = rollout(mapped_project(tmp_path / 'plain', floating=floating,
+ order=[0, 1], mapping=None))
+ for name, order, mapping in [('identity', [0, 1], [0, 1]), ('swapped', [1, 0], [1, 0])]:
+ actual = rollout(mapped_project(tmp_path / name, floating=floating,
+ order=order, mapping=mapping))
+ np.testing.assert_allclose(actual[0], baseline[0], rtol=0, atol=1e-12)
+ np.testing.assert_allclose(actual[1], baseline[1], rtol=0, atol=1e-12)
+ np.testing.assert_allclose(actual[2][:, mapping], baseline[2], rtol=0, atol=1e-12)
+ assert np.max(np.abs(baseline[1])) > 0.1
+
+
+def test_single_policy_rejects_out_of_scope_actuator_mapping(tmp_path):
+ path = mapped_project(tmp_path / 'invalid', floating=True, order=[0, 1], mapping=[2, 0])
+ with open_headless(path) as run:
+ with pytest.raises(ValueError, match='outside robot action dimension'):
+ run.step()
diff --git a/tests/test_mjcf_dependencies.py b/tests/test_mjcf_dependencies.py
new file mode 100644
index 0000000..c67166b
--- /dev/null
+++ b/tests/test_mjcf_dependencies.py
@@ -0,0 +1,92 @@
+"""Dependency closure must agree with real MuJoCo and remain project-contained."""
+import numpy as np
+import pytest
+
+from botlab._host import BotLabError
+from botlab._mjcf import mjcf_dependencies
+from botlab._runtime.run import _load_model_with_transform
+
+
+@pytest.fixture()
+def scene(tmp_path):
+ robot = tmp_path / "robot"
+ robot.mkdir()
+ shared = tmp_path / "shared"
+ shared.mkdir()
+ (shared / "tetra.obj").write_text("v 0 0 0\nv 1 0 0\nv 0 1 0\nv 0 0 1\nf 1 3 2\nf 1 2 4\nf 1 4 3\nf 2 3 4\n")
+ (shared / "body.xml").write_text("")
+ (shared / "nested.xml").write_text("")
+ entry = robot / "scene.xml"
+ entry.write_text("""
+
+
+ """)
+ return entry
+
+
+def test_sibling_nested_dependencies_match_native(scene):
+ mujoco = pytest.importorskip("mujoco")
+ documents, assets = mjcf_dependencies(scene, root=scene.parent.parent)
+ assert len(documents) == 3
+ assert {p.name for p in assets} == {"tetra.obj"}
+ original = mujoco.MjModel.from_xml_path(str(scene))
+ transformed = _load_model_with_transform(scene, lambda xml: xml, mujoco)
+ np.testing.assert_array_equal(original.mesh_vert, transformed.mesh_vert)
+ np.testing.assert_array_equal(original.geom_pos, transformed.geom_pos)
+ np.testing.assert_array_equal(original.body_mass, transformed.body_mass)
+
+
+def test_missing_dependency_and_cycle_are_diagnostic(scene):
+ (scene.parent.parent / 'shared/tetra.obj').unlink()
+ with pytest.raises(BotLabError, match='tetra.obj'):
+ mjcf_dependencies(scene)
+ scene.write_text("")
+ with pytest.raises(BotLabError, match='include cycle'):
+ mjcf_dependencies(scene)
+
+
+def test_dependencies_cannot_escape_project_or_follow_external_symlinks(scene, tmp_path):
+ with pytest.raises(BotLabError, match='escapes the project'):
+ mjcf_dependencies(scene, root=scene.parent)
+ external = tmp_path.parent / (tmp_path.name + '-external.obj')
+ external.write_text('external')
+ mesh = tmp_path / 'shared/tetra.obj'
+ mesh.unlink()
+ mesh.symlink_to(external)
+ with pytest.raises(BotLabError, match='escapes the project'):
+ mjcf_dependencies(scene, root=tmp_path)
+
+
+def test_compiler_assetdir_strippath_and_texture(tmp_path):
+ (tmp_path / 'assets').mkdir()
+ for name in ('mesh.obj', 'height.bin', 'color.png'):
+ (tmp_path / 'assets' / name).write_bytes(b'fixture')
+ entry = tmp_path / 'scene.xml'
+ entry.write_text("""
+
+ """)
+ _, assets = mjcf_dependencies(entry, root=tmp_path)
+ assert {p.name for p in assets} == {'mesh.obj', 'height.bin', 'color.png'}
+
+
+def test_transform_applies_to_included_document(scene):
+ mujoco = pytest.importorskip('mujoco')
+ result = _load_model_with_transform(scene, lambda xml: xml.replace(
+ '', ""), mujoco)
+ assert result.ngeom == 2
+ np.testing.assert_array_equal(result.geom_pos[0], [0, 0, 3])
+
+
+def test_packaging_rejects_absolute_dependencies(scene):
+ scene.write_text(f"")
+ with pytest.raises(BotLabError, match='must be project-relative'):
+ mjcf_dependencies(scene, root=scene.parent.parent)
+
+
+def test_attached_model_cycles_fail_without_recursion(tmp_path):
+ a = tmp_path / 'a.xml'
+ b = tmp_path / 'b.xml'
+ a.write_text("")
+ b.write_text("")
+ with pytest.raises(BotLabError, match='attached model cycle'):
+ mjcf_dependencies(a, root=tmp_path)
diff --git a/tests/test_project.py b/tests/test_project.py
index 555daf7..a23dded 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -249,3 +249,66 @@ def test_modify_hook_project_travels_as_sources(tmp_path: Path):
assert "project/controller.py" in archive.namelist()
# digest still covers manifest + sources
assert config["project"]["projectDigest"] == project.project_digest
+
+
+def test_named_robot_sensor_identity_is_not_an_asset_path(project_dir):
+ controller = CONTROLLER.replace('Robot("assets/robot/scene.xml",', 'Robot("assets/robot/scene.xml", name="G1",')
+ controller += '\nfrom botlab import Sensor\ncamera = Sensor(robot, name="Depth", config={"outputMode": "depth"})\n'
+ (project_dir / 'controller.py').write_text(controller)
+ loaded = load_project(project_dir)
+ sensor = next(r for r in loaded.manifest['resources'] if r['kind'] == 'sensor')
+ assert sensor['source'] == 'G1'
+ assert loaded.compiled_plan['robot'] == 'G1'
+ assert 'G1' not in loaded.asset_paths
+ assert 'assets/robot/scene.xml' in loaded.asset_paths
+ create_release_package(loaded)
+
+
+def test_sensor_requires_robot_reference(project_dir):
+ (project_dir / 'controller.py').write_text(CONTROLLER + '\nfrom botlab import Sensor\ncamera = Sensor("missing", name="Depth")\n')
+ with pytest.raises(BotLabError, match='must reference a declared robot'):
+ load_project(project_dir)
+
+
+def test_project_collects_mjcf_sibling_assets(project_dir):
+ shared = project_dir / 'assets/shared'
+ shared.mkdir()
+ (shared / 'body.xml').write_text('')
+ (shared / 'mesh.obj').write_bytes(b'fixture')
+ (project_dir / 'assets/robot/scene.xml').write_text("""
+ """)
+ loaded = load_project(project_dir)
+ assert {'assets/shared/body.xml', 'assets/shared/mesh.obj'} <= set(loaded.asset_paths)
+
+
+def test_digest_matches_actual_node_for_number_and_key_boundaries():
+ import shutil
+ import subprocess
+
+ node = shutil.which('node')
+ if node is None:
+ pytest.skip('Node is needed for the cross-language digest test')
+ values = [6.812889478169382e-05, 1e-7, 1e-6, 1e20, 1e21, -0.0, 5e-324,
+ 1.7976931348623157e308, 333333333.33333329, 1000000000000000128]
+ payload = {'values': values, 'keys': {'10': 1, '2': 2, '01': 3, '\U00010000': 4, '\ue000': 5},
+ 'text': '中文\n\ud800'}
+ expected = subprocess.check_output([node, '-e', '''
+const fs=require('fs'); const c=v=>Array.isArray(v)?v.map(c):v&&typeof v==='object'?
+Object.fromEntries(Object.keys(v).sort().map(k=>[k,c(v[k])])):v;
+process.stdout.write(JSON.stringify(c(JSON.parse(fs.readFileSync(0,'utf8')))));
+'''], input=json.dumps(payload).encode()).decode()
+ assert canonical_json(payload) == expected
+
+
+def test_public_angular_velocity_marks_body_frame(project_dir: Path):
+ controller = project_dir / "controller.py"
+ controller.write_text(
+ CONTROLLER.replace("robot.projected_gravity()", "robot.base_angular_velocity()"),
+ encoding="utf-8",
+ )
+ project = load_project(project_dir)
+ angular_nodes = [
+ node for node in project.compiled_plan["nodes"] if node["type"] == "obs-base-ang-vel"
+ ]
+ assert len(angular_nodes) == 1
+ assert angular_nodes[0]["data"]["rootAngularVelocityFrame"] == "body"
diff --git a/tests/test_site.py b/tests/test_site.py
index c722876..a699d56 100644
--- a/tests/test_site.py
+++ b/tests/test_site.py
@@ -177,7 +177,7 @@ def test_build_site_directory_layout(
assert (out_dir / "assets" / "embed-abc.js").is_file()
assert (out_dir / "wasm" / "obj-parser" / "objParser.wasm").is_file()
assert not (out_dir / "assets" / "editor-main-unused.js").exists()
- # The whole project directory is mirrored under /project/ like the local server graft.
+ # Declared source and asset paths are preserved under /project/.
assert (out_dir / "project" / "assets" / "robot" / "scene.xml").is_file()
assert (out_dir / "project" / "controller.py").is_file()
assert (out_dir / "release.zip").read_bytes() == package
@@ -227,3 +227,66 @@ def test_build_site_with_a_root_level_robot_asset(
assert (out / "project/robot.xml").is_file()
config = read_release_config((out / "release.zip").read_bytes())
assert config["project"]["manifest"]["resources"][0]["source"] == "robot.xml"
+
+
+def test_site_copies_declared_files_and_mjcf_closure_only(
+ project_dir, mini_snapshot, monkeypatch,
+):
+ from botlab import _site
+
+ monkeypatch.setattr(_site, 'ensure_browser_runtime', lambda: mini_snapshot)
+ shared = project_dir / 'assets/shared'
+ shared.mkdir()
+ (shared / 'body.xml').write_text('')
+ (shared / 'terrain.obj').write_bytes(b'mesh fixture')
+ (project_dir / 'assets/robot/scene.xml').write_text("""
+
+ """)
+ (project_dir / 'helper.py').write_text('VALUE = 1\n')
+ project = load_project(project_dir)
+ package = create_release_package(project, metadata={'name': 'Test Duck'})
+ # A real project has a venv, downloads, logs and previous outputs. None are
+ # runtime declarations, including unrelated files in the assets directory.
+ for name in ('.env', '.git/config', 'logs/run.log', 'downloads/archive.zip',
+ 'assets/unused.bin', 'tmp/old-site/index.html', 'release.zip'):
+ target = project_dir / name
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_bytes(b'unrelated')
+ venv_python = project_dir / '.venv/bin/python'
+ venv_python.parent.mkdir(parents=True)
+ import sys
+ venv_python.symlink_to(sys.executable)
+
+ out = project_dir / 'tmp/new-site'
+ build_site_directory(project_dir, package, out)
+ actual = {p.relative_to(out / 'project').as_posix()
+ for p in (out / 'project').rglob('*') if p.is_file()}
+ assert actual == set(project.files) | set(project.asset_paths)
+ assert {'assets/shared/body.xml', 'assets/shared/terrain.obj', 'helper.py'} <= actual
+ assert (out / 'release.zip').read_bytes() == package
+ # Subsequent outputs under the same project must not copy earlier sites.
+ second = project_dir / 'tmp/second-site'
+ build_site_directory(project_dir, package, second)
+ assert not (second / 'project/tmp').exists()
+
+
+@pytest.mark.parametrize('change', ['missing', 'directory', 'external-symlink'])
+def test_site_rejects_invalid_declared_file_before_writing(
+ project_dir, mini_snapshot, monkeypatch, change, tmp_path_factory,
+):
+ from botlab import _site
+
+ monkeypatch.setattr(_site, 'ensure_browser_runtime', lambda: mini_snapshot)
+ package = create_release_package(load_project(project_dir), metadata={'name': 'T'})
+ path = project_dir / 'assets/robot/scene.xml'
+ path.unlink()
+ if change == 'directory':
+ path.mkdir()
+ elif change == 'external-symlink':
+ outside = tmp_path_factory.mktemp('outside') / 'scene.xml'
+ outside.write_text('')
+ path.symlink_to(outside)
+ out = project_dir / 'tmp/new-site'
+ with pytest.raises(BotLabError, match='Project asset'):
+ build_site_directory(project_dir, package, out)
+ assert not out.exists()