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
49 changes: 48 additions & 1 deletion example/scripts/tests/runtime_integration_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import v8 from "node:v8";
import vm from "node:vm";
import { Color, Engine, GD, GDArray, GDDictionary, GDString, GodotObject, Image, ImageTexture, Node, PackedInt32Array, PackedScene, PackedStringArray, PackedVector3Array, Resource, ResourceLoader, ResourceSaver, type VariantArgument, Vector2, Vector2i, Vector3 } from "godot";
import * as GodotModule from "godot";
import { Color, Engine, GD, GDArray, GDDictionary, GDString, GodotObject, Image, ImageTexture, Node, PackedInt32Array, PackedScene, PackedStringArray, PackedVector3Array, PropertyHint, PropertyHint as PropertyHintAlias, Resource, ResourceLoader, ResourceLoader_CacheMode, ResourceSaver, type VariantArgument, VariantType, Vector2, Vector2i, Vector3 } from "godot";
import cjsFixture, { makeCommonPayload } from "./commonjs_fixture.cjs";
import type RuntimeArrayResource from "./runtime_array_resource.js";
import type RuntimeExternalResource from "./runtime_external_resource.js";
Expand All @@ -26,6 +27,7 @@ const VARIANT_TYPE_DICTIONARY = 27;
const VARIANT_TYPE_ARRAY = 28;
const VARIANT_TYPE_OBJECT = 24;
const PROPERTY_HINT_NONE = 0;
const PROPERTY_HINT_RANGE = 1;
const PROPERTY_HINT_ENUM = 2;
const PROPERTY_HINT_TYPE_STRING = 23;
const PROPERTY_HINT_ARRAY_TYPE = 31;
Expand Down Expand Up @@ -69,6 +71,8 @@ class RuntimeIntegrationTest extends RuntimeBaseModule.RuntimeIntegrationBase {
"static_imported_resource_array": { "type": "RuntimeExportTypes.RuntimeImportedResourceArray", "default": [] as const },
"static_imported_generic_dictionary": { "type": "RuntimeImportedGenericDictionary<RuntimeArrayResource>", "default": {} as const },
"static_imported_generic_external_array": { "type": "RuntimeImportedGenericArray<RuntimeExternalResource>", "default": [] as const },
"static_range_alias": { "type": "float", "hint": PropertyHintAlias.PROPERTY_HINT_RANGE, "hint_string": "0,8,0.5", "default": 2 as const },
"static_range_namespace": { "type": "float", "hint": GodotModule.PropertyHint.PROPERTY_HINT_RANGE, "hint_string": "0,4,1", "default": 3 as const },
} satisfies ExportMap;

