OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / keymap_audit.gd

293 lines · gd ref:
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])