feat(input,ecs): #79 Input.axis_i directional int + #84 world bounds
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 23s
ci / build-and-test (push) Successful in 2m23s
commit-lint / conventional-commits (push) Successful in 5s
docs / build-and-deploy (push) Successful in 28s

#79 — Input.axis_i(neg,pos) -> int returns a -1/0/1 movement intent from the
multi-key device set, so WASD-to-movement needs no bool->int glue and feeds an
int mover directly (dx = Input.axis_i('a','d')).

#84 — a Bounds config entity (rect + policy, from ludic.core) drives the
engine-owned world-bounds system (LateUpdate): clamp / wrap / bounce (clamp +
flip Body velocity) / kill (despawn a body fully outside). Reads the Collider
size so the box stays inside; off by default, spliced only when Bounds is
declared (byte-identical otherwise). Adds World.despawn(e) — the by-id
reflective despawn (runs @OnDespawn + frees) via a new world_despawn intrinsic
whose @fn_world_despawn helper is emitted in emit_program's tail once a use is
seen (the g_uses_* prelude pattern), used by the kill policy and callable from
any system.

Examples input_movement + world_bounds. Full suite 112/0, goldens
byte-identical, fixpoint holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-02 07:44:41 +03:00
parent 0497029dae
commit ad3be0c53c
17 changed files with 30435 additions and 29731 deletions

View file

@ -224,6 +224,7 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
if (meth == "press") { bare = "input_press"; push(labels, "key") }
if (meth == "release") { bare = "input_release"; push(labels, "key") }
if (meth == "axis") { bare = "input_axis"; push(labels, "neg"); push(labels, "pos") }
if (meth == "axis_i") { bare = "input_axis_i"; push(labels, "neg"); push(labels, "pos") } # #79 directional int (-1/0/1)
if (meth == "vector") { bare = "input_vector"; push(labels, "left"); push(labels, "right"); push(labels, "up"); push(labels, "down") }
if (meth == "strength") { bare = "input_strength"; push(labels, "action") }
if (meth == "mouse_x") { bare = "input_mouse_x" }
@ -287,6 +288,7 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
if (meth == "count") { bare = "world_count" }
if (meth == "size") { bare = "world_size" }
if (meth == "spawn") { bare = "world_spawn" }
if (meth == "despawn") { bare = "world_despawn" } # #84 — despawn an entity by id (runs @OnDespawn + frees)
if (meth == "save") { bare = "world_save" }
if (meth == "load") { bare = "world_load" }
if (meth == "prop_id") { bare = "world_prop_id" }

View file

@ -147,6 +147,7 @@ function emit_program() -> void {
g_uses_panic = false
g_uses_result = false
g_uses_option = false
g_uses_world_despawn = false # #84: set when a world_despawn call is emitted (below)
g_cov_lines = new []int
g_cov_active = true
loc_name = new []pointer; loc_reg = new []pointer; loc_ty = new []pointer; loc_mut = new []int
@ -184,6 +185,7 @@ function emit_program() -> void {
i = 0
while i < len(prog) { if prog[i].kind == N_MAIN { emit_main(prog[i]) }; i = i + 1 }
} } }
if g_uses_world_despawn { emit_world_despawn_fn() } # #84: @fn_world_despawn, after all World.despawn / esys_bounds-kill uses are seen
if g_uses_loopback { emit_loopback() } # built-in transport, after all net_send/net_poll uses are seen
if g_uses_str { emit_str_prelude() } # @fn_str_eq / @fn_str_concat, after all uses are seen
if g_uses_intstr { emit_int_str() } # @fn_int_str, for string(int) in interpolation

View file

