Covers src/wojtek_env/ (the extracted env library) and src/env/
(the training-side bridge). The library is ruff-excluded and hatchling-
packaged — treat it as a stable dependency; research code consumes it, does
not edit it.
wojtek_env/envs/registry.py maps task name → (env class, default config):
joystick— velocity-command locomotion (WojtekJoystick). Default task.getup— recover to standing (WojtekGetup).jump— jump task (WojtekJump).
from wojtek_env.envs import registry
env = registry.make_env("joystick", env_overrides={...})Configs are ml_collections ConfigDicts built in code. env_overrides is a
plain nested dict (as Hydra yaml carries) merged onto the default with
tuple/float coercion — e.g. yaml vx: [-0.8, 1.8] lands on a tuple field, and
a scalar default like action_scale can be overridden with a per-element
vector.
WojtekJoystick.default_config() (envs/env.py) defines several
locomotion-quality and sim2real-robustness features that ship
enable=False/scale=0.0; the config value itself already documents each
one inline. env/overrides.py's movement_overrides()
(below) turns on the task-independent ones:
latency,encoder— substep control latency and joint-encoder miscalibration randomization (sim2real robustness;action_delay=1is a separate always-on fixed delay).symmetry— left/right mirror augmentation.real_pose_ref— judge pose/height against the kinematic standing family instead of commanded ctrl targets (removes a PD-sag-dependent penalty floor; sim2real correctness fix, not just a shaping knob).- Spawn pose (
WojtekJoystick.reset, local edit to the extracted env): every leg joint, passive four-bar joints included, comes from the settled closed-loop pose for the commanded height (_spawn_joint_qpos, tabled perDSECOND_TABLErung by_settle_height_grid), so the closure loop starts shut; setting only the actuated joints would leave it open by up to ~0.13 m and the solver would snap it at 40–70° toggle. Rest-pose toggle ≤ ~26°. movement_overridesleaves thefall.max_toggle_degtermination off (thetoggle_flatpenalty stays): random actions push a leg past 150° in ~35% of 2 s episodes, and with the termination on brax PPO loses 92% of its episodes to the toggle at ~3 s. At the defaultopt.iterations=2the four-bar closure stretches ~14 mm under random actions (2.3 mm at 10 iterations), which doubles the toggle events.action_filter— EMA smoothing on actions before the PD targets.command.pure_wz_prob/pure_vy_prob/arc_prob/pure_slow_prob/pure_fast_prob/pure_back_prob— dedicated clean-command training modes; the uniform command box under-samples these (spin-in-place, strafe, arc-following, slow/fast/backward walking), and the inline comments cite measured deadlocks from training without them.no_progress— probabilistic episode cut when the robot ignores the commanded velocity, instead of letting a stalled episode run out the clock.gait.air_time_cap,fall.max_toggle_deg— anti-runaway swing-time cap and four-bar toggle-angle termination.reward.tracking_relative/tracking_product/shaping_tracking_gate/tracking_far_weight/orientation_tol_degandreward.scales.torque_rate/feet_landing/stand_feet_down/torque_limit/toggle_flat— see the inline comments inenv.pyfor what exploit or dead-gradient region each one closes.
These read zero / never fire on the flat scene (terrain.enable=False), so
they only make sense once terrain is on. terrain_overrides() turns them on
for the tiled-arena scripts:
height_scan— body-frame grid of terrain heights ahead of the robot; privileged-only ("height_scan_clean"inobs.privileged, i.e. the critic) — wojtek has no lidar/depth sensor, so the actor (obs.state) never gets aheight_scanreading, corrupted or not. This is the asymmetric actor-critic split (seeFunctionalEnvbelow): the critic's terrain knowledge shapes the advantage/regret signal during training, but the deployed actor stays proprioceptive-only, matching real hardware.env.pyraises ifobs.privilegednames"height_scan_clean"withoutheight_scan.enable;terrain_overrides()sets both together.reward.terrain_gate,reward.scales.flat_pitch— scale gait shaping and a nose-down pitch penalty by local terrain roughness.command.terrain_bias— bias the resampled command toward steady forward walking; the base command distribution is tuned for the flat scene, where standing/spinning happens on the spawn's local ground and never meets a terrain feature.fall.on_base_contact— analytic base-on-terrain contact ends the episode; without it a robot lying on rough ground can dodge the flatmin_height/tilt fall check.
wojtek_env/terrain/terrain.py builds one deterministic arena from a seed: a
grid of ~3 m tiles, rows of increasing difficulty, one tile of every terrain
type per row, flat border. Rough/slopes/pit are carved into a single
heightfield; stairs and obstacles are static box geoms on top.
Key facts that matter downstream:
- MuJoCo normalises heightfield data to
[0,1]on compile; the physical surface ispos_z + data * elevation_z. The generator stores data normalised to its own range and setselevation_z/pos_zto reproduce exact heights. - A dense lookup grid stores ground-truth surface height on the same node grid as the heightfield, for bilinear terrain-relative height/scandot reads (no raycasts — they don't exist on the JAX backend).
Companion modules: terrain_env.py, terrain_wrapper.py (curriculum wrapper),
terrain_suite.py (fixed measurement course). ACCEL does not use this
tile-type parametrization for its levels — see
levels.md.
wojtek_env/gymnasium/env.py — WojtekGymEnv(gym.Env) wraps a task env in
brax's EpisodeWrapper with jitted reset/step for a standard Gymnasium loop:
from wojtek_env.gymnasium import WojtekGymEnv
env = WojtekGymEnv(task="joystick")
obs, info = env.reset(seed=0)wojtek_env/envs/randomize.py make_domain_randomize(mj_model, dr_cfg=None)
returns a domain_randomize(model, rng) closure, the brax
randomization_fn convention: given a batch of rngs it returns a
(batched_model, in_axes) pair — model.tree_replaced with the randomized
fields stacked on a leading axis, in_axes marking which fields are batched
(0) vs shared (None), ready for jax.vmap(fn, in_axes=[in_axes, 0]).
dr_cfg=None (every field's enable: False) randomizes five fields
unconditionally — floor/terrain friction, base and per-link mass, one
gain/kd actuator scale — the library's baseline domain randomization. Passing
a dr_cfg dict (shape mirrors randomize._DEFAULT_DR) turns on extra,
independently-gated fields: com_offset, joint_gains (per-actuator instead
of one shared scale), dof (damping/armature/frictionloss), foot_friction,
motor_strength. brax_ued.py exposes no dr_cfg
overrides; it uses the baseline five-field form via
FunctionalEnv(domain_randomize=True), which draws one randomized
mjx.Model per parallel env (FunctionalEnv.sample_dr) and keeps the
jax.vmap axis spec for it.
terrain/terrain_wrapper.py's wrap_for_terrain_brax_training also accepts a
randomization_fn and applies it through mujoco_playground's
BraxDomainRandomizationVmapWrapper, but no script currently wires
randomize.py through that path — FunctionalEnv uses its own swap
mechanism instead (below), consistent with the hfield swap it already does.
env/functional.py FunctionalEnv is what training/ and evaluation/ use. It
wraps registry.make_env + brax EpisodeWrapper and exposes pure reset/step
usable under jit/vmap.
-
robot=<TerrainRobot>runs a Playground quadruped (robots/, see robots.md) on the arena instead of Wojtek; the arena and DR work the same, but it reads terrain heights straight fromhfield_data(no lookup) and spawns onspawn_xy. DR is the robot'sdomain_randomize(none for Barkour, Spot). -
Split obs.
observation_size/ everyObscarriesactor(state) andcritic(privileged_state) separately — asymmetric actor-critic. -
Auto-reset semantics.
stepfreezesdata/obsto the episode's first values ondone(viafirst_data/first_obsstashed at reset), so a vmapped batch stays aligned. -
Divergence guard (
env/divergence.py). A step whoseqpos,qvel, obs or reward is not finite, or with any |qvel| aboveQVEL_DIVERGED(200 m/s or rad/s; random actions on full-difficulty levels peak at ~75), ends the episode (done1, reward and per-env metrics 0, then the auto-reset above) and setsinfo["diverged"](brax_ued logsdiverged_per_step; in eval a diverged env scores a fall). MJX blows up now and then on one env; its obs would reach brax's running obs normalizer and never wash out (seen: joint-velocity stds 100-640000x, then policy collapse; a nan one turns every env nan).infois not reset, so the guard zeroes a diverged env's non-finite floatinfoentries (e.g. Playground'sswing_peak); left nan, they would end every later episode of that env at its first step until the nextreset. -
Shared tiled arena.
reset(key, hfield_data, dr_model, tile_xy)andstep(..., hfield_data, dr_model)take the arena'shfield_data(hfield.tiles.TileLayout.assemble: every level of the iteration on its own tile) — one array for every env, vmapped within_axes=None.tile_xyis the env's tile centre, where it spawns;resetraises if an arena comes without it._swapped_modeltemporarily doesbase._mjx_model = model.tree_replace({"hfield_data": hfield_data})around the call, then restores it, so a new iteration's levels need no MJX recompilation (see below). Details: tiled arena. -
Domain randomization swap.
reset/stepalso take an optionaldr_model— a fullmjx.Model(fromFunctionalEnv.sample_dr, above) that_swapped_modelinstalls as_mjx_modeloutright instead oftree_replace-ing one field. The two compose: passing both swapsdr_modelin first, thentree_replaceshfield_dataon top (brax_ued.py).
src/env/overrides.py.
registry.py's task defaults leave most locomotion-quality and sim2real
robustness features off (enable=False/scale=0.0) — see
task config knobs for the
full inventory. env/factory.py (make_env, used by training and every
eval) and diagnostics/arena_check.py turn the same set on rather than repeating one large
dict:
merge_overrides(*dicts)— deep-merges nested override dicts (a plain{**a, **b}would clobber a whole nested key, e.g.command, if two sources both touch it).movement_overrides()— task-independent:latency/encodernoise,symmetrymirror augmentation,real_pose_ref,action_filter, thecommand.pure_*_probclean-command modes,no_progress,gait.air_time_cap, and therewardscale/gate fields that are0.0/Falseinregistry.py.terrain_overrides()— terrain-only (height_scan,reward.terrain_gate,reward.scales.flat_pitch,command.terrain_bias,fall.on_base_contact, and theobs.state/obs.privilegedlists extended withheight_scan/height_scan_clean). Only for runs on the tiled arena; on a flat sceneterrain_gate/height_scanread zero anyway (see terrain-only knobs).- Enabling
height_scanchangesactor_obs_dim/critic_obs_dim(+25 each,NX*NY); this needs no wiring becauseFunctionalEnv.observation_sizereads the size off a live env probe, not a hardcoded constant.symmetry+height_scancompose correctly out of the box —wojtek_env.envs.symmetry.obs_mirroralready has a registered mirror map for theheight_scan/height_scan_cleancatalog entries. - Most of the
movement_overrides()/terrain_overrides()magnitudes (action_filter,tracking_far_weight,orientation_tol_deg, thereward.scalesvalues, thecommand.pure_*_probvalues,gait.air_time_cap) are untuned estimates set by analogy to sibling terms inregistry.py.
mjx.Model registers a field as a dynamic pytree leaf iff it is a jax.Array.
hfield_data→jax.Array→ real dynamic leaf → swappable at runtime with no MJX recompilation.hfield_size(radii, elevation, base) andhfield_nrow/ncol→ plainnumpy.ndarray/ int → static, baked in at compile time; changing them needs a new model.
Consequence: fix the arena geometry once (HFieldConfig: level grid,
elevation envelope, tile count, gap), and the heightfield values become a
cheap, JAX-native "level genome" — which is what makes ACCEL practical at RL
scale.
Two things must follow the swap, or the level only exists on paper:
- Terrain lookup — height scan, base height, foot contact/clearance and
the base-contact/min-height falls read
Arena.lookup, nothfield_data. The accel arena's static lookup is flat, soFunctionalEnv._swapped_modelalso swapsbase._terrain.lookupforbase_z + hfield_data * elevation(HFieldGeometry,env/height_lookup.py) and points every spawn-table entry (origin_xy) at the env'stile_xy. Requires an arena whose lookup is the single heightfield's grid (theaccelarenas); otherwise it fails fast. - Warp physics — MJWarp's collision kernel indexes
hfield_datawithout a world id, andmjxflattens a vmapped(B, N)hfield_datato(B*N,), so a per-env (batched) heightfield onsim.backend=warpmakes every env collide with the first env's level. The tiled arena avoids it by construction:hfield_datais unbatched, so warp is valid andaccel_arena_overridestakes the backend (brax_ued.py backend=, defaultauto: warp on a GPU, jax on CPU) with a terrain-sized contact pool (naconmax_per_env116, over the 84-contact floor of 21 ground-colliding geoms;njmax1024). The Playground robots run on jax only: on warp their foot contacts with this arena diverge far from the arena origin (robots.md). Never vmaphfield_datawithin_axes=0: nothing can detect it inside the vmap.src/scripts/check_hfield_batch.py(diagnostics/arena_check.py) checks it per backend (flat/pit tiles;compare=true= jax vs warp;bench_envs=Ntimes it). - Warp memory — warp allocates its collision scratch (EPA buffers,
~0.3 GB each at 8192 envs) outside JAX's pool, so JAX's default 75 %
preallocation runs it out of memory on terrain.
FunctionalEnvrefuses warp unlessXLA_PYTHON_CLIENT_PREALLOCATE=false; the justfile andslurm/*.sbatchexport it (XLA_PYTHON_CLIENT_PREALLOCis not a variable JAX reads). - Warp CUDA graphs — mjx's warp FFI (
GraphMode.WARP) captures one CUDA graph per set of buffer addresses, each owning its own collision scratch, and caches 32 per callable. brax_ued's separate jitted reset / unroll / eval calls pass new buffers every call, so the default cache runs a CUDA OOM within a few iterations even with preallocation off.FunctionalEnvcaps the cache atWARP_GRAPH_CACHE= 4 (configure_warp) when it builds a warp env; with both settings a training run uses ~16 GB.
hfield/tiles.py TileLayout(HFieldConfig); the accel assets are built
with it (hfield/arena.py).
tiles x tilessquare cells (HFieldConfig.tiles, default 16 = 256 tiles), row-major from(-half, -half). Tile i holds level i, itsgrid_size x grid_sizepatch centred in the cell; spawns at the centre, on the level's flat pad (pad_radius, ±pad_jitter0.15 m).- Between neighbouring patches: a flat
gap(the neutral plane, height 0),reach - patch_radiusrounded up to an even number of cells, at least 2.reach(default 10 m) bounds how far from its tile centre a robot reads terrain within one level, so it cannot read a neighbour's level. Budget (env.travel.TravelBounds): spawn jitter +steps x ctrl_dt xmax command speed + sensing (feet 0.35 m, or the height scan's far corner 1.48 m when any obs reads it; the critic does). Benchmark, 300 steps (fast walk 1.2 m/s): 0.15 + 6 x 1.2 + 1.48 = 8.83 m. Gap 7 m, 4161² nodes (208 m, 69 MB); Go1:reachx 2.5, gap 17.5 m; every length scales by the robot'sSCALE. TileLayout.check_reach: every script that puts several levels on the arena (brax_ued,eval_brax,eval_sim2sim) fails at startup when its steps per level exceed the budget; training counts the critic's scan, eval only the deterministic policy's obs and feet. 1000-step levels need ~28 m: not supported.TileLayout.leaked(xy, tile_xy, sensing): base past `patch_radius + gap- sensing
(Chebyshev) from the own centre = reads another tile.brax_ued.pylogstile_leak_frac` (envs that got there during the iteration, with the critic's sensing).
- sensing
- One model update per iteration:
assemble(jitted, on device) writes the iteration's rendered levels into the arena; unused tiles stay flat. - Float32 positions differ per tile, which perturbs a standing robot
slightly (chaotic spawn settle): ~0.5 mm in the 2x2-tile test arena
(
tests/env/test_functional.py), far below a level's features. - CPU rendering (
paper/terrains.py,rendering/video.py) useshfield.arena.single_tile_spec: the scene cut to one tile with the level at the origin; the renderer meshes the whole hfield, which for the full arena is millions of cells.
wojtek_env/paths.py centralises repo-relative paths. Generated arena file sets
(terrain_paths(kind)) are scene XML, hfield binary, spec JSON, lookup
NPZ, one set per kind in TERRAIN_KINDS = (train, eval, eval_deep, test, accel); tests/env/test_functional.py writes a small tiled arena as test.
They must live next to the robot XML: the scene includes the robot by
relative path and MuJoCo resolves <hfield file=> against the robot's mesh
directory — an arena written elsewhere won't compile. All are gitignored and
built by the tools below.
wojtek_env/tools/ (run via python -m wojtek_env.tools.<name>):
build_model—wojtek.xml→wojtek_mjx.xml+scene_mjx.xml(just build).build_terrain— write a procedural terrain arena's asset set.check_gym,check_model_mjx,check_terrain— sanity-check CLIs.
The ACCEL arena is written by hfield/arena.py instead
(just build-accel-arena), documented in levels.md.