Table-driven attribute reading: rebase the reader on generated rows.

Every mechanical attribute read in MJCF now derives from mjcf.schema.
generate_read_table.py emits typed mjXAttr rows (mjcf_read_table.inc,
doc_test-gated) binding each attribute to its spec struct field; field
offsets are offsetof() expressions, so binding mistakes are compile
errors, and the field's C type -- parsed from the headers -- selects the
row kind, so mjtNum-versus-double is decided by the struct, not the
schema. mjXReader::ReadAttrTable is the generic loop; its static core
also serves the section parsers and records XML-authored fields via
mjs_setAuthored for attach conflict resolution. Row kinds cover
strings, string lists, numeric scalars and vectors (exact and ranged),
enums (int- and byte-width), bitwise flag sets, bools, unbounded typed
vectors, fixed char arrays, and identity constants declared by 'set'.
The rows are inline variables, and carry the writing=custom flag,
because the writer will share them.

The keyword maps the rows reference are generated too: the ~48
hand-written mjMap tables become mjcf_map.h, one map and size constant
per enum as C++17 inline variables, retiring the hand-maintained
extern block in xml_base.h. Map names follow the schema enum names
(fluid->fluidshape, TFAuto->FalseTrueAuto, FAuto->FalseAuto,
joint->jointtype, geom->geomtype, jac->jacobian); all maps are
key-order- and value-identical to the hand tables they replace, and
bool_map is hand-emitted (the bool type is built in, not a schema
enum).

The OneX() parsers reduce to genuine irregulars, schema-marked as
reading=custom: orientation alternatives, file attributes (VFS and
asset-dir context), the actuator shorthand remappings and per-type
input maps, springlength's one-value copy, mesh builtin construction,
hfield elevation, texture cube files, flexcomp seeding, the memory
suffix parse, and the flag bit families. All 41 sensors that are pure
identity-plus-references -- including the frame family and insidesite
-- dispatch through a generated tag table; frame-sensor
objtype/reftype vocabulary tightens from the full mju_str2Type
namespace to the documented body/xbody/geom/site/camera subset, so an
invalid keyword now fails at parse time instead of compile time. The
equality family and both tendon types read shared group rows; the
twelve actuator shorthands share the general rows, with per-tag
legality enforced by the schema check. Sections bind non-mjs structs,
the visual sub-sections reaching their anonymous sub-structs through
member paths declared by an element-level field= facet.

Latent irregularities surfaced by the migration and preserved via
schema declarations or remnants: key's name is set even when absent,
eulerseq and gridlayout are fixed char arrays (chars[n], arity in
characters), gridlayout's length-must-match-gridsize stays a
value-conditional remnant, and constructor-style elements (tendon
wraps, asset model, replicate, attach) are annotated as such -- their
attributes are arguments, not field writes.

Two coherence tests guard the schema against the C sources: every
schema enum constant must be a member of the C enum it claims, and
every C member must be a keyword, a count sentinel, or a documented
exemption; and generate_default_table.py emits one row per defaulted
attribute (mjcf_default_table.inc), compared by SchemaDefaultsTest
against a freshly-constructed spec -- the schema cannot disagree with
the C default-constructors without failing the suite.