@ -13,6 +13,7 @@ function is_intrinsic(name: pointer) -> bool {
if (name == "arg_count") or (name == "arg") or (name == "exit") { return true }
if (name == "file_stderr") or (name == "file_stdout") { return true }
if (name == "run") or (name == "getenv") { return true }
if (name == "world_despawn") { return true } # #84 — despawn an entity by id from a system
return false
}
# emit " <r> = <rest>\n" and return r
@ -56,6 +57,15 @@ function emit_intrinsic(name: pointer, e: Node) -> Val {
emit(" call i32 @fclose(ptr "); emit(f); emit(")\n")
return val("0", "void")
}
# #84 — despawn an entity by id (runs its @OnDespawn hooks + frees the slot),
# callable from a system (esys_bounds kill) or as World.despawn(e). The helper
# @fn_world_despawn is emitted with the ECS defs when g_uses_world_despawn.
if (name == "world_despawn") {
g_uses_world_despawn = true # emit @fn_world_despawn in the tail (after all uses seen)
let ent = arg_code(e, 0)
emit(" call void @fn_world_despawn(i32 "); emit(ent); emit(")\n")
return val("0", "void")
}
# print_str / print_int are the polymorphic `print(x)` builtin now (emit_call).
if (name == "arg_count") { return val(emit_bind("load i32, ptr @L_argc"), "int") }
if (name == "arg") {

View file

@ -293,6 +293,37 @@ function emit_despawn_hooks() -> void {
emit_despawn_all_fn()
}
# #84 — @fn_world_despawn(e): the runtime-callable despawn, used by the
# world-bounds kill policy and World.despawn(e). Same per-kind @OnDespawn dispatch
# as the `despawn` statement (emit_despawn) but reading a runtime %e, then the
# entity-scoped-listener sweep and the slot free. Emitted from emit_program's tail
# only when a world_despawn call was emitted (g_uses_world_despawn), after all uses
# are seen — so a program that never despawns by id is byte-identical.
function emit_world_despawn_fn() -> void {
emit("define void @fn_world_despawn(i32 %e) {\nentry:\n")
if len(g_ondespawn) > 0 {
let me = itoa(MAX_ENT)
emit(" %kp = getelementptr inbounds ["); emit(me); emit(" x i32], ptr @L_kind, i32 0, i32 %e\n")
emit(" %k = load i32, ptr %kp\n")
var i = 0
while i < len(g_ondespawn) {
let mname = g_ondespawn[i].s
let si = itoa(i)
emit(" %c"); emit(si); emit(" = icmp eq i32 %k, "); emit(itoa(find_arch_id(mname))); emit("\n")
emit(" br i1 %c"); emit(si); emit(", label %hit"); emit(si); emit(", label %next"); emit(si); emit("\n")
emit("hit"); emit(si); emit(":\n call void @on_despawn_"); emit(mname); emit("(i32 %e, i32 0)\n") # reason = Despawned
let dev = `model_{mname}_despawn`
if (find_event(dev) != null) { emit(" call void @ev_"); emit(dev); emit("(i32 %e, i32 0)\n") }
emit(" br label %next"); emit(si); emit("\n")
emit("next"); emit(si); emit(":\n")
i = i + 1
}
}
if len(g_events) > 0 { emit(" call void @ludic_sweep_entity(i32 %e)\n") } # EV5: drop entity-scoped listeners
emit(" call void @L_free_entity(i32 %e)\n")
emit(" ret void\n}\n\n")
}
# LC1 "no silent deaths": at program shutdown every still-live entity's despawn
# hook fires with reason Quit, so teardown that must run on exit is not skipped.
# @L_despawn_all(reason) walks the live set and dispatches each entity by kind —

View file

@ -192,7 +192,7 @@ function p_postfix() -> Node {
if e.a.kind == E_ID and e.a.s == "Input" and (e.s == "bind" or e.s == "rebind" or e.s == "poll" or e.s == "down" or e.s == "pressed" or e.s == "record" or e.s == "replay" or e.s == "action" or e.s == "bind_pad" or e.s == "active" or e.s == "just_pressed" or e.s == "just_released") { g_uses_input = true }
# #50 device layer — any of the multi-key / analog / mouse / gamepad / touch
# methods also splices input.ludic (Input.key stays bare, no runtime).
if e.a.kind == E_ID and e.a.s == "Input" and (e.s == "key_down" or e.s == "key_pressed" or e.s == "key_released" or e.s == "press" or e.s == "release" or e.s == "axis" or e.s == "vector" or e.s == "strength" or e.s == "mouse_x" or e.s == "mouse_y" or e.s == "mouse_dx" or e.s == "mouse_dy" or e.s == "mouse_down" or e.s == "wheel" or e.s == "set_mouse" or e.s == "pad_connected" or e.s == "pad_button" or e.s == "pad_axis" or e.s == "set_pad" or e.s == "touch_count" or e.s == "touch_x" or e.s == "touch_y" or e.s == "set_touch") { g_uses_input = true }
if e.a.kind == E_ID and e.a.s == "Input" and (e.s == "key_down" or e.s == "key_pressed" or e.s == "key_released" or e.s == "press" or e.s == "release" or e.s == "axis" or e.s == "axis_i" or e.s == "vector" or e.s == "strength" or e.s == "mouse_x" or e.s == "mouse_y" or e.s == "mouse_dx" or e.s == "mouse_dy" or e.s == "mouse_down" or e.s == "wheel" or e.s == "set_mouse" or e.s == "pad_connected" or e.s == "pad_button" or e.s == "pad_axis" or e.s == "set_pad" or e.s == "touch_count" or e.s == "touch_x" or e.s == "touch_y" or e.s == "set_touch") { g_uses_input = true }
# Anim.play/clip/on_frame/fired + Motion.to (#48): the ergonomic writes over
# the SpriteAnim/Motion components live in systems.ludic and use the world
# table, so splice it and force the reflection ABI even if the game leaves
@ -448,6 +448,7 @@ var g_uses_input: bool = false # a program used Input.bind/down/poll/… (act
var g_has_clear_color: bool = false # @ClearColor(N) declared -> the Render phase auto-clears + auto-presents (#86)
var g_clear_color: int = 0 # the declared clear colour (0x00RRGGBB)
var g_warned_draw_sprite: bool = false # emit the bare-draw_sprite deprecation note once (#85)
var g_uses_world_despawn: bool = false # World.despawn / esys_bounds kill -> emit @fn_world_despawn (#84)
# #85 — steer a direct bare draw_sprite / draw_sprite_scaled call to the namespaced
# Screen.sprite (or the engine sprite-render system). Warns once, non-fatally — the
@ -864,6 +865,12 @@ function maybe_splice_runtime() -> void {
do_import("runtime/native/core.ludic")
do_import("runtime/native/systems_sprite.ludic")
}
# Bounds -> the engine world-bounds system (#84). Self-contained (reflection
# ABI only); its kill policy calls world_despawn, so force the @fn_world_despawn
# helper to be emitted.
if find_comp("Bounds") != null {
do_import("runtime/native/systems_bounds.ludic")
}
# Light2D / Occluder -> the lighting render system, which links against the
# 2D light pass (light.ludic). do_import dedupes, so this is a no-op when the
# game also uses Light.* directly (g_uses_light already pulled it in).
@ -903,6 +910,7 @@ function parse_program() -> void {
g_has_clear_color = false
g_clear_color = 0
g_warned_draw_sprite = false
g_uses_world_despawn = false
g_uses_light = false
g_uses_value = false
g_uses_xml = false
@ -920,6 +928,7 @@ function parse_program() -> void {
push(g_esys_comp, "SpriteAnim"); push(g_esys_fn, "esys_spriteanim"); push(g_esys_phase, "Update")
push(g_esys_comp, "Motion"); push(g_esys_fn, "esys_motion"); push(g_esys_phase, "Update")
push(g_esys_comp, "Body"); push(g_esys_fn, "esys_move"); push(g_esys_phase, "Update")
push(g_esys_comp, "Bounds"); push(g_esys_fn, "esys_bounds"); push(g_esys_phase, "LateUpdate")
push(g_esys_comp, "Sprite"); push(g_esys_fn, "esys_sprite"); push(g_esys_phase, "Render")
push(g_esys_comp, "Light2D"); push(g_esys_fn, "esys_light2d"); push(g_esys_phase, "Render")
g_namespaces = new []pointer

File diff suppressed because it is too large Load diff