Skip to content

Commit e7f880f

Browse files
committed
feat(eht): generate native QuickJS bindings from the same reflection source
EHT already emits the web binding surface (WebBindings.generated.cpp via embind) and the TS side from one C++ reflection pass. Add a native sibling so a native (embedded-Dawn + JS-engine) host gets its bindings from the SAME source and the web / native surfaces cannot drift — the WGSL-twin / single-PlatformAdapter philosophy applied to the SDK->engine boundary. NativeBindingsGenerator emits, per ES_COMPONENT, a QuickJS-callable es_set_<Component>(entityId, obj) that writes the reflected scalar / vec / bool / enum fields (handles, entity refs, vectors and quats are skipped — they need a resource-aware binding), plus an esn_register() that installs them as globals. It reuses TypeSystem so every component classifies exactly as it does for embind. Opt-in and inert by default: only emitted when `python -m eht --native-output PATH` is passed (with optional --native-components / --native-shim), so the standard `eht` run and its committed *.generated.* files are byte-identical (verified — git clean after a full regen) and CI's freshness gate is untouched. A native build invokes it to emit NativeBindings.generated.cpp into its build tree. Proven end-to-end on device: a QuickJS game script setting components through the generated bindings renders a real ECS scene via native Dawn/Vulkan.
1 parent 7c0b5f7 commit e7f880f

3 files changed

Lines changed: 112 additions & 2 deletions

File tree

tools/eht/__main__.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from .abi import compute_abi_hash
99
from .generators import (
1010
EmbindGenerator, TypeScriptGenerator, MetadataGenerator,
11-
PtrLayoutGenerator, EditorAPIGenerator,
11+
PtrLayoutGenerator, EditorAPIGenerator, NativeBindingsGenerator,
1212
)
1313

1414

@@ -23,6 +23,15 @@ def main() -> int:
2323
parser.add_argument('--ts-output', type=Path, default=Path('sdk'),
2424
help='Output directory for TypeScript')
2525
parser.add_argument('--verbose', '-v', action='store_true')
26+
# Opt-in native (QuickJS) bindings. Off by default so the standard EHT run and
27+
# its committed *.generated.* files are unchanged; a native build passes this
28+
# to emit NativeBindings.generated.cpp into its build tree from the same source.
29+
parser.add_argument('--native-output', type=Path, default=None,
30+
help='Also emit native QuickJS bindings to this .cpp path')
31+
parser.add_argument('--native-components', type=str, default=None,
32+
help='Comma-separated component names to emit (default: all)')
33+
parser.add_argument('--native-shim', type=str, default='esn_shim.hpp',
34+
help='Shim header the generated native TU includes')
2635
args = parser.parse_args()
2736

2837
print("EHT - ESEngine Header Tool")
@@ -85,6 +94,19 @@ def main() -> int:
8594
)
8695
embind_path.write_text(embind_gen.generate(), encoding='utf-8')
8796

97+
# ── Native QuickJS Bindings (opt-in) ──
98+
if args.native_output is not None:
99+
only = None
100+
if args.native_components:
101+
only = {n.strip() for n in args.native_components.split(',') if n.strip()}
102+
args.native_output.parent.mkdir(parents=True, exist_ok=True)
103+
print(f"Generating: {args.native_output}")
104+
native_gen = NativeBindingsGenerator(
105+
cpp_parser.components, cpp_parser.enums,
106+
shim_header=args.native_shim, only=only,
107+
)
108+
args.native_output.write_text(native_gen.generate(), encoding='utf-8')
109+
88110
# Resolve the TS source directory robustly. Callers historically pass either
89111
# the package root (`sdk`) or the source dir (`sdk/src`); detect which by
90112
# looking for the `ecs/` subfolder. Previously a `sdk/src` argument made every

tools/eht/generators/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
from .metadata import MetadataGenerator
66
from .ptr_layout import PtrLayoutGenerator
77
from .editor_api import EditorAPIGenerator
8+
from .native_bindings import NativeBindingsGenerator
89