label = "runtime" as string;
Expand All @@ -84,6 +88,8 @@ class RuntimeIntegrationTest extends RuntimeBaseModule.RuntimeIntegrationBase {
static_imported_resource_array = [] as RuntimeExportTypes.RuntimeImportedResourceArray;
static_imported_generic_dictionary = {} as Record<string, RuntimeArrayResource>;
static_imported_generic_external_array = new GDArray() as RuntimeImportedGenericArray<RuntimeExternalResource>;
static_range_alias = 2 as number;
static_range_namespace = 3 as number;

@Export()
resource_slot: Resource | null = null;
Expand Down Expand Up @@ -166,6 +172,15 @@ class RuntimeIntegrationTest extends RuntimeBaseModule.RuntimeIntegrationBase {
@Export({ hint: 20, hint_string: "manual enum hint" })
editor_explicit_hint_enum: "first" | "second" = "first";

@Export({ hint: PropertyHint.PROPERTY_HINT_RANGE, hint_string: "0.1,16,0.1,or_greater" })
editor_range: number = 1;

@Export({ hint: PropertyHintAlias.PROPERTY_HINT_RANGE, hint_string: "0,8,0.5" })
editor_range_alias: number = 2;

@Export(GodotModule.PropertyHint.PROPERTY_HINT_RANGE, "0,4,1")
editor_range_namespace: number = 3;

run_test(): void {
void this.run();
}
Expand All @@ -187,6 +202,13 @@ class RuntimeIntegrationTest extends RuntimeBaseModule.RuntimeIntegrationBase {
nodeAssert.equal(moduleMarker, "esm-runtime-helper");
nodeAssert.equal(path.posix.basename("res://scripts/tests/runtime_integration_test.ts"), "runtime_integration_test.ts");
nodeAssert.equal(ResourceLoader.exists(RUNTIME_ARRAY_RESOURCE_SCRIPT_PATH, "TypeScriptScript"), true);
nodeAssert.equal(Number(PropertyHint.PROPERTY_HINT_RANGE), PROPERTY_HINT_RANGE);
nodeAssert.equal(String(PropertyHint[PROPERTY_HINT_RANGE]), "PROPERTY_HINT_RANGE");
nodeAssert.equal(Number(PropertyHintAlias.PROPERTY_HINT_RANGE), PROPERTY_HINT_RANGE);
nodeAssert.equal(Number(GodotModule.PropertyHint.PROPERTY_HINT_RANGE), PROPERTY_HINT_RANGE);
nodeAssert.equal(Number(VariantType.TYPE_FLOAT), VARIANT_TYPE_FLOAT);
nodeAssert.equal(Number(ResourceLoader_CacheMode.CACHE_MODE_REUSE), 1);
nodeAssert.equal(String(ResourceLoader_CacheMode[1]), "CACHE_MODE_REUSE");

const esmPayload = buildRuntimePayload("alpha");
nodeAssert.deepEqual(esmPayload.values, [1, 2, 3]);
Expand Down Expand Up @@ -369,6 +391,9 @@ class RuntimeIntegrationTest extends RuntimeBaseModule.RuntimeIntegrationBase {
"editor_mixed_union",
"editor_mixed_object_union",
"editor_explicit_hint_enum",
"editor_range",
"editor_range_alias",
"editor_range_namespace",
"static_resource_default_first",
"static_image",
"static_number_array",
Expand All @@ -378,6 +403,8 @@ class RuntimeIntegrationTest extends RuntimeBaseModule.RuntimeIntegrationBase {
"static_imported_resource_array",
"static_imported_generic_dictionary",
"static_imported_generic_external_array",
"static_range_alias",
"static_range_namespace",
"inherited_label",
"inherited_count",
];
Expand Down Expand Up @@ -440,6 +467,26 @@ class RuntimeIntegrationTest extends RuntimeBaseModule.RuntimeIntegrationBase {
nodeAssert.equal(Number(explicitHintEnumProperty.type), VARIANT_TYPE_STRING);
nodeAssert.equal(Number(explicitHintEnumProperty.hint), 20);
nodeAssert.equal(String(explicitHintEnumProperty.hint_string), "manual enum hint");
const rangeProperty = getExportProperty("editor_range");
nodeAssert.equal(Number(rangeProperty.type), VARIANT_TYPE_FLOAT);
nodeAssert.equal(Number(rangeProperty.hint), PROPERTY_HINT_RANGE);
nodeAssert.equal(String(rangeProperty.hint_string), "0.1,16,0.1,or_greater");
const rangeAliasProperty = getExportProperty("editor_range_alias");
nodeAssert.equal(Number(rangeAliasProperty.type), VARIANT_TYPE_FLOAT);
nodeAssert.equal(Number(rangeAliasProperty.hint), PROPERTY_HINT_RANGE);
nodeAssert.equal(String(rangeAliasProperty.hint_string), "0,8,0.5");
const rangeNamespaceProperty = getExportProperty("editor_range_namespace");
nodeAssert.equal(Number(rangeNamespaceProperty.type), VARIANT_TYPE_FLOAT);
nodeAssert.equal(Number(rangeNamespaceProperty.hint), PROPERTY_HINT_RANGE);
nodeAssert.equal(String(rangeNamespaceProperty.hint_string), "0,4,1");
const staticRangeAliasProperty = getExportProperty("static_range_alias");
nodeAssert.equal(Number(staticRangeAliasProperty.type), VARIANT_TYPE_FLOAT);
nodeAssert.equal(Number(staticRangeAliasProperty.hint), PROPERTY_HINT_RANGE);
nodeAssert.equal(String(staticRangeAliasProperty.hint_string), "0,8,0.5");
const staticRangeNamespaceProperty = getExportProperty("static_range_namespace");
nodeAssert.equal(Number(staticRangeNamespaceProperty.type), VARIANT_TYPE_FLOAT);
nodeAssert.equal(Number(staticRangeNamespaceProperty.hint), PROPERTY_HINT_RANGE);
nodeAssert.equal(String(staticRangeNamespaceProperty.hint_string), "0,4,1");

const exportedResource = new Resource();
exportedResource.resource_name = "PersistentRuntimeResource";
Expand Down
53 changes: 15 additions & 38 deletions generator/dts_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@
from .base_generator import CodeGenerator
from .utils.api_data import load_extension_api_json
from .utils.binding_policy import (
GLOBAL_ENUM_ALIASES,
builtin_operator_method_name,
global_enum_export_name,
method_conflicts_with_builtin_member,
resolve_property_accessor,
sanitize_ts_identifier,
skipped_method_reason,
singleton_enum_export_name,
)
from .utils.type_mappings import (
JS_CLASS_RENAME_MAP,
Expand Down Expand Up @@ -36,27 +40,9 @@
# Builtin classes that map directly to JS primitives — skip class generation
SKIP_BUILTINS = frozenset(['Nil', 'void', 'bool', 'int', 'float'])

# Global enums to skip — exported below under JS-friendly aliases.
SKIP_GLOBAL_ENUMS = frozenset(['Variant.Type', 'Variant.Operator'])
VARIANT_ENUM_ALIASES = {
'Variant.Type': 'VariantType',
'Variant.Operator': 'VariantOperator',
}

# Rename map: Godot name → JS/TS API name (avoids conflicts with JS built-ins)
RENAME_MAP = JS_CLASS_RENAME_MAP

# Method/param names that are reserved in TypeScript/JS
TS_RESERVED = frozenset([
'constructor', 'delete', 'class', 'new', 'return', 'typeof',
'void', 'function', 'var', 'let', 'const', 'if', 'else',
'for', 'while', 'break', 'continue', 'switch', 'case',
'default', 'import', 'export', 'from', 'extends', 'super',
'this', 'static', 'in', 'of', 'instanceof',
'throw', 'try', 'catch', 'finally', 'async', 'await',
'yield', 'debugger', 'with', 'enum',
])

UTILITY_RETURN_TYPE_OVERRIDES = {
'instance_from_id': 'GodotObject | null',
'typeof': 'VariantType',
Expand All @@ -70,15 +56,12 @@


def sanitize_name(name: str) -> str:
name = name.replace('-', '_')
if name in TS_RESERVED:
return name + '_gd'
return name
return sanitize_ts_identifier(name)


def member_name(name: str) -> str:
name = name.replace('-', '_')
if name in TS_RESERVED:
if sanitize_ts_identifier(name) != name:
return json.dumps(name)
return name

Expand Down Expand Up @@ -479,8 +462,7 @@ def _gen_class(self, cls_data: dict, indent: int, is_singleton: bool = False) ->
enum,
indent,
export=True,
const=True,
name=f'{name}_{sanitize_name(enum["name"])}',
name=singleton_enum_export_name(name, enum["name"]),
)
lines.append('')

Expand Down Expand Up @@ -597,15 +579,18 @@ def _gen_utility_functions(self, api: dict, indent: int) -> list:
self._append_unique_line(lines, seen, f'{ind}{ind}{name}({params}): {ret};')
return lines

def _gen_variant_alias_enums(self, api: dict) -> list:
def _gen_global_enums(self, api: dict) -> list:
global_enums = {enum['name']: enum for enum in api.get('global_enums', [])}
missing = sorted(name for name in VARIANT_ENUM_ALIASES if name not in global_enums)
missing = sorted(name for name in GLOBAL_ENUM_ALIASES if name not in global_enums)
if missing:
raise KeyError(f"extension_api.json missing Variant enum(s): {', '.join(missing)}")

lines = []
for source_name, alias_name in VARIANT_ENUM_ALIASES.items():
lines += self._gen_enum(global_enums[source_name], indent=1, export=True, const=True, name=alias_name)
for enum in api.get('global_enums', []):
export_name = global_enum_export_name(enum['name'])
if not export_name:
continue
lines += self._gen_enum(enum, indent=1, export=True, name=export_name)
lines.append('')
return lines

Expand Down Expand Up @@ -704,15 +689,7 @@ def _generate(self, api: dict) -> list:
'',
]

# Global enums
for enum in api.get('global_enums', []):
if enum['name'] in SKIP_GLOBAL_ENUMS:
continue
lines += self._gen_enum(enum, indent=1, export=True, const=True)
lines.append('')

# Variant enums are exported under JS-friendly aliases since VariantBinding is not generated.
lines += self._gen_variant_alias_enums(api)
lines += self._gen_global_enums(api)

# Builtin classes (Vector2, Color, …)
for cls in api.get('builtin_classes', []):
Expand Down
43 changes: 40 additions & 3 deletions generator/register_generator.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
from .base_generator import CodeGenerator
from .utils.api_data import load_extension_api_json
from .utils.binding_policy import global_enum_export_name, singleton_enum_export_name
from .utils.string_utils import to_snake_case
from .utils.type_mappings import JS_CLASS_RENAME_MAP

def enum_variable_name(name):
variable_name = to_snake_case(name)
while "__" in variable_name:
variable_name = variable_name.replace("__", "_")
return variable_name


class RegisterGenerator(CodeGenerator):
def run(self):
api_data = load_extension_api_json(required_keys=("builtin_classes", "classes"))
api_data = load_extension_api_json(required_keys=("builtin_classes", "classes", "global_enums"))

builtins = []
classes = []
Expand Down Expand Up @@ -35,9 +44,36 @@ def run(self):
'include': f"classes/{snake_name}_binding.gen.h"
})

global_enums = []
for enum_def in api_data['global_enums']:
export_name = global_enum_export_name(enum_def['name'])
if not export_name:
continue
global_enums.append({
'name': export_name,
'variable_name': enum_variable_name(export_name),
'values': enum_def.get('values', []),
})

singleton_names = {s['name'] for s in api_data.get('singletons', [])}
singleton_enum_aliases = []
for class_def in api_data['classes']:
class_name = class_def['name']
if class_name not in singleton_names:
continue
owner_name = JS_CLASS_RENAME_MAP.get(class_name, class_name)
for enum_def in class_def.get('enums', []):
export_name = singleton_enum_export_name(owner_name, enum_def['name'])
singleton_enum_aliases.append({
'name': export_name,
'variable_name': enum_variable_name(export_name),
'values': enum_def.get('values', []),
})

context = {
'builtins': builtins,
'classes': [] # We don't want to register classes globally anymore
'classes': [], # We don't want to register classes globally anymore
'global_enums': global_enums,
}

# Generate Header
Expand All @@ -63,7 +99,8 @@ def run(self):

context = {
'classes': classes,
'singletons': singletons
'singletons': singletons,
'singleton_enum_aliases': singleton_enum_aliases,
}
self.render('register_classes.h.jinja2', context, 'register_classes.gen.h', 'include_dir')
self.render('register_classes.cpp.jinja2', context, 'register_classes.gen.cpp', 'src_dir')
12 changes: 12 additions & 0 deletions generator/templates/register_builtin.cpp.jinja2
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#include "register_builtin.gen.h"
#include "runtime/value_convert.h"

// Builtins
{% for item in builtins %}
Expand All @@ -12,6 +13,17 @@ void register_builtin(Napi::Env env, Napi::Object exports) {
{% for item in builtins %}
{{ item.class_name }}Binding::init(env, exports);
{% endfor %}

// Register global enums as runtime objects so they remain usable when
// TypeScript isolatedModules disables ambient const enum access.
{% for enum in global_enums %}
Napi::Object {{ enum.variable_name }} = Napi::Object::New(env);
{% for value in enum['values'] %}
{{ enum.variable_name }}.Set("{{ value['name'] }}", gode::godot_result_to_napi(env, {{ value['value'] }}));
{{ enum.variable_name }}.Set(Napi::Number::New(env, {{ value['value'] }}), Napi::String::New(env, "{{ value['name'] }}"));
{% endfor %}
exports.Set("{{ enum.name }}", {{ enum.variable_name }});
{% endfor %}
}

void reset_builtin_references() {
Expand Down
11 changes: 11 additions & 0 deletions generator/templates/register_classes.cpp.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ void register_classes(Napi::Env env, Napi::Object exports) {
}
{% endif %}
{% endfor %}

// Register singleton enum aliases as module-level runtime objects. The DTS
// exposes these aliases because singleton instances cannot host namespaces.
{% for enum in singleton_enum_aliases %}
Napi::Object {{ enum.variable_name }} = Napi::Object::New(env);
{% for value in enum['values'] %}
{{ enum.variable_name }}.Set("{{ value['name'] }}", gode::godot_result_to_napi(env, {{ value['value'] }}));
{{ enum.variable_name }}.Set(Napi::Number::New(env, {{ value['value'] }}), Napi::String::New(env, "{{ value['name'] }}"));
{% endfor %}
exports.Set("{{ enum.name }}", {{ enum.variable_name }});
{% endfor %}
}

void reset_class_references() {
Expand Down
34 changes: 34 additions & 0 deletions generator/utils/binding_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@
("VideoStreamPlayback", "mix_audio"): (1,),
}

TS_RESERVED_NAMES = frozenset([
'constructor', 'delete', 'class', 'new', 'return', 'typeof',
'void', 'function', 'var', 'let', 'const', 'if', 'else',
'for', 'while', 'break', 'continue', 'switch', 'case',
'default', 'import', 'export', 'from', 'extends', 'super',
'this', 'static', 'in', 'of', 'instanceof',
'throw', 'try', 'catch', 'finally', 'async', 'await',
'yield', 'debugger', 'with', 'enum',
])

# Godot exposes these as dotted global enum names. The JS/TS surface uses stable
# module-level aliases because dotted identifiers are not valid exports.
GLOBAL_ENUM_ALIASES = {
'Variant.Type': 'VariantType',
'Variant.Operator': 'VariantOperator',
}

VARIANT_OPERATOR_ENUM_NAMES = {
'==': 'OP_EQUAL',
'!=': 'OP_NOT_EQUAL',
Expand Down Expand Up @@ -127,3 +144,20 @@ def variant_operator_enum_name(operator_symbol: str) -> Optional[str]:

def method_bind_out_argument_indices(class_name: str, method: dict) -> Sequence[int]:
return METHOD_BIND_OUT_ARGUMENTS.get((class_name, method.get("name", "")), ())


def sanitize_ts_identifier(name: str) -> str:
name = name.replace('-', '_')
if name in TS_RESERVED_NAMES:
return name + '_gd'
return name


def global_enum_export_name(enum_name: str) -> Optional[str]:
if enum_name in GLOBAL_ENUM_ALIASES:
return GLOBAL_ENUM_ALIASES[enum_name]
return sanitize_ts_identifier(enum_name)


def singleton_enum_export_name(owner_name: str, enum_name: str) -> str:
return f'{sanitize_ts_identifier(owner_name)}_{sanitize_ts_identifier(enum_name)}'
Loading