Verified: doc_test regenerates and diffs every artifact; the full
suite; and an A/B harness compiling the model corpus against the
pre-migration reader -- saved XML and binary models are byte-identical.
PiperOrigin-RevId: 958075724
Change-Id: I9715fe4deeb438eec988fd5084d74ba8b466b10b
This commit is contained in:
Yuval Tassa
2026-08-02 17:24:11 -07:00
committed by Copybara-Service
parent 790f8fac30
commit 4278c7b0cd
14 changed files with 3131 additions and 1668 deletions
+186
View File
@@ -0,0 +1,186 @@
# Copyright 2026 DeepMind Technologies Limited
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Generates the default-value check table from src/xml/mjcf.schema.
The schema declares attribute defaults, but the defaults that act live in
the C default-constructors (mjs_default*, mj_defaultOption, ...). This
emits src/xml/mjcf_default_table.inc: one row per defaulted attribute,
binding the declared values to the field they describe, consumed by
SchemaDefaultsTest, which compares every row against a freshly-constructed
spec -- so a schema default that disagrees with the C defaults is a test
failure, not documentation drift. Checked in and gated by
test/doc/doc_test.py.
"""
import os
import sys
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _SCRIPT_DIR)
import generate_read_table
import mjcf_schema
_REPO_ROOT = os.path.dirname(os.path.dirname(_SCRIPT_DIR))
SCHEMA_PATH = os.path.join(_REPO_ROOT, 'src', 'xml', 'mjcf.schema')
# kind codes shared with the test
KIND_BY_CTYPE = {'double': 0, 'float': 1, 'int': 2,
'mjtByte': 3, 'mjtBool': 3, 'mjtNum': 4}
_HEADER = '''\
// Copyright 2026 DeepMind Technologies Limited
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// GENERATED FILE, DO NOT EDIT. Generated from src/xml/mjcf.schema by
// doc/generate/generate_default_table.py; test/doc/doc_test.py checks
// freshness.
//
// One row per schema attribute with a declared default, binding the declared
// values to the bound field. SchemaDefaultsTest compares every row against a
// freshly-constructed spec: the schema's defaults must agree with the C
// default-constructors. Rows are {attr, offset, kind, len, ndecl, values};
// values beyond ndecl are expected to be zero; kind: 0=double 1=float 2=int
// 3=byte 4=mjtNum.
// clang-format off
struct mjXDefaultEntry {
const char* attr;
int offset;
int kind;
int len;
int ndecl;
double value[8];
};
struct mjXDefaultTable {
const char* structname;
const mjXDefaultEntry* entries;
int n;
};
'''
def _values(schema, attr, ctype):
"""(ndecl, [C value expressions]) for an attribute's declared default."""
default = attr.default
if attr.type == 'enum':
constants = dict(schema.enums[attr.target].items)
return 1, [f'(double){constants[default]}']
if attr.type == 'bool':
return 1, ['1' if default == 'true' else '0']
values = default if isinstance(default, tuple) else (default,)
return len(values), [repr(v) for v in values]
def collect(schema, structs):
"""struct key -> list of row tuples, deduplicated across elements."""
tables = {}
seen = {}
for element in schema.elements.values():
if not element.spec:
continue
sub = element.facets.get('field')
key = f'{element.spec}.{sub}' if sub else element.spec
fields = structs.get(key)
if fields is None:
continue
prefix = f'{sub}.' if sub else ''
for attr in schema.expanded_attrs(element):
if attr.default is None or attr.type in ('string', 'file', 'chars',
'ref', 'id', 'flags'):
continue
field = attr.facets.get('field', attr.name)
entry = fields.get(field)
if entry is None:
if 'reading' in attr.facets:
continue # custom lowering with no direct binding
raise ValueError(f'{element.name}.{attr.name}: no field '
f'{element.spec}.{field}')
ctype, dim = entry
if ctype not in KIND_BY_CTYPE and not ctype.startswith('mjt'):
raise ValueError(f'{element.name}.{attr.name}: default bound to '
f'field {field} ({ctype})')
kind = KIND_BY_CTYPE.get(ctype, 2) # other mjt enums are int-sized
length = dim if dim is not None else '1'
ndecl, values = _values(schema, attr, ctype)
if ndecl > 8:
raise ValueError(f'{element.name}.{attr.name}: {ndecl} default '
'values exceed the row capacity')
row = (attr.name, f'(int)offsetof({element.spec}, {prefix}{field})',
kind, str(length), ndecl, values)
prior = seen.get((key, field))
if prior is not None:
if prior != (ndecl, values):
raise ValueError(f'{element.name}.{attr.name}: conflicting '
f'defaults for {key}.{field}')
continue
seen[(key, field)] = (ndecl, values)
tables.setdefault(key, []).append(row)
return tables
def generate() -> str:
schema = mjcf_schema.parse_file(SCHEMA_PATH)
structs = generate_read_table.parse_spec_structs(
generate_read_table.SPEC_H_PATH, generate_read_table.MODEL_H_PATH)
tables = collect(schema, structs)
out = [_HEADER]
for key in sorted(tables):
array = 'kDefaults_' + key.replace('.', '_')
out.append(f'static const mjXDefaultEntry {array}[] = {{')
for attr, offset, kind, length, ndecl, values in tables[key]:
vals = ', '.join(values)
out.append(f' {{"{attr}", {offset}, {kind}, {length}, {ndecl}, '
f'{{{vals}}}}},')
out.append('};')
out.append('')
out.append('static const mjXDefaultTable kDefaultTables[] = {')
for key in sorted(tables):
array = 'kDefaults_' + key.replace('.', '_')
root = key.split('.')[0]
out.append(f' {{"{root}", {array}, '
f'(int)(sizeof({array}) / sizeof({array}[0]))}},')
out.append('};')
out.append('static const int kDefaultTablesN = '
'(int)(sizeof(kDefaultTables) / sizeof(kDefaultTables[0]));')
out.append('// clang-format on')
return '\n'.join(out) + '\n'
def main() -> int:
if len(sys.argv) > 2:
sys.exit('usage: generate_default_table.py [output.inc]')
text = generate()
if len(sys.argv) == 2:
with open(sys.argv[1], 'w', encoding='utf-8') as file:
file.write(text)
else:
sys.stdout.write(text)
return 0
if __name__ == '__main__':
sys.exit(main())
+116
View File
@@ -0,0 +1,116 @@
# Copyright 2026 DeepMind Technologies Limited
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Generates the keyword-map header from src/xml/mjcf.schema.
Emits src/xml/mjcf_map.h: one mjMap array and size constant per schema
enum, as C++17 inline variables, so the reader, the writer and the
generated tables share one definition with no extern declarations to
maintain. It is checked in and gated by test/doc/doc_test.py, which
regenerates it from the schema and diffs.
"""
import os
import sys
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _SCRIPT_DIR)
import mjcf_schema
_REPO_ROOT = os.path.dirname(os.path.dirname(_SCRIPT_DIR))
SCHEMA_PATH = os.path.join(_REPO_ROOT, 'src', 'xml', 'mjcf.schema')
_GUARD = 'MUJOCO_SRC_XML_MJCF_MAP_H_'
_INCLUDES = '''\
#include <mujoco/mjspec.h>
#include <mujoco/mjtype.h>
#include "user/user_composite.h"
#include "user/user_flexcomp.h"
#include "xml/xml_util.h"'''
_HEADER = f'''\
// Copyright 2026 DeepMind Technologies Limited
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// GENERATED FILE, DO NOT EDIT. Generated from src/xml/mjcf.schema by
// doc/generate/generate_mjcf_map.py; test/doc/doc_test.py checks freshness.
//
// Keyword maps, one per schema enum, shared by the reader, the writer and
// the generated tables. Inline variables: including this header is all a
// translation unit needs.
#ifndef HEADER_GUARD_PLACEHOLDER
#define HEADER_GUARD_PLACEHOLDER
{_INCLUDES}
// clang-format off
// keywords of the built-in bool type (not a schema enum)
inline constexpr mjMap bool_map[] = {{
{{"false", 0}},
{{"true", 1}},
}};
'''
_FOOTER = '''\
// clang-format on
#endif // HEADER_GUARD_PLACEHOLDER
'''
def generate() -> str:
schema = mjcf_schema.parse_file(SCHEMA_PATH)
out = []
for enum in schema.enums.values():
width = max(len(key) for key in enum.keywords()) + 3
out.append(f'// enum {enum.name}')
out.append(f'inline constexpr mjMap {enum.name}_map[] = {{')
for key, value in enum.items:
padded = f'"{key}",'.ljust(width + 1)
out.append(f' {{{padded} {value}}},')
out.append('};')
out.append(f'inline constexpr int {enum.name}_sz = {len(enum.items)};')
out.append('')
header = _HEADER.replace('HEADER_GUARD_PLACEHOLDER', _GUARD)
footer = _FOOTER.replace('HEADER_GUARD_PLACEHOLDER', _GUARD)
return header + '\n'.join(out) + footer
def main() -> int:
if len(sys.argv) > 2:
sys.exit('usage: generate_mjcf_map.py [output.h]')
text = generate()
if len(sys.argv) == 2:
with open(sys.argv[1], 'w', encoding='utf-8') as file:
file.write(text)
else:
sys.stdout.write(text)
return 0
if __name__ == '__main__':
sys.exit(main())
+451
View File
@@ -0,0 +1,451 @@
# Copyright 2026 DeepMind Technologies Limited
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Generates keyword maps and typed attribute-read rows from mjcf.schema.
Emits src/xml/mjcf_read_table.inc: per-element mjXAttr row arrays consumed by
mjXReader::ReadAttrTable, plus the kSensorDispatch tag table and shared group
arrays.
Which elements get rows is determined automatically: every schema element with
a bound spec struct and at least one table-drivable attribute is included,
unless it appears in NOT_TABLE_DRIVEN (elements whose OneX() readers have
custom logic). A coverage check in doc_test verifies that every eligible
element is accounted for.
Field offsets are emitted as offsetof() expressions, so binding mistakes are
compile errors, and the field's C type (parsed from mjspec.h) selects the row
kind, so mjtNum vs double is decided by the struct, not by the schema.
"""
import os
import re
import sys
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _SCRIPT_DIR)
import mjcf_schema
_REPO_ROOT = os.path.dirname(os.path.dirname(_SCRIPT_DIR))
SCHEMA_PATH = os.path.join(_REPO_ROOT, 'src', 'xml', 'mjcf.schema')
SPEC_H_PATH = os.path.join(_REPO_ROOT, 'include', 'mujoco', 'mjspec.h')
MODEL_H_PATH = os.path.join(_REPO_ROOT, 'include', 'mujoco', 'mjmodel.h')
# elements with a bound spec whose OneX() reader is NOT table-driven;
# their rows are not emitted. These are the genuinely irregular elements
# whose readers have custom logic that can't be factored into ReadAttrTable.
# If you add a new schema element with a bound spec, either migrate its
# reader to ReadAttrTable (it will be auto-included) or add it here.
NOT_TABLE_DRIVEN = {
# actuator shorthands: read shared rows + per-tag remappings
'motor', 'position', 'velocity', 'intvelocity', 'orientation',
'pid', 'damper', 'cylinder', 'muscle', 'adhesion', 'dcmotor',
'actuator_plugin',
# equality subtypes: read shared equality_base + per-type refs
'connect', 'weld', 'equality_joint', 'equality_tendon',
'equality_flex', 'flexvert', 'flexstrain',
# sensors with custom reading logic
'rangefinder', 'distance', 'user', 'normal', 'fromto',
'sensor_contact', 'sensor_plugin', 'tactile',
# other irregulars
'frame', 'plugin', 'numeric', 'text', 'tuple',
}
# sensors whose whole branch derives from the schema (identity constants +
# references); their arrays are also collected into kSensorDispatch
SENSOR_DISPATCH = [
'touch',
'accelerometer',
'velocimeter',
'gyro',
'force',
'torque',
'magnetometer',
'camprojection',
'jointpos',
'jointvel',
'tendonpos',
'tendonvel',
'actuatorpos',
'actuatorvel',
'actuatorfrc',
'jointactuatorfrc',
'tendonactuatorfrc',
'ballquat',
'ballangvel',
'jointlimitpos',
'jointlimitvel',
'jointlimitfrc',
'tendonlimitpos',
'tendonlimitvel',
'tendonlimitfrc',
'framepos',
'framequat',
'framexaxis',
'frameyaxis',
'framezaxis',
'framelinvel',
'frameangvel',
'framelinacc',
'frameangacc',
'insidesite',
'subtreecom',
'subtreelinvel',
'subtreeangmom',
'e_potential',
'e_kinetic',
'clock',
]
# shared groups emitted as standalone row arrays, for shared OneX() readers:
# group name -> (bound struct, emitted array name)
EMIT_GROUPS = {
'equality_base': ('mjsEquality', 'kEqualityBaseAttrs'),
'sensor_base': ('mjsSensor', 'kSensorBaseAttrs'),
}
# groups whose attributes are hand-read in the OneX() remnant;
# their rows are not emitted
HAND_GROUPS = ['orientation', 'transmission',
'sensor_base'] # read via kSensorBaseAttrs before dispatch
# mjspec.h array-dimension expressions equivalent to a numeric schema bound;
# the expression is emitted as the row length so it tracks the header
DIM_EQUIV = {'mjNPOLY+1': '3'}
# schema scalar/C-type -> mjXAttr kind
KIND_BY_CTYPE = {'mjString*': 'kString', 'int': 'kInt', 'double': 'kDouble',
'mjtNum': 'kNum', 'float': 'kFloat'}
_HEADER = '''\
// Copyright 2026 DeepMind Technologies Limited
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// GENERATED FILE, DO NOT EDIT. Generated from src/xml/mjcf.schema by
// doc/generate/generate_read_table.py; test/doc/doc_test.py checks freshness.
//
// Typed attribute rows for table-driven reading (mjXReader::ReadAttrTable),
// one array per migrated element. Rows are {attr, kind, len, exact,
// required, nodefault, handwrite, offset}; keyword maps are defined in
// mjcf_map.h.
// clang-format off
'''
def _has_table_attrs(schema, element):
"""Check if the element has at least one non-custom table-drivable attr."""
for member in element.members:
if isinstance(member, mjcf_schema.Attr) and 'reading' not in member.facets:
return True
if isinstance(member, mjcf_schema.Use):
group = schema.groups.get(member.group)
if group:
for gm in group.members:
if (isinstance(gm, mjcf_schema.Attr)
and 'reading' not in gm.facets):
return True
return False
def table_driven_elements(schema):
"""Return the list of element names whose readers are table-driven.
An element is table-driven if it has a bound spec struct, at least one
table-drivable attribute, and is not in NOT_TABLE_DRIVEN.
Args:
schema: Parsed schema object.
"""
result = []
for name, element in schema.elements.items():
if not element.spec:
continue
if not _has_table_attrs(schema, element):
continue
if name in NOT_TABLE_DRIVEN:
continue
result.append(name)
return result
def uncovered_elements(schema):
"""Return element names eligible for table-driving but not accounted for.
An element is "eligible" if it has a bound spec struct and at least one
table-drivable attribute. Every eligible element should be either
auto-included by table_driven_elements() or listed in NOT_TABLE_DRIVEN.
This function returns any that fall through the cracks — used by doc_test.
Args:
schema: Parsed schema object.
"""
eligible = {name for name, el in schema.elements.items()
if el.spec and _has_table_attrs(schema, el)}
auto = set(table_driven_elements(schema))
return eligible - auto - NOT_TABLE_DRIVEN
def parse_spec_structs(*paths):
"""Parse struct layouts from C headers.
Args:
*paths: Paths to C header files to parse.
Returns:
A dict mapping struct name -> {field: (ctype, dim-or-None)}.
Nested anonymous sub-structs (the mjVisual sections) are exposed as
'Outer.subname' pseudo-structs.
"""
structs = {}
field_re = re.compile(
r'^\s*([\w<>*]+(?:\s*\*)?)\s+(\w+)(\[([^\]]+)\])?\s*;', re.M)
for path in paths:
with open(path, encoding='utf-8') as f:
text = f.read()
for m in re.finditer(r'typedef struct (mj\w+)_ \{(.*?)\n\} \1;', text,
re.S):
name, body = m.group(1), m.group(2)
for sm in re.finditer(r'struct \{(.*?)\} (\w+);', body, re.S):
sub_fields = {}
for fm in field_re.finditer(sm.group(1)):
ctype, fname, _, dim = fm.groups()
sub_fields[fname] = (ctype.replace(' ', ''), dim)
structs[f'{name}.{sm.group(2)}'] = sub_fields
body = re.sub(r'struct \{.*?\} \w+;', '', body, flags=re.S)
fields = {}
for fm in field_re.finditer(body):
ctype, fname, _, dim = fm.groups()
fields[fname] = (ctype.replace(' ', ''), dim)
structs[name] = fields
return structs
def _attr_row(spec, fields, attr, ctx, prefix=''):
"""Return a row tuple for one attribute, or None if it has no row."""
required = 'true' if attr.facets.get('required') else 'false'
nodefault = 'true' if attr.facets.get('nodefault') else 'false'
handwrite = 'true' if attr.facets.get('writing') else 'false'
if attr.type == 'id' and attr.name == 'name':
# names are never settable in default classes
return (f'"{attr.name}"', 'mjXAttr::kName', '1', 'true', required,
'true', 'false', '-1')
if attr.type == 'ref' and attr.target == 'default':
return None # class selection is handled by the section parsers
if attr.type == 'file':
return None # files need VFS/asset-dir context: hand-read in the remnant
field = attr.facets.get('field', attr.name)
entry = fields.get(field)
if entry is None:
raise ValueError(f'{ctx}.{attr.name}: no field {spec}.{field}')
ctype, dim = entry
if attr.type in ('string', 'file', 'ref', 'id'):
if ctype == 'mjStringVec*':
kind, length, exact = 'kStringVec', '1', 'true'
elif ctype != 'mjString*':
raise ValueError(f'{ctx}.{attr.name}: bound to non-string '
f'field {field} ({ctype})')
else:
kind, length, exact = 'kString', '1', 'true'
elif attr.type == 'enum':
if not ctype.startswith('mjt') and ctype != 'int':
raise ValueError(f'{ctx}.{attr.name}: enum bound to '
f'field {field} ({ctype})')
kind = 'kEnumByte' if ctype in ('mjtByte', 'mjtBool') else 'kEnum'
length, exact = '1', 'true'
elif attr.type == 'flags':
if ctype != 'int':
raise ValueError(f'{ctx}.{attr.name}: flags bound to '
f'field {field} ({ctype})')
kind, length, exact = 'kFlags', '1', 'true'
elif attr.type == 'bool':
if ctype in ('mjtBool', 'mjtByte'):
kind, length, exact = 'kBool', '1', 'true'
elif ctype == 'int' or ctype.startswith('mjt'):
# int-typed flag: read through the keyword map (values are 0/1)
row = [f'"{attr.name}"', 'mjXAttr::kEnum', '1', 'true', required,
nodefault, handwrite,
f'(int)offsetof({spec}, {prefix}{field})', 'bool_map', '2']
return tuple(row)
else:
raise ValueError(f'{ctx}.{attr.name}: bool bound to '
f'field {field} ({ctype})')
elif attr.type == 'chars':
if ctype != 'char' or dim is None:
raise ValueError(f'{ctx}.{attr.name}: chars bound to '
f'field {field} ({ctype})')
lo, hi = attr.arity.lo, attr.arity.hi
if str(dim) != str(hi):
raise ValueError(f'{ctx}.{attr.name}: chars arity {hi} vs '
f'field dim {dim}')
kind, length = 'kChars', str(hi)
exact = 'true' if lo == hi else 'false'
elif attr.type in ('double', 'float', 'int') and attr.arity.hi is None:
vec_kinds = {'mjDoubleVec*': 'kDoubleVec', 'mjFloatVec*': 'kFloatVec',
'mjIntVec*': 'kIntVec'}
kind = vec_kinds.get(ctype)
if kind is None:
raise ValueError(f'{ctx}.{attr.name}: unbounded vector '
f'bound to field {field} ({ctype})')
length, exact = '1', 'true'
elif attr.type in ('double', 'float', 'int'):
kind = KIND_BY_CTYPE.get(ctype)
if kind is None or (attr.type == 'int') != (kind == 'kInt'):
raise ValueError(f'{ctx}.{attr.name}: schema type '
f'{attr.type} vs field {field} ({ctype})')
lo, hi = attr.arity.lo, attr.arity.hi
length = str(hi)
declared = dim if dim is not None else '1'
if str(declared) != length:
if DIM_EQUIV.get(str(declared)) == length:
length = str(declared) # emit the expression: it tracks the header
else:
raise ValueError(f'{ctx}.{attr.name}: arity {length} vs '
f'field dim {declared}')
exact = 'true' if lo == hi or (lo == 1 and hi == 1) else 'false'
else:
raise ValueError(f'{ctx}.{attr.name}: kind {attr.type} is '
'not table-drivable yet')
offset = f'(int)offsetof({spec}, {prefix}{field})'
row = [f'"{attr.name}"', f'mjXAttr::{kind}', length, exact,
required, nodefault, handwrite, offset]
if kind in ('kEnum', 'kEnumByte', 'kFlags'):
row += [f'{attr.target}_map', f'{attr.target}_sz']
return tuple(row)
def rows_for(schema, structs, element_name):
"""Return (struct name, list of row tuples) for one table-driven element."""
element = schema.elements[element_name]
if not element.spec:
raise ValueError(f'{element_name}: no bound spec struct')
sub = element.facets.get('field')
key = f'{element.spec}.{sub}' if sub else element.spec
fields = structs.get(key)
if fields is None:
raise ValueError(f'{element_name}: struct {key} not found in headers')
prefix = f'{sub}.' if sub else ''
hand_attrs = set()
for gname in HAND_GROUPS:
if any(isinstance(m, mjcf_schema.Use) and m.group == gname
for m in element.members):
hand_attrs |= {m.name for m in schema.groups[gname].members}
rows = []
for const in element.consts():
entry = fields.get(const.field)
if entry is None:
raise ValueError(f'{element_name}: set {const.field}: no field '
f'{element.spec}.{const.field}')
ctype = entry[0]
if not ctype.startswith('mjt') and ctype != 'int':
raise ValueError(f'{element_name}: set {const.field}: field is {ctype}')
rows.append(('nullptr', 'mjXAttr::kConst', '1', 'true', 'false', 'false',
'false',
f'(int)offsetof({element.spec}, {prefix}{const.field})',
'nullptr', '0', const.value))
for attr in schema.expanded_attrs(element):
if attr.name in hand_attrs or 'reading' in attr.facets:
continue # hand-read in the OneX() remnant
row = _attr_row(element.spec, fields, attr, element_name,
prefix=prefix)
if row is not None:
rows.append(row)
return element.spec, rows
def rows_for_group(schema, structs, group_name, spec):
"""Return list of row tuples for a shared group, bound to the given struct."""
fields = structs.get(spec)
if fields is None:
raise ValueError(f'{group_name}: struct {spec} not in mjspec.h')
rows = []
for attr in schema.groups[group_name].members:
if not isinstance(attr, mjcf_schema.Attr) or 'reading' in attr.facets:
continue
row = _attr_row(spec, fields, attr, group_name)
if row is not None:
rows.append(row)
return rows
def generate():
"""Generate the mjcf_read_table.inc content as a string."""
schema = mjcf_schema.parse_file(SCHEMA_PATH)
structs = parse_spec_structs(SPEC_H_PATH, MODEL_H_PATH)
migrated = table_driven_elements(schema)
out = [_HEADER]
for name in migrated:
struct, rows = rows_for(schema, structs, name)
array = f'k{name.capitalize()}Attrs'
out.append(f'// {name} ({struct})')
out.append(f'inline constexpr mjXAttr {array}[] = {{')
for row in rows:
out.append(' {' + ', '.join(row) + '},')
out.append('};')
out.append(f'inline constexpr int {array}N = '
f'sizeof({array}) / sizeof({array}[0]);')
out.append('')
out.append('// sensors fully described by the schema: dispatch by tag')
out.append('struct mjXSensorEntry { const char* tag; const mjXAttr* rows;'
' int n; };')
out.append('inline constexpr mjXSensorEntry kSensorDispatch[] = {')
for name in SENSOR_DISPATCH:
array = f'k{name.capitalize()}Attrs'
tag = schema.elements[name].xml_name()
out.append(f' {{"{tag}", {array}, {array}N}},')
out.append('};')
out.append('inline constexpr int kSensorDispatchN = '
'sizeof(kSensorDispatch) / sizeof(kSensorDispatch[0]);')
out.append('')
for gname, (struct, array) in EMIT_GROUPS.items():
rows = rows_for_group(schema, structs, gname, struct)
out.append(f'// group {gname} ({struct})')
out.append(f'inline constexpr mjXAttr {array}[] = {{')
for row in rows:
out.append(' {' + ', '.join(row) + '},')
out.append('};')
out.append(f'inline constexpr int {array}N = '
f'sizeof({array}) / sizeof({array}[0]);')
out.append('')
out.append('// clang-format on')
return '\n'.join(out) + '\n'
def main():
"""CLI entry point: generate mjcf_read_table.inc to stdout or a file."""
if len(sys.argv) > 2:
sys.exit('usage: generate_read_table.py [output.inc]')
text = generate()
if len(sys.argv) == 2:
with open(sys.argv[1], 'w', encoding='utf-8') as file:
file.write(text)
else:
sys.stdout.write(text)
return 0
if __name__ == '__main__':
sys.exit(main())