910
__all__ = [
1011
'EmbindGenerator', 'TypeScriptGenerator', 'MetadataGenerator',
11-
'PtrLayoutGenerator', 'EditorAPIGenerator',
12+
'PtrLayoutGenerator', 'EditorAPIGenerator', 'NativeBindingsGenerator',
1213
]
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Native (QuickJS) binding generator — the native sibling of the embind generator.
2+
3+
Emits, per ES_COMPONENT, a QuickJS-callable ``es_set_<Component>(entityId, obj)``
4+
that writes the reflected scalar / vec / bool / enum fields, plus an
5+
``esn_register()`` that installs them as script globals. It consumes the SAME
6+
parsed reflection (data.Component) as EmbindGenerator, so the web (embind) and
7+
native (QuickJS) binding surfaces are generated from one source and cannot drift.
8+
9+
Opt-in: only emitted when ``python -m eht --native-output PATH`` is given, so the
10+
default EHT run (and the committed *.generated.* files it produces) is unchanged.
11+
The generated TU relies on a host-provided shim header (default ``esn_shim.hpp``)
12+
for the ``esn_*`` readers, the entity lookup, and the component includes.
13+
"""
14+
15+
from typing import List
16+
from ..data import Component, Enum
17+
from ..type_system import TypeSystem
18+
19+
# Numeric primitives written straight through (via a double), by cleaned type.
20+
_FLOAT = {'f32', 'f64', 'float', 'double'}
21+
_INT = {'i8', 'i16', 'i32', 'i64', 'u8', 'u16', 'u32', 'u64',
22+
'int', 'int8_t', 'int16_t', 'int32_t', 'int64_t',
23+
'uint8_t', 'uint16_t', 'uint32_t', 'uint64_t', 'size_t', 'char'}
24+
# glm vector arities the reader marshals (float lanes). quat/uvec2 skipped:
25+
# quaternion storage order is fragile to hand-write, uvec2 is unsigned.
26+
_GLM_ARITY = {'glm::vec2': 2, 'glm::vec3': 3, 'glm::vec4': 4}
27+
28+
29+
class NativeBindingsGenerator:
30+
def __init__(self, components: List[Component], enums: List[Enum],
31+
shim_header: str = 'esn_shim.hpp', only=None):
32+
# ``only`` (a set of component names) narrows the emitted surface so a host
33+
# that includes only some component headers still compiles; None = all.
34+
self.components = [c for c in components if only is None or c.name in only]
35+
self.enums = enums
36+
self.types = TypeSystem(enums)
37+
self.shim_header = shim_header
38+
39+
def _field(self, prop) -> str:
40+
t = prop.cpp_type
41+
n = prop.name
42+
ct = self.types.clean_type(t)
43+
if ct in _GLM_ARITY:
44+
return f' esn_getvec(ctx, o, "{n}", &c.{n}.x, {_GLM_ARITY[ct]});'
45+
if ct == 'bool':
46+
return f' {{ int _b; if (esn_getbool(ctx, o, "{n}", &_b)) c.{n} = _b != 0; }}'
47+
if self.types.is_enum(t) or ct in _INT:
48+
return (f' {{ double _v; if (esn_getnum(ctx, o, "{n}", &_v)) '
49+
f'c.{n} = static_cast<decltype(c.{n})>(static_cast<long long>(_v)); }}')
50+
if ct in _FLOAT:
51+
return (f' {{ double _v; if (esn_getnum(ctx, o, "{n}", &_v)) '
52+
f'c.{n} = static_cast<decltype(c.{n})>(_v); }}')
53+
return f' // skip {n}: {t} (handle / entity / vector / struct — needs a resource-aware binding)'
54+
55+
def _component(self, comp: Component) -> List[str]:
56+
full = f'{comp.namespace}::{comp.name}' if comp.namespace else comp.name
57+
out = [f'static JSValue es_set_{comp.name}(JSContext* ctx, JSValueConst, int argc, JSValueConst* argv) {{',
58+
' if (argc < 2) return JS_UNDEFINED;',
59+
' esengine::Entity e = esn_entity(ctx, argv[0]);',
60+
f' auto& c = esn_reg().getOrEmplace<{full}>(e);',
61+
' JSValueConst o = argv[1];',
62+
' (void)c; (void)o;']
63+
for prop in comp.properties:
64+
out.append(self._field(prop))
65+
out.append(' return JS_UNDEFINED;')
66+
out.append('}')
67+
out.append('')
68+
return out
69+
70+
def generate(self) -> str:
71+
lines = [
72+
'// Auto-generated by EHT (native_bindings) — DO NOT EDIT.',
73+
'// Native QuickJS sibling of WebBindings.generated.cpp — one reflection source.',
74+
f'#include "{self.shim_header}"',
75+
'',
76+
]
77+
components = sorted(self.components, key=lambda c: c.name)
78+
for comp in components:
79+
lines.extend(self._component(comp))
80+
lines.append('void esn_register(JSContext* ctx, JSValue global) {')
81+
for comp in components:
82+
fn = f'es_set_{comp.name}'
83+
lines.append(f' JS_SetPropertyStr(ctx, global, "{fn}", '
84+
f'JS_NewCFunction(ctx, {fn}, "{fn}", 2));')
85+
lines.append('}')
86+
lines.append('')
87+
return '\n'.join(lines)

0 commit comments

Comments
 (0)