extends SceneTree
## KEYMAP AUDIT — every key this project binds, and every conflict between them.
##
## Why this exists: bindings live in two places that cannot see each other. The
## InputMap (project.godot, data, rebindable, visible in the editor) and hardcoded
## `event.keycode == KEY_X` comparisons scattered through GDScript (invisible,
## unrebindable, and silently overlapping). Adding Ctrl+Z meant grepping the whole
## project by hand to prove the key was free — which is not a method, it is a
## habit that works until it doesn't.
##
## So this reports BOTH surfaces in one table and cross-checks them:
##
## 1. InputMap actions and their keys, read from the live InputMap rather than
## parsed — the engine is the authority on what is actually bound.
## 2. Hardcoded key comparisons found in .gd sources, with file and line.
## 3. CONFLICTS: one physical key claimed by more than one thing.
##
## A conflict is not automatically a bug — the same key legitimately means
## different things in different modes (walking vs building), and that is exactly
## the distinction Godot's InputMap cannot express, since it has no notion of
## context. This audit makes the overlaps visible so the keymap layer can decide
## them deliberately instead of by load order.
##
## Run:
## cd /storage/projects/code/godot/hyper-tps
## "$HYPER_TPS_GODOT" --headless --script res://scenes/test/keymap_audit.gd
## Sources to scan for hardcoded bindings. Tests and benchmarks are excluded: they
## are not part of the shipped input surface and would drown the signal.
const SCAN_DIRS: Array[String] = [
"res://scenes", "res://autoloads", "res://addons",
]
const SKIP_DIRS: Array[String] = ["res://scenes/test"]
## KEY_* constants that are modifiers rather than bindable keys in their own right.
const MODIFIER_KEYS: Array[String] = ["KEY_SHIFT", "KEY_CTRL", "KEY_ALT", "KEY_META"]
## Actions that are LEGITIMATELY loose, with the reason — because an audit that
## nags about intended behaviour is an audit people learn to ignore.
## grapple a HOLD: you grapple while sprinting, so Shift must not break it.
## ui_cancel Escape must back out whatever else is held down.
const LOOSE_BY_DESIGN: Array[String] = ["grapple", "ui_cancel"]
func _init() -> void:
print("=== KEYMAP AUDIT ===\n")
var by_key: Dictionary = {} # "KEY_Z+ctrl" -> Array of claim strings
_collect_inputmap(by_key)
_collect_hardcoded(by_key)
_report(by_key)
quit(0)
## Everything the engine thinks is bound. Built-in ui_* actions are included but
## marked, because they are real conflicts (ui_undo is Ctrl+Z on every platform)
## and a UI element with focus will eat the key before gameplay sees it.
func _collect_inputmap(by_key: Dictionary) -> void:
print("-- InputMap actions --")
var rows: Array[String] = []
for action: StringName in InputMap.get_actions():
var builtin: bool = String(action).begins_with("ui_")
for ev: InputEvent in InputMap.action_get_events(action):
if not (ev is InputEventKey):
continue
var k := ev as InputEventKey
var sig := _signature_from_event(k)
if sig == "":
continue
var claim := "InputMap %s%s" % [action, " (built-in)" if builtin else ""]
_claim(by_key, sig, claim)
if not builtin:
rows.append(" %-28s %s" % [String(action), sig])
rows.sort()
for r: String in rows:
print(r)
print(" (%d project actions, built-in ui_* actions audited but not listed)"
% rows.size())
## Hardcoded comparisons in source. Deliberately a text scan: the point is to find
## bindings that exist NOWHERE the engine can see, so there is nothing to ask.
func _collect_hardcoded(by_key: Dictionary) -> void:
print("\n-- Hardcoded key comparisons in GDScript --")
var files: Array[String] = []
for d: String in SCAN_DIRS:
_walk(d, files)
# Matches `keycode == KEY_X`, `== KEY_X`, `is_key_pressed(KEY_X)`, `KEY_X:` in
# a match arm. Modifier state is picked up from the same LINE, which is where
# this file's own bindings put it (`shift_pressed`, `ctrl_pressed`).
var re := RegEx.new()
re.compile("KEY_[A-Z0-9_]+")
var found := 0
for f: String in files:
var txt: String = FileAccess.get_file_as_string(f)
if txt == "":
continue
var line_no := 0
for line: String in txt.split("\n"):
line_no += 1
var stripped := line.strip_edges()
if stripped.begins_with("#") or stripped.begins_with("##"):
continue
for m: RegExMatch in re.search_all(line):
var key: String = m.get_string()
if key in MODIFIER_KEYS:
continue
var sig := key
if line.contains("ctrl_pressed") or line.contains("KEY_CTRL"):
sig += "+ctrl"
if line.contains("shift_pressed") or line.contains("KEY_SHIFT"):
sig += "+shift"
if line.contains("alt_pressed") or line.contains("KEY_ALT"):
sig += "+alt"
_claim(by_key, sig, "%s:%d" % [f.replace("res://", ""), line_no])
found += 1
print(" %d hardcoded key references in %d files" % [found, files.size()])
func _walk(dir_path: String, out: Array[String]) -> void:
if dir_path in SKIP_DIRS:
return
var d := DirAccess.open(dir_path)
if d == null:
return
d.list_dir_begin()
var name := d.get_next()
while name != "":
var p: String = dir_path.path_join(name)
if d.current_is_dir():
if not name.begins_with("."):
_walk(p, out)
elif name.ends_with(".gd"):
out.append(p)
name = d.get_next()
d.list_dir_end()
func _signature_from_event(k: InputEventKey) -> String:
var code: int = k.physical_keycode if k.physical_keycode != 0 else k.keycode
if code == 0:
return ""
var sig := "KEY_" + OS.get_keycode_string(code).to_upper()
if k.ctrl_pressed:
sig += "+ctrl"
if k.shift_pressed:
sig += "+shift"
if k.alt_pressed:
sig += "+alt"
return sig
func _claim(by_key: Dictionary, sig: String, who: String) -> void:
if not by_key.has(sig):
by_key[sig] = []
(by_key[sig] as Array).append(who)
## A key claimed by more than one thing. Reported, never failed: the same key
## meaning different things in different MODES is correct design, and it is the
## thing Godot's InputMap cannot represent. What matters is that every overlap is
## a decision someone made, not a coincidence nobody noticed.
func _report(by_key: Dictionary) -> void:
var sigs: Array = by_key.keys()
sigs.sort()
var ours := 0
var builtin_only := 0
# OURS FIRST, and separately: a clash between two built-in ui_* actions is
# Godot's business and there is nothing to decide about it. A clash involving
# our own bindings is a decision waiting to be made.
print("\n-- Conflicts involving OUR bindings --")
for sig: String in sigs:
var claims: Array = by_key[sig]
if claims.size() < 2:
continue
var mine: Array = []
for c: String in claims:
if not c.ends_with("(built-in)"):
mine.append(c)
if mine.is_empty():
builtin_only += 1
continue
ours += 1
print(" %s" % sig)
for c: String in claims:
print(" %s" % c)
if ours == 0:
print(" none")
# The modifier trap, called out by name because it is invisible in the table
# above and it bit this very session. Godot matches an action WITHOUT
# exact_match by default, so an action bound to plain Z also fires on Ctrl+Z.
# Binding a Ctrl+<key> shortcut therefore silently triggers whatever owns the
# unmodified key, and no audit that only lists keys would show it.
print("\n-- Modifier shadowing (plain key also claimed with a modifier) --")
var shadowed := 0
for sig: String in sigs:
if sig.contains("+"):
continue
for other: String in sigs:
if not other.begins_with(sig + "+"):
continue
shadowed += 1
print(" %s is shadowed by %s" % [sig, other])
for c: String in (by_key[sig] as Array):
print(" plain: %s" % c)
for c: String in (by_key[other] as Array):
print(" mod: %s" % c)
if shadowed == 0:
print(" none")
_report_loose_matching()
print("\n%d conflict(s) involving our bindings, %d built-in-only (ignored), %d shadowed, %d distinct keys"
% [ours, builtin_only, shadowed, sigs.size()])
## THE ONE THAT ACTUALLY FIRES — measured, not quoted from the manual.
##
## is_action_pressed(action, allow_echo, exact_match) defaults exact_match to
## FALSE, and a non-exact match IGNORES modifiers. So an action bound to plain Z
## also fires on Ctrl+Z, and adding a Ctrl shortcut silently triggers whatever owns
## the unmodified key. Nothing in the conflict table above can show this, because
## on paper the two bindings are different keys.
##
## Rather than assert the semantics, this synthesises the modified event and asks
## the engine. Every action that answers "yes" is a live shadow.
func _report_loose_matching() -> void:
print("\n-- Actions that fire with modifiers held (exact_match=false) --")
var n := 0
for action: StringName in InputMap.get_actions():
if String(action).begins_with("ui_"):
continue
for ev: InputEvent in InputMap.action_get_events(action):
if not (ev is InputEventKey):
continue
var k := ev as InputEventKey
if k.ctrl_pressed or k.alt_pressed or k.meta_pressed:
continue
var probe := InputEventKey.new()
probe.physical_keycode = k.physical_keycode
probe.keycode = k.keycode
probe.pressed = true
probe.ctrl_pressed = true
if probe.is_action_pressed(action):
n += 1
print(" %-24s also fires on Ctrl+%s (needs exact_match, or a rebind)"
% [String(action), OS.get_keycode_string(
k.physical_keycode if k.physical_keycode != 0 else k.keycode)])
if n == 0:
print(" none")
# The list above is a property of the BINDINGS and stays true forever: any action
# on a plain key can be matched by a modified event. What decides whether that
# becomes a bug is the CALL SITE — so this is the actionable half.
print("\n-- Event call sites that match loosely (would be fooled) --")
var files: Array[String] = []
for d: String in SCAN_DIRS:
_walk(d, files)
var loose := 0
var by_design := 0
var re := RegEx.new()
# event.is_action_pressed("x") / .is_action_released("x") with NO exact_match arg.
re.compile('is_action_(pressed|released)\\("[^"]+"\\s*\\)')
for f: String in files:
var txt: String = FileAccess.get_file_as_string(f)
var line_no := 0
for line: String in txt.split("\n"):
line_no += 1
var stripped := line.strip_edges()
if stripped.begins_with("#"):
continue
# Input.is_action_pressed is the CONTINUOUS poll — non-exact is correct
# there, because movement must survive a held modifier.
if line.contains("Input.is_action"):
continue
var m: RegExMatch = re.search(line)
if m == null:
continue
var intended := false
for a: String in LOOSE_BY_DESIGN:
if m.get_string().contains('"%s"' % a):
intended = true
if intended:
by_design += 1
continue
loose += 1
print(" %s:%d %s" % [f.replace("res://", ""), line_no, m.get_string()])
if loose == 0:
print(" none unexpected — every other event check is exact (Keymap.tapped)")
print(" %d unexpected, %d loose by design (hold actions, cancel)" % [loose, by_design])