extends Node
## HEADLESS GATE — the keymap layer holds the properties it exists for.
##
## The audit (scenes/test/keymap_audit.gd) reports what IS bound; this asserts the
## rules that stop the next Ctrl+Z-shaped bug:
##
## 1. every declared action is really registered with the engine;
## 2. no two actions in one context share a key (the thing nobody can grep for);
## 3. a plain-key action does NOT fire while a modifier is held — Godot's default
## is the opposite, and that is exactly how undo also toggled slow motion;
## 4. a context that is not active dispatches nothing;
## 5. a rebind is refused when it would collide, accepted when it would not, and
## survives a reset.
##
## Run as a SCENE (autoload singletons are not registered when --script compiles):
## "$HYPER_TPS_GODOT" --headless res://scenes/test/keymap_gate.tscn
var fails := 0
func _ok(what: String, cond: bool) -> void:
if not cond:
fails += 1
print(" %-58s %s" % [what, "OK" if cond else "**FAIL**"])
func _eqi(what: String, got: int, want: int) -> void:
var c := got == want
if not c:
fails += 1
print(" %-58s %-6d want %-6d %s" % [what, got, want, "OK" if c else "**FAIL**"])
func _eqs(what: String, got: String, want: String) -> void:
var c := got == want
if not c:
fails += 1
print(" %-58s %-14s want %-14s %s" % [what, got, want, "OK" if c else "**FAIL**"])
## Which project actions claim this key with NO modifier held. The question "is this
## key free" should have an answer that does not depend on grepping carefully enough.
func _claims_bare(key: int) -> PackedStringArray:
var out := PackedStringArray()
for action: StringName in InputMap.get_actions():
if String(action).begins_with("ui_"):
continue
for ev: InputEvent in InputMap.action_get_events(action):
if ev is InputEventKey:
var k := ev as InputEventKey
if k.physical_keycode == key and not k.ctrl_pressed \
and not k.shift_pressed and not k.alt_pressed:
out.append(String(action))
return out
## A synthetic press, so matching can be measured instead of assumed.
func _lmb() -> InputEventMouseButton:
var e := InputEventMouseButton.new()
e.button_index = MOUSE_BUTTON_LEFT
e.pressed = true
return e
func _rmb() -> InputEventMouseButton:
var e := InputEventMouseButton.new()
e.button_index = MOUSE_BUTTON_RIGHT
e.pressed = true
return e
func _rmb_up() -> InputEventMouseButton:
var e := _rmb()
e.pressed = false
return e
func _lmb_up() -> InputEventMouseButton:
var e := _lmb()
e.pressed = false
return e
func _press(key: int, ctrl := false, shift := false, alt := false) -> InputEventKey:
var e := InputEventKey.new()
# BOTH, as a real key event carries both — QWERTY, where character = position. A
# position-only event could never match a letter, which binds by character (§6).
e.physical_keycode = key
e.keycode = key
e.pressed = true
e.ctrl_pressed = ctrl
e.shift_pressed = shift
e.alt_pressed = alt
return e
## The synthetic event a declared binding is pressed with.
func _event_for(b: Dictionary) -> InputEvent:
if b.has("mouse"):
var m := InputEventMouseButton.new()
m.button_index = int(b["mouse"])
m.pressed = true
return m
return _press(int(b.get("key", 0)), bool(b.get("ctrl", false)),
bool(b.get("shift", false)), bool(b.get("alt", false)))
## Every declared action that a press of `b` fires on the CURRENT stack.
func _fired_by(km: Node, b: Dictionary) -> PackedStringArray:
var ev := _event_for(b)
var out := PackedStringArray()
for row: Dictionary in km.bindings():
var a := String(row["action"])
if km.tapped(ev, a):
out.append(a)
return out
func _ready() -> void:
await get_tree().process_frame
print("=== Keymap gate ===\n")
var km: Node = get_tree().root.get_node_or_null("/root/Keymap")
if km == null:
printerr("Keymap autoload missing")
get_tree().quit(1)
return
print("-- every declared action is registered --")
var declared: Array = km.bindings()
_ok("the builder context declares actions", declared.size() >= 10)
var missing: Array[String] = []
for b: Dictionary in declared:
if not InputMap.has_action(String(b["action"])):
missing.append(String(b["action"]))
_ok("all present in InputMap", missing.is_empty())
# Blender's since keymap step 3 (docs/keymap-blender-279.md §3a).
_eqs("undo reads as", String(km.describe("builder.undo")), "Ctrl+Z")
_eqs("redo reads as", String(km.describe("builder.redo")), "Ctrl+Shift+Z")
print("\n-- no two actions in one context share a key --")
var cl: Array = km.conflicts()
if not cl.is_empty():
for c: String in cl:
print(" %s" % c)
_ok("no in-context conflicts", cl.is_empty())
print("\n-- EXACT matching: a plain key does not fire with a modifier --")
km.push_context("builder")
km.push_context("build_mode") # the editor tools are build-mode only
# KEYMAP STEP 3 (docs/keymap-blender-279.md §3a): Blender's keys, exact-matched.
_ok("K is the knife", km.tapped(_press(KEY_K), "builder.knife"))
_ok("Ctrl+K is NOT the knife", not km.tapped(_press(KEY_K, true), "builder.knife"))
_ok("build-mode carve has no key (menu / toolbar only)", not km.binding_of("builder.carve").has("key"))
_ok("B is box select", km.tapped(_press(KEY_B), "builder.select_box"))
_ok("A toggles select all", km.tapped(_press(KEY_A), "builder.select_all"))
_ok("Shift+A is the add menu, not select all", km.tapped(_press(KEY_A, false, true), "builder.add_menu")
and not km.tapped(_press(KEY_A, false, true), "builder.select_all"))
_ok("S scales", km.tapped(_press(KEY_S), "builder.scale"))
# Ctrl+Z since step 1 made Ctrl "descend" only while flying (and `fly` suppresses
# every command). Exact matching keeps Ctrl+Z, Ctrl+Shift+Z and Z three actions.
_ok("Ctrl+Z undoes", km.tapped(_press(KEY_Z, true), "builder.undo"))
_ok("Ctrl+Shift+Z does NOT undo — exact match",
not km.tapped(_press(KEY_Z, true, true), "builder.undo"))
_ok("Ctrl+Shift+Z redoes", km.tapped(_press(KEY_Z, true, true), "builder.redo"))
_ok("plain Z is the wireframe toggle, not undo", km.tapped(_press(KEY_Z), "builder.wireframe")
and not km.tapped(_press(KEY_Z), "builder.undo"))
km.pop_context("build_mode")
# The regression that shipped was Ctrl+Z ALSO firing toggle_slowmo, which was
# bound to plain Z, because Godot matches actions non-exact by default.
#
# Since keymap step 3 plain Z is Blender's wireframe toggle and undo is Ctrl+Z. The
# shadow this guards against is unchanged: a SECOND action on plain Z would fire
# together with it. Exactly one owner.
print("\n-- plain Z belongs to the wireframe toggle, and to nothing else --")
_ok("toggle_slowmo no longer exists", not InputMap.has_action("toggle_slowmo"))
var z_claims: Array[String] = []
for action: StringName in InputMap.get_actions():
if String(action).begins_with("ui_"):
continue
for ev: InputEvent in InputMap.action_get_events(action):
# Either field: a letter binds by CHARACTER (keycode) since keymap step 5.
if ev is InputEventKey and ((ev as InputEventKey).physical_keycode == KEY_Z
or (ev as InputEventKey).keycode == KEY_Z) \
and not (ev as InputEventKey).ctrl_pressed \
and not (ev as InputEventKey).shift_pressed:
z_claims.append(String(action))
if z_claims.size() != 1:
print(" claimed by: %s" % ", ".join(z_claims))
_eqi("exactly one action owns plain Z", z_claims.size(), 1)
_ok("and it is the wireframe toggle", z_claims.size() == 1 and z_claims[0] == "builder.wireframe")
print("\n-- EXCLUSIVITY: a mode suppresses the layer under it --")
# The gap this closes: before exclusivity the builder context only ADDED
# bindings, so every player action stayed live while building — you could jump
# and shoot while placing geometry, and `B` could not safely mean two things.
km.push_context("player")
# Against build_mode, which is the context that MEANS to suppress. "builder" is
# additive on purpose: the level map pushes it while you are still a player, and
# making it exclusive stopped toggle_light and respawn working on that map.
km.push_context("builder")
_ok("the additive builder layer does NOT suppress the player",
km.is_context_active("player"))
_ok("so a player action still fires with the dev keys layered on",
km.tapped(_press(KEY_X), "toggle_light"))
km.push_context("build_mode")
_ok("build_mode is active", km.is_context_active("build_mode"))
_ok("player is SUPPRESSED under it", not km.is_context_active("player"))
_ok("global survives regardless", km.is_context_active("global"))
_ok("a player action does not fire in build mode",
not km.tapped(_press(KEY_X), "toggle_light"))
_ok("a global action still does",
km.tapped(_press(KEY_ESCAPE), "ui_cancel"))
# Chat is a PLAYER action since 2026-10-18 ("t still brings up chat menu in editor
# which is very wrong"), so build mode suppresses it like any other.
_ok("chat (T) does not fire in build mode",
not km.tapped(_press(KEY_T), "toggle_chat"))
km.pop_context("build_mode")
_ok("popping the mode restores the player", km.is_context_active("player"))
_ok("and the player action fires again", km.tapped(_press(KEY_X), "toggle_light"))
print("\n-- an inactive context dispatches nothing --")
km.pop_context("builder") # the section above leaves it pushed
_ok("K does not knife while not building", not km.tapped(_press(KEY_K), "builder.knife"))
# Re-pushing must MOVE the entry, not stack a second copy: a map that reloads
# would otherwise leave a stale "builder" underneath, and one pop would not
# actually leave the mode.
km.push_context("builder")
km.push_context("builder")
km.pop_context("builder")
_ok("re-pushing does not duplicate the stack entry", not km.is_context_active("builder"))
km.push_context("builder")
print("\n-- the F5 and K double-bindings are gone --")
# Both were hardcoded keycode comparisons, so no table could see them: F5 was
# `respawn` AND fly-mode; K was the level editor's carve AND the climb-debug
# visual. WHERE those keys ended up is asserted in mode_gate.gd, which owns the
# F-row layout — restating it here would be two places to update. What belongs
# here is that the collisions themselves cannot recur.
_eqs("the knife owns K (keymap step 3)", String(km.describe("builder.knife")), "K")
_ok("K does not fire climb debug", not km.tapped(_press(KEY_K), "player.climb_debug"))
_ok("nothing is bound to bare F5 any more", _claims_bare(KEY_F5).is_empty())
# F3 stopped being a MODE key — the mode collapsed onto F4 alone — and since keymap
# step 3 the palette is Space (Blender_27x.py spacebar_action='SEARCH').
_ok("bare F3 is free (search moved to Space)", _claims_bare(KEY_F3).is_empty())
_eqs("search is Space", String(km.describe("builder.palette")), "Space")
_ok("F4 is the mode toggle, alone",
_claims_bare(KEY_F4) == PackedStringArray(["global.mode_toggle"]))
print("\n-- the help screen can describe every bound action --")
# A key list built from Keymap.bindings() is only useful if bindings() also covers
# the actions whose keys live in project.godot — movement most of all.
var listed: Dictionary = {}
var blank: Array[String] = []
var menu_only: Array[String] = []
for b: Dictionary in km.bindings():
listed[String(b["action"])] = true
if String(b["keys"]) == "":
# MENU-ONLY by design (no key, no button — the 3D cursor's snap commands, run
# from the Shift+S menu): nothing to resolve. Anything else blank is a gap.
var raw: Dictionary = km.binding_of(String(b["action"]))
if not raw.is_empty() and not raw.has("key") and not raw.has("mouse"):
menu_only.append(String(b["action"]))
else:
blank.append(String(b["action"]))
if not menu_only.is_empty():
print(" menu-only (no key by design): %s" % ", ".join(menu_only))
_ok("movement is listed", listed.has("move_forward") and listed.has("jump"))
_ok("builder actions are listed", listed.has("builder.carve"))
_ok("help itself is listed", listed.has("global.help"))
if not blank.is_empty():
print(" no key resolved for: %s" % ", ".join(blank))
_ok("every listed action resolves to a key", blank.is_empty())
_eqs("and help is on F1", String(km.describe("global.help")), "F1")
print("\n-- the overlay itself renders, not just its data --")
# Asserting bindings() is not the same as asserting the SCREEN works: a BBCode
# typo or a null label would leave a blank panel and pass every check above.
var help: Node = get_tree().root.get_node_or_null("/root/HelpOverlay")
_ok("the overlay is autoloaded", help != null)
if help != null:
help.show_help()
# Keymap step 4: F1 shows the ACTIVE contexts; "show every context" all of them
# (gate_help_contexts owns the active-only behaviour).
help.set_show_all(true)
var body: String = ""
for child: Node in help.find_children("*", "RichTextLabel", true, false):
body = (child as RichTextLabel).text
_ok("it is visible", bool(help.visible))
_ok("it lists a movement key", body.contains("Move forward"))
_ok("it lists a builder key", body.contains("Knife"))
_ok("it groups by context", body.contains("PLAYING") and body.contains("EDITOR TOOLS"))
help.set_show_all(false)
_ok("it says how to close itself", body.contains("F1"))
help.hide_help()
_ok("and it closes", not help.visible)
print("\n-- rebinding --")
km.push_context("build_mode")
# The KNIFE as the example since keymap step 3 (carve has no key now). B is box select.
_eqs("a colliding rebind is refused, with a reason",
String(km.rebind("builder.knife", KEY_B)).left(13), "that key is a")
_eqs("the knife is unchanged", String(km.describe("builder.knife")), "K")
# Y, not J: J is build_mode.csg_merge, live at the same time since the editor
# tools became build-mode only -- the rebind check is per context and would
# accept it, and the same-moment section below would then (rightly) fail. (It was
# M until M became Move to collection, #120 F1.) Y is bound nowhere.
_eqs("a free key is accepted", String(km.rebind("builder.knife", KEY_Y)), "")
_eqs("and takes effect", String(km.describe("builder.knife")), "Y")
_ok("Y now is the knife", km.tapped(_press(KEY_Y), "builder.knife"))
_ok("K no longer is", not km.tapped(_press(KEY_K), "builder.knife"))
km.reset_binding("builder.knife")
_eqs("reset restores the default", String(km.describe("builder.knife")), "K")
_ok("K is the knife again", km.tapped(_press(KEY_K), "builder.knife"))
km.pop_context("build_mode")
print("\n-- THE MOUSE: left fires the carver, right grapples (key audit 2026-10-01) --")
# The carver is a WEAPON now, not an editor action leaking into play: LMB fires
# it, RMB is the grapple (moved off LMB). Both belong to `player`, so in build
# mode -- where RMB-hold is the look and LMB selects -- neither fires.
km.push_context("player")
_ok("left click fires the carver while playing", km.tapped(_lmb(), "player.fire"))
_ok("left click does NOT grapple", not km.tapped(_lmb(), "grapple"))
_ok("right click grapples while playing", km.tapped(_rmb(), "grapple"))
_ok("and the grapple release still matches", km.released(_rmb_up(), "grapple"))
_ok("right click does NOT fire", not km.tapped(_rmb(), "player.fire"))
km.push_context("build_mode")
_ok("in build mode left click fires nothing of the player's",
not km.tapped(_lmb(), "player.fire") and not km.tapped(_lmb(), "grapple"))
_ok("nor does right click (it is the look there)", not km.tapped(_rmb(), "grapple"))
km.pop_context("build_mode")
_ok("both come back when you leave build mode",
km.tapped(_lmb(), "player.fire") and km.tapped(_rmb(), "grapple"))
print("\n-- A COMMAND NEVER FIRES WHILE FLYING (keymap step 1) --")
# The CSG keys shipped as Ctrl+D / Ctrl+I / Ctrl+M and every one of them also
# dropped you through the floor, because Ctrl was descend whenever you were in build
# mode — descend is POLLED, so no input handler can consume it. The fix used to be
# "no Ctrl / Shift in build_mode". Since keymap step 1 (docs/keymap-blender-279.md
# §2a) the camera only moves while FLYING (RMB held / the Shift+F modal), and the
# `fly` context is exclusive — so Ctrl is descend ONLY while no command can fire.
# Assert that shape: fly suppresses every command; outside it Ctrl chords work.
km.push_context("build_mode")
_ok("the fly context is exclusive", bool(km.CONTEXTS.get("fly", {}).get("exclusive", false)))
_ok("Ctrl+Numpad − subtracts (not flying)", km.tapped(_press(KEY_KP_SUBTRACT, true), "build_mode.csg_subtract"))
_ok("Ctrl+Numpad * intersects", km.tapped(_press(KEY_KP_MULTIPLY, true), "build_mode.csg_intersect"))
_ok("Ctrl+Numpad / slices", km.tapped(_press(KEY_KP_DIVIDE, true), "build_mode.csg_slice"))
_ok("plain Numpad − does NOT subtract (exact matching)", not km.tapped(_press(KEY_KP_SUBTRACT), "build_mode.csg_subtract"))
km.push_context("fly")
_ok("WHILE FLYING Ctrl+Numpad − does nothing (descend + command cannot collide)",
not km.tapped(_press(KEY_KP_SUBTRACT, true), "build_mode.csg_subtract"))
_ok("... nor does any build_mode key", not km.tapped(_press(KEY_J), "build_mode.csg_merge"))
km.pop_context("fly")
# H IS BLENDER'S HIDE since 2026-10-12 (docs/cutting-and-operations.md §9c).
_ok("H is hide, a plain key", int(km.binding_of("builder.hide").get("key", 0)) == KEY_H
and not bool(km.binding_of("builder.hide").get("ctrl", false)))
_ok("H no longer subtracts", not km.tapped(_press(KEY_H), "build_mode.csg_subtract"))
_ok("I no longer intersects (free for inset)", not km.tapped(_press(KEY_I), "build_mode.csg_intersect"))
_ok("J convex-merges", km.tapped(_press(KEY_J), "build_mode.csg_merge"))
km.pop_context("build_mode")
_ok("none of them fire outside build mode",
not km.tapped(_press(KEY_KP_SUBTRACT, true), "build_mode.csg_subtract"))
print("\n-- THE EDITOR TOOLS ARE BUILD-MODE ONLY (key audit 2026-10-01) --")
# `builder` used to be ADDITIVE over play on the level map, so K B C G E N F V L
# Z [ ] - = were live while walking -- L fired lizard-debug AND entity links, and
# any future gameplay letter would have collided. It now requires build_mode.
km.push_context("player")
km.push_context("builder") # the level map pushes it while loaded
_ok("K does not knife the level while playing", not km.tapped(_press(KEY_K), "builder.knife"))
_ok("L does not select linked while playing", not km.tapped(_press(KEY_L), "builder.select_linked_pick"))
_ok("- does not change the grid while playing", not km.tapped(_press(KEY_MINUS), "builder.grid_finer"))
km.push_context("build_mode")
_ok("in build mode K is the knife", km.tapped(_press(KEY_K), "builder.knife"))
_ok("in build mode - is the grid", km.tapped(_press(KEY_MINUS), "builder.grid_finer")) # - = finer, = = coarser
km.pop_context("build_mode")
print("\n-- THE CARVER'S KEYS, WHILE PLAYING --")
_ok("1 picks the box brush", km.tapped(_press(KEY_1), "player.csg_box"))
_ok("Q toggles remove / add", km.tapped(_press(KEY_Q), "player.carve_mode"))
_ok("- makes the brush smaller", km.tapped(_press(KEY_MINUS), "player.carve_smaller"))
_ok("= makes it bigger", km.tapped(_press(KEY_EQUAL), "player.carve_bigger"))
_ok("Shift is no longer part of any carve binding",
not String(km.describe("player.fire")).contains("Shift"))
km.push_context("build_mode")
_ok("in build mode 1 is select-vertex, not the box brush",
km.tapped(_press(KEY_1), "build_mode.sel_vertex") and not km.tapped(_press(KEY_1), "player.csg_box"))
_ok("and - is the grid, not the brush size",
not km.tapped(_press(KEY_MINUS), "player.carve_smaller"))
km.pop_context("build_mode")
print("\n-- ONE PRESS, ONE ACTION: every declared binding, in play AND in build mode --")
# The four double-fires the audit found were all ACROSS contexts that are live at
# the same moment (global + player + builder), which the per-context conflict
# check cannot see by construction. So press every declared binding on the real
# stack of each mode and count what fires.
var states := {"play (level map)": ["player", "builder"], "build (level map)": ["player", "builder", "build_mode"]}
for state: String in states:
for c: String in ["player", "builder", "build_mode"]:
km.pop_context(c)
for c: String in states[state]:
km.push_context(c)
var doubles: Array[String] = []
for row: Dictionary in km.bindings():
var a := String(row["action"])
var b: Dictionary = km.binding_of(a)
if b.is_empty():
continue # project.godot actions: no key to synthesise here
var fired := _fired_by(km, b)
if fired.size() > 1:
doubles.append("%s fires %s" % [km.describe(a), ", ".join(fired)])
for d: String in doubles:
print(" %s" % d)
_eqi("%s: presses that fire more than one action" % state, doubles.size(), 0)
for c: String in ["builder", "build_mode"]:
km.pop_context(c)
print("\n-- NO KEY IS READ OUTSIDE THE KEYMAP (rule 3), except where allowed --")
# The double-fires were all hardcoded `keycode ==` checks no table could see.
# Allowed: modal operators (their keys only exist while the tool is up), the two
# dev maps with their own key walls, Escape handlers, and third-party addons.
var allowed := ["res://scenes/test/", "res://scenes/maps/operators/",
"res://scenes/maps/map_geiss.gd", # map_dc_terrain migrated 2026-10-01: it SHIPS
"res://addons/nubis_clouds/", "res://addons/SunshineClouds2/",
"res://addons/editor_screenshot/", "res://autoloads/keymap.gd"]
var re := RegEx.create_from_string("\\.(physical_)?keycode\\s*(==|!=)\\s*KEY_(?!ESCAPE)|match\\s+[^\\n]*keycode|is_key_pressed\\(KEY_(?!ALT|SHIFT|CTRL|META)")
var raw: Array[String] = []
for root: String in ["res://scenes", "res://autoloads", "res://addons"]:
_scan(root, allowed, re, raw)
for r: String in raw:
print(" %s" % r)
_eqi("raw key reads outside the keymap", raw.size(), 0)
print("\n%s" % ("FAILED" if fails else "PASS"))
get_tree().quit(1 if fails else 0)
func _scan(dir: String, allowed: Array, re: RegEx, out: Array[String]) -> void:
for a: String in allowed:
if (dir + "/").begins_with(a):
return
var d := DirAccess.open(dir)
if d == null:
return
for sub: String in d.get_directories():
_scan(dir.path_join(sub), allowed, re, out)
for f: String in d.get_files():
if not f.ends_with(".gd"):
continue
var path := dir.path_join(f)
if path in allowed:
continue
var lines := FileAccess.get_file_as_string(path).split("\n")
for i in lines.size():
var l := lines[i].strip_edges()
if l.begins_with("#"):
continue
if re.search(lines[i]) != null:
out.append("%s:%d %s" % [path, i + 1, l.left(80)])