extends Node
## KEYMAPS ARE DATA — one table, with contexts, conflict-checked at boot.
##
## Why this exists (hyper-games #22 follow-up, the keymap audit):
## bindings lived in two places that could not see each other — InputMap actions in
## project.godot, and ~78 hardcoded `event.keycode == KEY_X` comparisons across 97
## GDScript files. Adding Ctrl+Z for undo meant grepping the project by hand to
## prove the key was free. It was not: `toggle_slowmo` is bound to plain Z, and
## Godot matches actions with `exact_match = false` by DEFAULT, which ignores
## modifiers — so Ctrl+Z undid an edit AND toggled slow motion. Shipped in 0.847.0,
## found by scenes/test/keymap_audit.gd within minutes of writing it.
##
## The model is Blender's, in the one respect that matters: a keymap is DATA
## (context -> action -> binding), so bindings are enumerable, rebindable and
## checkable, instead of being statements buried in an input handler. Godot's
## InputMap gets us the first half and cannot express the second: it has no notion
## of CONTEXT, so "B selects a brush while building" and "B does something else
## while walking" have to be resolved by whoever happens to handle the event first.
## Contexts live here for exactly that reason.
##
## TAP vs HOLD is a real distinction, not a naming choice:
## - a HOLD action (movement) must tolerate modifiers — you have to be able to
## walk while holding ctrl to crouch — so it is matched non-exact;
## - a TAP action (a toggle, a shortcut) must NOT, or every modifier combination
## triggers it, which is the bug above.
## The audit measures this by synthesising a modified event and asking the engine,
## rather than trusting either the docs or this comment.
##
## Bindings the player changes are persisted to user://keymap.json — deliberately
## NOT through Settings, whose set_value validates against a fixed SCHEMA that has
## no room for arbitrary action names.
const SAVE_PATH := "user://keymap.json"
## The keyboard-layout rule (keymap step 5, §6) — shared with the operators.
const KeyRules := preload("res://autoloads/key_rules.gd")
## CONTEXTS, and what each one does to the ones under it.
##
## Blender consults keymaps in order (window, screen, area, region, mode, tool) and
## an item either handles an event or passes it through. Same idea, three fields:
##
## always never suppressed. Screenshots, metrics, Escape — a mode must
## not be able to trap you with no way to look at anything or get out.
## exclusive suppresses every non-`always` context below it on the stack. This is
## what makes a MODE a mode: while building you are a camera, not a
## body, so `jump` should not fire and `B` means what the builder says.
## (neither) ordinary layer, active when pushed.
##
## Without exclusivity the builder context only ADDED bindings, so every player
## action stayed live underneath it — which is why `B` could not safely mean two
## things, the exact problem contexts were introduced to solve.
const CONTEXTS: Dictionary = {
"global": {"always": true},
"player": {},
# ADDITIVE, not exclusive, and the distinction is load-bearing. The level map
# pushes "builder" the whole time it is loaded, but you are still a PLAYER there
# — the editor keys are dev bindings layered over ordinary play. Making this
# exclusive silently killed toggle_light and respawn on that map; the gate caught
# it immediately, which is the entire reason the gate asserts suppression against
# a context that is meant to suppress rather than against this one.
#
# REQUIRES build_mode since the 2026-10-01 key audit, and that reverses the
# paragraph above for a reason it could not see: additive meant K B C G E N F V
# L Z [ ] - = were live while WALKING on the level map -- L fired lizard-debug
# AND entity links, and every gameplay letter added later would collide. The
# editor's tools are the editor's; F4 is the way in. toggle_light and respawn
# keep working on that map because nothing here suppresses `player` any more.
"builder": {"requires": "build_mode"},
# The builder MODE: a free-fly camera, not a body. Genuinely exclusive — while
# flying you have no legs, so `jump` must not fire.
#
# `keeps` is the other half of that, and the mode gate is what forced it into
# existence: exclusivity alone ALSO suppressed the editing keys, so entering build
# mode would have disabled carve, select and fill — the tools the mode exists for.
# An exclusive context therefore has to say what SURVIVES it. "Takes your legs,
# keeps your tools" is the whole rule, and it is data rather than a special case
# buried in the lookup.
"build_mode": {"exclusive": true, "keeps": ["builder", "face_sel"]},
# SOMETHING IS SELECTED (UV controls, docs/uv-controls-design.md §4). Pushed / popped
# by map_dc_level while the selection is non-empty, so the arrows, PgUp/PgDn, Home
# and End mean "align the selection's textures" only when there is one — and F1
# lists them only then. Kept by build_mode, like the builder's tools.
"face_sel": {"requires": "build_mode"},
# A TOOL IS UP (editor redesign E0, docs/editor-redesign.md §2). Pushed and popped by
# OperatorStack. While it is active and RMB is NOT held, the tool owns Ctrl / Shift /
# Space (snap invert / precision / confirm) instead of the fly camera — Blender's
# rule that a running modal operator owns its keys. WASD keep moving.
"tool": {"requires": "build_mode"},
# TYPING. Exclusive and set automatically from the focused text field (_process), so
# a search box, the chat line or a SpinBox gets every key: no builder tool fires and
# movement stops reading the keyboard (movement_allowed). Before this, typing "brick"
# into the texture search was B select, R, I intersect, C knife — and typing in chat
# walked you, because movement POLLS the keyboard and focus never reached it.
# `always` contexts (Esc, F-keys) stay live: a field must not trap you.
"text_input": {"exclusive": true},
# A HELPER POPUP IS OPEN (editor redesign E3: tool popup, context menu) — the same
# guarantee as text_input for popups that have no text field: no tool fires
# underneath and the fly camera stops (movement_allowed), so picking from a menu
# never also flies you somewhere. Pushed / popped by the popup itself.
"menu": {"exclusive": true},
# FLYING (keymap step 1, docs/keymap-blender-279.md §2a; user 2026-10-12 "suppress
# while flying like blender"). On the stack ONLY while RMB is held or the Shift+F fly
# modal is up (GameMode). Exclusive and keeps nothing, as Blender's fly modal consumes
# every event: no editor key fires while you fly, so Ctrl / Shift / Space — the fly
# camera's while flying — are free modifiers for commands the rest of the time.
"fly": {"exclusive": true},
}
## Which context each PRE-EXISTING project.godot action belongs to.
##
## Their bindings deliberately stay in project.godot — that file is real data, it is
## editable in Godot's own UI, and churning working gameplay bindings in the same
## change as the context mechanism would risk breaking movement to prove a point.
## What lives here is MEMBERSHIP and DISPATCH; new actions (builder.*) are declared
## fully in DEFAULTS below. Migrating the rest into one table is a follow-up, and
## nothing depends on the order it happens in.
const MEMBERSHIP: Dictionary = {
"move_forward": "player", "move_back": "player",
"move_left": "player", "move_right": "player",
"jump": "player", "crouch": "player", "sprint": "player", "walk": "player",
"toggle_light": "player",
# Chat is a PLAYER action: the editor's exclusive build mode suppresses it (user
# 2026-10-18: "t still brings up chat menu in editor which is very wrong"). The
# player context is the base of the stack, so chat works from launch (the lobby too).
"toggle_chat": "player",
# Always reachable, whatever mode you are in.
"ui_cancel": "global",
}
## context -> action -> {key, ctrl, shift, alt, hold, label}
## `hold` marks a continuous action (non-exact matching, see above).
const DEFAULTS: Dictionary = {
# F-KEYS ARE DEVELOPER / DIAGNOSTIC, letters are gameplay verbs. That rule is why
# these three moved: fly was on F5, which `respawn` already owned, so one press
# did both; and the climb-debug visual was on K, which is the CARVE key, so on the
# level map one press carved a hole AND toggled debug lines. Both were invisible
# because they were hardcoded keycode comparisons that no table could see.
# THE F ROW SELECTS CONTEXT. That is the rule; everything else on the row moved to
# make room for it, because "F-keys are diagnostics" was never a principle — it
# was a description of which keys happened to be free.
#
# Tab carries the switch you make constantly (Blender puts Object/Edit there for
# exactly that reason: one key, left hand, no reach), while the F row is for going
# somewhere SPECIFIC and has room for the modes we do not have yet. Maya's
# F2/F3/F4 menu-set switching is the precedent for the row itself.
#
# F9-F12 stay out of the scheme: window managers and Godot commonly grab F11 for
# fullscreen, and laptops need Fn for the far end of the row.
"global": {
"global.help": {"key": KEY_F1, "label": "Show / hide the key list"},
# ONE KEY FOR THE MODE. There were three — Tab toggled, F2 set play, F3 set
# build — on the reasoning that "a mode is a value, not the parity of how
# many times you pressed something". That argument only pays off for a
# PROGRAMMATIC caller, and those call GameMode.set_mode() directly; for a
# person it was three keys for two states, and Tab additionally collided
# with Godot's ui_focus_next the moment the editor grew focusable panels.
#
# F4 because F2 and F3 are now free and this is the editor's own key, next
# to the F-row it used to share. Looking around is RMB-hold, so there is no
# second sub-mode key either.
"global.mode_toggle": {"key": KEY_F4, "label": "Enter / exit build mode"},
# DIAGNOSTICS KEEP THEIR KEY BUT GAIN Ctrl — same row, same mnemonic, and the
# modifier says "meta, not gameplay". toggle_metrics HAD to move regardless:
# it was on bare F3, which the build mode now owns.
"toggle_metrics": {"key": KEY_F3, "ctrl": true, "label": "Performance metrics"},
# THE REST OF THE DIAGNOSTICS, DECLARED (key audit 2026-10-01). They were
# keycode comparisons in map_core / player / keet_game_state / game, and
# four of them fired a SECOND action on the same press: Ctrl+F8 (unstick +
# CSG overlay), Ctrl+F6 (climb debug + wireframe, whose check ignored Ctrl),
# Ctrl+F9 (LOD colours + the bare-F9 room overlay) and L (lizard + entity
# links). Ctrl+F<n> keeps its key where it was alone; the one that collided
# moves to Ctrl+SHIFT+F<n> -- the precedent is the settings dump on
# Ctrl+Shift+F5 -- so the F key still names the area.
"debug.teleport_far": {"key": KEY_F2, "ctrl": true, "label": "Teleport 100 km and back (precision A/B)"},
"debug.terrain_probe": {"key": KEY_F5, "ctrl": true, "label": "Terrain ground probe"},
"debug.settings_dump": {"key": KEY_F5, "ctrl": true, "shift": true, "label": "Dump this map's tuned settings"},
"debug.collision_hull": {"key": KEY_F7, "ctrl": true, "label": "Collision hull overlay"},
"debug.wireframe": {"key": KEY_F7, "ctrl": true, "shift": true, "label": "Wireframe + triangle count"},
"debug.csg_edits": {"key": KEY_F8, "ctrl": true, "shift": true, "label": "Show the CSG edits"},
"debug.lod_colors": {"key": KEY_F9, "ctrl": true, "label": "Terrain LOD colours"},
"debug.room_overlay": {"key": KEY_F9, "ctrl": true, "shift": true, "label": "Room membership overlay"},
"debug.player_dump": {"key": KEY_F10, "ctrl": true, "label": "Dump the last 3 s of player state"},
"global.screenshot": {"key": KEY_F12, "label": "Screenshot"},
},
"player": {
# CSG EDIT TYPE — the number row, while PLAYING.
#
# The comment on the build_mode block says no tool may take 1-9 because
# they are weapon slots. There are no weapons yet, and these are declared
# in `player` rather than `builder` for the reason that block gives:
# `build_mode` is EXCLUSIVE and suppresses `player`, so 1-4 mean select
# mode while building and edit type while playing, and the two can never
# both be live. `builder` is additive and WOULD have collided.
#
# The order is map_dc_terrain.gd's EDIT_MODES, unchanged, so the muscle
# memory of the map that has had these all along still works.
"player.csg_box": {"key": KEY_1, "label": "Carver brush: box"},
"player.csg_sphere": {"key": KEY_2, "label": "Carver brush: smooth sphere"},
"player.csg_hard": {"key": KEY_3, "label": "Carver brush: hard sphere"},
"player.csg_voxelize": {"key": KEY_4, "label": "Carver brush: voxelize the brush under the crosshair"},
# THE CARVER IS A WEAPON (key audit 2026-10-01): LMB fires it, 1-4 are its
# brushes -- the number row is weapon slots, which is what rule 2 always
# reserved it for. It used to be RMB, Shift+RMB to ADD and Shift+wheel for
# size, and Shift is SPRINT: carving while running silently added instead of
# removing, and scrolling while running resized instead of zooming.
# `any_mods`: you fire and grapple while sprinting, so these two match with
# modifiers held (tapped() is otherwise exact).
"player.fire": {"mouse": MOUSE_BUTTON_LEFT, "any_mods": true, "label": "Fire the carver"},
"grapple": {"mouse": MOUSE_BUTTON_RIGHT, "any_mods": true, "label": "Grapple"},
"player.carve_mode": {"key": KEY_Q, "label": "Carver: remove / add"},
"player.carve_smaller": {"key": KEY_MINUS, "label": "Carver: smaller brush"},
"player.carve_bigger": {"key": KEY_EQUAL, "label": "Carver: bigger brush"},
# RESPAWN IS NOT A MODE, so it has no business on the F row. Backspace is
# reachable, unambiguous, and cannot be confused with a mode switch.
"respawn": {"key": KEY_BACKSPACE, "label": "Respawn"},
"player.climb_debug": {"key": KEY_F6, "ctrl": true, "label": "Wall-climb debug visuals"},
"player.camera_unstick":{"key": KEY_F8, "ctrl": true, "label": "Unstick the camera spring arm"},
# Was a bare `keycode == KEY_L` in player_procedural -- which is also
# builder.entity_links on the level map. Beside climb debug, which it is.
"player.lizard_debug": {"key": KEY_F6, "ctrl": true, "shift": true, "label": "Lizard-walk debug (crawl on flat ground)"},
# player.fly is GONE. Free-flying IS build mode — a separate toggle would have
# been a second way into the same state, and the two would drift.
},
"builder": {
# KEYMAP STEP 3 (docs/keymap-blender-279.md §3a): K is Blender's knife now. Build-mode
# carve is menu / toolbar only (user 2026-10-12: carving keys are the play-mode
# carver's — LMB fire, Q mode, 1-4 brush).
"builder.carve": {"label": "Carve a sphere at the crosshair"},
# Crosshair pick: LMB click selects (Blender left-select); B is box select now.
"builder.select": {"label": "Select vertex / edge / face under the crosshair"},
# 2.79 with use_select_all_toggle (Blender_27x.py): A toggles all, Alt+A none,
# Ctrl+I inverts. Shift+B (clear) retires.
"builder.select_all": {"key": KEY_A, "label": "Select all / none (toggle)"},
"builder.deselect": {"key": KEY_A, "alt": true, "label": "Clear the selection"},
"builder.select_invert": {"key": KEY_I, "ctrl": true, "label": "Invert the selection"},
"builder.select_box": {"key": KEY_B, "label": "Box select (drag a rectangle; Shift adds)"},
"builder.select_circle": {"key": KEY_C, "label": "Circle select (paint in with LMB, Shift paints out, wheel resizes, RMB ends)"},
# SELECT LINKED / GROUPED (keymap step 6.3; Blender: L / Shift+L linked-pick, Ctrl+L
# linked, Shift+G grouped). "Linked" = the same shape or group; Ctrl+L = touching.
"builder.select_linked_pick": {"key": KEY_L, "label": "Select linked: the shape / group under the pointer"},
"builder.deselect_linked_pick": {"key": KEY_L, "shift": true, "label": "Deselect the shape / group under the pointer"},
"builder.select_linked": {"key": KEY_L, "ctrl": true, "label": "Select linked: everything touching the selection"},
"builder.select_grouped": {"key": KEY_G, "shift": true, "label": "Select grouped (menu)"},
"builder.select_same_group": {"label": "Same group / shape"},
"builder.select_same_material": {"label": "Same material"},
"builder.select_same_texture": {"label": "Same texture"},
"builder.select_same_type": {"label": "Same shape type"},
"builder.move_in": {"key": KEY_BRACKETLEFT, "label": "Move selection in one grid step"},
"builder.move_out": {"key": KEY_BRACKETRIGHT, "label": "Move selection out one grid step"},
# = bigger / - smaller, the same as the carve brush in play mode.
"builder.grid_coarser": {"key": KEY_EQUAL, "label": "Coarser grid"},
"builder.grid_finer": {"key": KEY_MINUS, "label": "Finer grid"},
# Blender's key for it. Shift is fly-fast at top level, so Shift+Tab also speeds
# the camera for the instant it is held — the same accepted trade as Shift+Z.
"builder.snap_toggle": {"key": KEY_TAB, "shift": true, "label": "Snap to grid on / off"},
# map_dc_world only — the embed depth of the level in the procedural
# terrain (level-mesh-implementation.md §5a). Declared here rather than
# read as a keycode in the map because everything in the builder context
# is, and the boot conflict check only sees what is declared. Maps with
# no terrain simply never handle them.
# Alt+PgDn / Alt+PgUp since keymap step 3: , . are Blender's pivot keys.
"builder.lift_down": {"key": KEY_PAGEDOWN, "alt": true, "label": "Lower the level into the terrain"},
"builder.lift_up": {"key": KEY_PAGEUP, "alt": true, "label": "Raise the level out of the terrain"},
# map_dc_world only. The shell subtract now re-derives ITSELF when the level
# changes, debounced by RESHELL_QUIET, so this is no longer the only way to
# get an up-to-date subtract. It stays for the two cases that automation
# cannot cover: the debounce is keyed on the shell cache key, which a face
# nudged along its own normal leaves identical, and forcing a re-derive is
# how you SEE what a deleted boundary brush does to the seal instead of
# reasoning about it. ~2.8 s hitch.
"builder.reshell": {"key": KEY_SEMICOLON, "label": "Re-derive the level shell subtract"},
# HOLD: aim, hold, aim at the opposing face, release. The only binding here
# that needs the key-up, which is why `released()` exists.
"builder.fill": {"key": KEY_F, "hold": true, "label": "Two-point face-to-face fill"},
# CLIP and DELETE together are what make a doorway reachable: cut, cut, delete
# the middle. Neither existed — add_face can only shrink a convex solid, and
# remove_brush had no binding at all.
"builder.brush_view": {"key": KEY_V, "label": "Show / hide brush outlines"},
# CLEARANCE volumes are air the level owns — the headroom over a flight of
# stairs, the bore of an arch — and the terrain is cut out of them. Nothing
# renders them, so without this the only evidence one exists is the hole in
# the hillside, which you cannot see from inside the passage. The view also
# makes them PICKABLE, which is what makes their extents editable with the
# ordinary face tools rather than needing a parallel set of their own.
"builder.clearance_view": {"key": KEY_V, "shift": true, "label": "Show / edit clearance volumes"},
# The WIRING, which is invisible in a rendered level: a trigger_teleport is
# a touch volume with no appearance of its own, and the destination it
# sends you to is a point entity with none either. Nothing on screen says
# they are connected, or which of the four destinations a given pad uses.
# Shift+Alt+L since keymap step 6.3: L is Blender's select linked. An Overlays row
# since step 6.5 — and still on its key (user 2026-10-12: keep the keys).
"builder.entity_links": {"key": KEY_L, "shift": true, "alt": true, "label": "Show / hide entity link lines"},
# OVERLAYS (keymap step 6.5, docs/keymap-blender-279.md §6a.5). The live cutters'
# wire had no switch; Shift+Alt+C is free in 2.79. Shift+Alt+Z is Blender 2.8's
# show_overlays: every overlay off / back as it was, each keeping its own state.
"builder.cutters_view": {"key": KEY_C, "shift": true, "alt": true, "label": "Show / hide live cutters"},
# Curves (their wire + points) as an overlay (2026-10-18); Shift+Alt+K is free in 2.79.
"builder.curves_view": {"key": KEY_K, "shift": true, "alt": true, "label": "Show / hide curves (wire and points)"},
"builder.overlays_all": {"key": KEY_Z, "shift": true, "alt": true, "label": "Overlays: all off / back on"},
# The active GRID drawn on the surface you are aiming at (TrenchBroom's
# GridRenderer, #31): the grid size existed with nothing showing it.
"builder.grid_view": {"key": KEY_APOSTROPHE, "label": "Show / hide the grid"},
# BLENDER'S NUMPAD VIEWS (#109; docs/editor-ortho-view.md, from blender_default.py
# 1683-1765). scenes/game/editor_view.gd runs them; listed in the View menu for
# keyboards without a numpad.
"view.persp_ortho": {"key": KEY_KP_5, "label": "Perspective / orthographic"},
"view.front": {"key": KEY_KP_1, "label": "Front view"},
"view.back": {"key": KEY_KP_1, "ctrl": true, "label": "Back view"},
"view.right": {"key": KEY_KP_3, "label": "Right view"},
"view.left": {"key": KEY_KP_3, "ctrl": true, "label": "Left view"},
"view.top": {"key": KEY_KP_7, "label": "Top view"},
"view.bottom": {"key": KEY_KP_7, "ctrl": true, "label": "Bottom view"},
"view.orbit_down": {"key": KEY_KP_2, "label": "Orbit down 15°"},
"view.orbit_left": {"key": KEY_KP_4, "label": "Orbit left 15°"},
"view.orbit_right": {"key": KEY_KP_6, "label": "Orbit right 15°"},
"view.orbit_up": {"key": KEY_KP_8, "label": "Orbit up 15°"},
"view.pan_down": {"key": KEY_KP_2, "ctrl": true, "label": "Pan down"},
"view.pan_left": {"key": KEY_KP_4, "ctrl": true, "label": "Pan left"},
"view.pan_right": {"key": KEY_KP_6, "ctrl": true, "label": "Pan right"},
"view.pan_up": {"key": KEY_KP_8, "ctrl": true, "label": "Pan up"},
"view.opposite": {"key": KEY_KP_9, "label": "Opposite view"},
"view.selected": {"key": KEY_KP_PERIOD, "label": "Frame the selection"},
# Keymap step 6 (Blender's view_all / view_center_cursor / view_center_pick). Home
# yields to UV fit while faces are selected (editor_view.gd).
"view.all": {"key": KEY_HOME, "label": "View all"},
"view.all_center": {"key": KEY_C, "shift": true, "label": "View all + 3D cursor to the origin"},
"view.center_cursor": {"key": KEY_HOME, "alt": true, "label": "Centre the view on the 3D cursor"},
"view.center_pick": {"key": KEY_F, "alt": true, "label": "Centre the view on the point under the pointer"},
# THE 3D CURSOR (#109, docs/editor-3d-cursor.md; Blender: Shift+RMB place / drag,
# Shift+S snap menu). The snap commands have no key of their own: they run from
# the Shift+S menu, the RMB-tap context menu and the View menu.
"builder.cursor_place": {"mouse": MOUSE_BUTTON_RIGHT, "shift": true, "label": "Place the 3D cursor (drag: slide it)"},
"builder.cursor_menu": {"key": KEY_S, "shift": true, "label": "Snap menu (3D cursor) — when not moving"},
"cursor.to_selected": {"label": "Cursor to selected"},
"cursor.to_active": {"label": "Cursor to active"},
"cursor.to_grid": {"label": "Cursor to grid"},
"cursor.to_origin": {"label": "Cursor to world origin"},
# THE Q MENU and Blender's HIDE (docs/cutting-and-operations.md §9c, user
# 2026-10-12 "Implement hide now"). Q is free in build mode (player.carve_mode
# is suppressed there). Hide drives the Outliner's own rows, so its eye agrees.
"builder.q_menu": {"key": KEY_Q, "label": "Operations menu (HardOps Q)"},
# BLENDER'S FLY MODAL (2.79 View3D Fly Modal; keymap step 1, §2a).
"builder.fly": {"key": KEY_F, "shift": true,
"label": "Fly: WASD, E/Q up/down, wheel speed — Enter keeps, Esc goes back"},
"builder.hide": {"key": KEY_H, "label": "Hide the selection"},
"builder.hide_unselected": {"key": KEY_H, "shift": true, "label": "Hide everything else"},
"builder.unhide_all": {"key": KEY_H, "alt": true, "label": "Unhide all"},
# Q-menu rows with no key of their own (menu-only, like cursor.*).
"builder.make_real": {"label": "Make Real (bake the shape and its cuts)"},
# E on a shape piece (#109, user 2026-10-13: "ask to make real or cancel"): the
# prompt's two rows. The pieces are read-only; Make Real first, then Extrude.
"builder.extrude_make_real": {"label": "Make Real, then extrude this face"},
"builder.prompt_cancel": {"label": "Cancel"},
# A shape's part edited (#109 "select, prompt on edit"): the guard's prompt row.
"builder.make_real_then": {"label": "Make Real, then go on"},
"builder.cuts_clear": {"label": "Clear the cuts (on it / made with it)"},
"builder.cut_as_subtract": {"label": "Cutter: subtract"},
"builder.cut_as_intersect": {"label": "Cutter: intersect"},
"builder.cut_as_slice": {"label": "Cutter: slice (keep both halves)"},
"builder.select_cut_target": {"label": "Select what this cutter cuts"},
# The Knife is MODAL now: C takes it up, X/Y/Z pick the axis inside it, Enter
# cuts, Escape cancels with nothing written. Shift+C is GONE — Shift is
# sprint, so the old chord meant that while sprinting you got the horizontal
# cut whether you wanted it or not. Variants belong inside an operator, not
# on a modifier the movement system already owns.
# OPERATORS ARE UNMODIFIED LETTERS. Movement owns Shift and Ctrl in build mode,
# so a chord would mean the tool you get depends on whether you were sprinting.
# Variants (axis, distance, size) live INSIDE each operator.
# KEYMAP STEP 3: Blender's keys now that WASD no longer fly (step 1) — K knife,
# Shift+D duplicate, S scale.
"builder.knife": {"key": KEY_K, "label": "Knife: cut the aimed brush"},
"builder.move": {"key": KEY_G, "label": "Move the aimed brush"},
"builder.duplicate": {"key": KEY_D, "shift": true, "label": "Duplicate the selection and move it"},
"builder.duplicate_linked": {"key": KEY_D, "alt": true, "label": "Duplicate linked: the copy shares the shape's data (edit one, both change)"},
"builder.rotate": {"key": KEY_R, "label": "Rotate the selection (X Y Z axis, [ ] or wheel)"},
"builder.scale": {"key": KEY_S, "label": "Scale the selection (X Y Z axis, C pivot, [ ] or wheel)"},
# Blender's transform.mirror (#109): Ctrl+M, then X / Y / Z, Enter.
"builder.mirror": {"key": KEY_M, "ctrl": true, "label": "Mirror the selection (X Y Z axis, about the pivot)"},
# Blender's pair: parent the selected objects to the ACTIVE one; clear keeps them
# where they are in the world.
"builder.parent": {"key": KEY_P, "ctrl": true, "label": "Parent the selected objects to the active one"},
"builder.clear_parent": {"key": KEY_P, "alt": true, "label": "Clear the parent (keep the world position)"},
# OBJECTS AND COLLECTIONS (#120 F1, Blender 2.79's keys: tools/keymap/blender_279_left.json).
# A brush object is a group of brushes; a collection holds objects and collections.
"builder.collection_new": {"key": KEY_G, "ctrl": true, "label": "New collection from the selection"},
# #120 L4: menu-only (user 2026-10-19), Object menu.
"builder.save_asset": {"label": "Save collection as asset…"},
"builder.place_asset": {"label": "Asset: place the current one at the 3D cursor"},
"builder.collection_move": {"key": KEY_M, "label": "Move to collection…"},
"builder.collection_link": {"key": KEY_M, "shift": true, "label": "Link to collection… (also)"},
"builder.collection_remove": {"key": KEY_G, "ctrl": true, "alt": true, "label": "Remove from collection…"},
"builder.join": {"key": KEY_J, "ctrl": true, "label": "Join the selected brushes into the active one's object"},
"builder.separate": {"key": KEY_P, "label": "Separate the selected brushes into a new object"},
"builder.rename": {"key": KEY_F2, "label": "Rename the selected collection (outliner)"},
# Menu-only (Object menu, Q menu): any brush -> a parametric shape (#120 F2).
"builder.make_parametric": {"label": "Make parametric (the brush becomes a shape: box or hull)"},
"builder.add_curve": {"label": "Curve (place points on surfaces; for cables, follow-curve)"},
"builder.add_cable": {"label": "Cable (place points on surfaces; [ ] slack hangs each span)"},
"builder.edit_curve": {"label": "Edit curve (the aimed point: move, Tab, X delete, I insert)"},
# #120 L3 §14.3 (Cablerator's Drop the Cable). Ctrl+Shift+D: with Shift+D / Alt+D
# beside it, and free in Blender's 3D view.
"builder.drop_cable": {"key": KEY_D, "ctrl": true, "shift": true, "label": "Drop the selected cables to the ground (ends stay)"},
# #120 L3 §14.5. Alt+C: Blender 2.79's Convert (to Curve from Mesh), the nearest act.
"builder.cable_from_edges": {"key": KEY_C, "alt": true, "label": "Cable from edges (edge mode: a cable per chain of selected edges)"},
# #120 L3 §14.6. Shift+Ctrl+C, beside Alt+C (Blender 2.79's is Add Constraint with
# targets; there are no constraints here).
"builder.cable_bundle": {"key": KEY_C, "ctrl": true, "shift": true, "label": "Cable bundle between two face sets (face mode: two islands)"},
"builder.assign_generator": {"label": "Generator… (stairs, tunnel … on this shape; it stays the passage)"},
"builder.add_modifier": {"key": KEY_M, "ctrl": true, "shift": true, "label": "Add modifier… (mirror, array, grid, radial, curve, chamfer, dice on this shape)"},
# Menu-only, as in Blender (the outliner's / X menu's Delete Hierarchy): X keeps children.
"builder.delete_hierarchy": {"label": "Delete hierarchy (the objects and everything under them)"},
"builder.extrude": {"key": KEY_E, "label": "Extrude the aimed face"},
# The create tool: from Shift+A (the add menu, one row per shape) and the Tools
# column — N is Blender's sidebar since keymap step 3.
"builder.create_box": {"label": "New shape on the aimed surface (create tool)"},
"builder.add_menu": {"key": KEY_A, "shift": true, "label": "Add menu (every shape type)"},
# The add menu's rows: the create tool, already set to that shape (param_shapes ids).
"builder.add_box": {"label": "Box"},
"builder.add_stairs": {"label": "Stairs"},
"builder.add_spiral_stairs": {"label": "Spiral stairs"},
"builder.add_tunnel": {"label": "Arch / tunnel"},
"builder.add_cylinder": {"label": "Cylinder"},
"builder.add_cone": {"label": "Cone"},
"builder.add_sphere": {"label": "Sphere"},
# THE U MENU (keymap step 3; Blender's U = the UV menu): every texture command, each
# also on its own key (user: "in a menu AND accessible via hotkey").
"builder.uv_menu": {"key": KEY_U, "label": "Texture menu (apply, pick, hotspot, fit ...)"},
"builder.texture_apply": {"key": KEY_U, "ctrl": true, "label": "Apply the current texture to the selection"},
"builder.texture_pick": {"key": KEY_U, "alt": true, "label": "Pick the aimed face's texture (eyedropper)"},
"builder.confirm": {"key": KEY_ENTER, "label": "Confirm the active operator"},
# Editor redesign E3 (docs/editor-redesign.md §5): search every command by name.
# SPACE since keymap step 3 — Blender_27x.py's spacebar_action='SEARCH' (Space was
# fly-up until step 1).
"builder.palette": {"key": KEY_SPACE, "label": "Search every command (palette)"},
# Blender's sidebar (N) and toolbar (T). T is global chat (toggle_chat), so the
# toolbar toggle takes Ctrl+T.
"builder.toggle_sidebar": {"key": KEY_N, "label": "Show / hide the right column (sidebar)"},
"builder.toggle_toolbar": {"key": KEY_T, "ctrl": true, "label": "Show / hide the left column (tools)"},
# #124 B3: panels moved with their header's right-click "Move to ▸" go home.
"builder.reset_panels": {"key": KEY_N, "ctrl": true, "alt": true, "label": "Reset panel layout (every panel back to its column)"},
# #120 L2 §13: BoxCutter's own start key (its manual: "alt + w with cursor in 3d view").
"builder.box_cutter": {"key": KEY_W, "alt": true, "label": "BoxCutter: draw a cutter on the aimed surface"},
# #120 L2 §13.4–13.5: the Q menu's stack operations (StackOps), each with a key.
"builder.change_cut": {"key": KEY_X, "alt": true, "label": "Change cut… (subtract / slice / intersect / inset / outset)"},
"builder.extract_cut": {"key": KEY_Y, "alt": true, "label": "Extract… (a cut's target ∩ cutter becomes new shapes)"},
"builder.apply_up_to": {"key": KEY_A, "ctrl": true, "alt": true, "label": "Apply up to here… (bake the cuts up to one, keep the rest live)"},
# Stack scroll (HardOps' modifier_scroll): a LOCAL preview of the first k cuts.
"builder.stack_scroll_back": {"key": KEY_BRACKETLEFT, "ctrl": true, "alt": true, "label": "Stack scroll: preview one cut fewer"},
"builder.stack_scroll_fwd": {"key": KEY_BRACKETRIGHT, "ctrl": true, "alt": true, "label": "Stack scroll: preview one cut more"},
"builder.adjust_last": {"key": KEY_F6, "label": "Adjust Last (the last operation's settings)"},
"builder.history": {"key": KEY_Z, "ctrl": true, "alt": true, "label": "Undo History"},
"builder.repeat_last": {"key": KEY_R, "shift": true, "label": "Repeat the last command (starts the tool again)"},
"builder.element_toggle": {"key": KEY_TAB, "label": "Element mode ↔ brush mode"},
# Blender 2.79 Mesh: Ctrl+Tab = VIEW3D_MT_edit_mesh_select_mode (#109 step 6).
"builder.select_mode_menu": {"key": KEY_TAB, "ctrl": true, "label": "Select mode menu (vertex / edge / face / brush)"},
# THE PIVOT (keymap step 6.2; Blender 2.79's , . keys). Ctrl+. (individual origins)
# is a later item, so it is not bound to nothing.
"builder.pivot_bbox": {"key": KEY_COMMA, "label": "Pivot: bounding box centre"},
"builder.pivot_median": {"key": KEY_COMMA, "ctrl": true, "label": "Pivot: median point"},
"builder.pivot_cursor": {"key": KEY_PERIOD, "label": "Pivot: 3D cursor"},
"builder.pivot_active": {"key": KEY_PERIOD, "alt": true, "label": "Pivot: active element"},
"builder.pivot_individual": {"key": KEY_PERIOD, "ctrl": true, "label": "Pivot: individual origins (each about its own centre)"},
# PROPORTIONAL EDITING (#109, Blender 2.7x): O / Shift+O / Alt+O.
"builder.proportional": {"key": KEY_O, "label": "Proportional editing on / off"},
"builder.proportional_falloff": {"key": KEY_O, "shift": true, "label": "Proportional falloff: next"},
"builder.proportional_connected": {"key": KEY_O, "alt": true, "label": "Proportional: connected only on / off"},
"builder.wireframe": {"key": KEY_Z, "label": "Wireframe on / off"},
# Blender's Shift+Space (user decision 2026-10-02). Shift+Space is fast fly-up at
# top level for the frame it is pressed; the popup's `menu` context stops the
# camera from then on — the same accepted trade as Shift+Z.
"builder.tool_popup": {"key": KEY_SPACE, "shift": true, "label": "Tools at the cursor (popup)"},
# THE WHEEL DRIVES THE ACTIVE TOOL (user decision 2026-10-02): the same as the
# tool's ] / [ — box size, knife offset, move steps, extrude push. Only while a
# tool is up and the cursor is out (map_dc_level); while looking (RMB held) the
# wheel stays camera zoom / Alt+wheel fly speed, and over a panel it scrolls it.
# Wheel DOWN = bigger, UP = smaller: the user's direction (2026-10-02, "it should
# be inverted" after playing with up = bigger).
"builder.tool_more": {"mouse": MOUSE_BUTTON_WHEEL_DOWN, "label": "Active tool: bigger / more (same as ])"},
"builder.tool_less": {"mouse": MOUSE_BUTTON_WHEEL_UP, "label": "Active tool: smaller / less (same as [)"},
"builder.delete": {"key": KEY_DELETE, "label": "Delete the selected objects, or the aimed brush"},
# Blender 2.79: X deletes WITH a confirm (a one-row popup), Delete without (step 6.4).
"builder.delete_menu": {"key": KEY_X, "label": "Delete… (confirm)"},
# CTRL+Z SINCE KEYMAP STEP 3. It was plain Z because Ctrl was DESCEND whenever you
# were in build mode, and undoing dropped you through the floor (descend is
# polled, so nothing could intercept it). Step 1 made Ctrl descend ONLY while
# flying, and the exclusive `fly` context suppresses every command then — so
# Blender's Ctrl+Z is safe. Plain Z is Blender's wireframe toggle.
#
# ⚠ While an operator is up, X/Y/Z are ITS axis keys and it gets first refusal.
"builder.undo": {"key": KEY_Z, "ctrl": true, "label": "Undo my last edit"},
"builder.redo": {"key": KEY_Z, "ctrl": true, "shift": true, "label": "Redo"},
},
# UV CONTROLS on the selection (docs/uv-controls-design.md). Arrows move the texture
# one grid step the way they point ON SCREEN (TrenchBroom's camera mapping); Home /
# End always step to the NEXT fit / edge, Shift to the previous (TrenchBroom).
"face_sel": {
"builder.uv_left": {"key": KEY_LEFT, "label": "Move texture left (as you see it, one grid step)"},
"builder.uv_right": {"key": KEY_RIGHT, "label": "Move texture right (as you see it, one grid step)"},
"builder.uv_up": {"key": KEY_UP, "label": "Move texture up (as you see it, one grid step)"},
"builder.uv_down": {"key": KEY_DOWN, "label": "Move texture down (as you see it, one grid step)"},
"builder.uv_rot_ccw": {"key": KEY_PAGEUP, "label": "Rotate texture +15°"},
"builder.uv_rot_cw": {"key": KEY_PAGEDOWN, "label": "Rotate texture -15°"},
"builder.uv_rot_ccw_fine": {"key": KEY_PAGEUP, "shift": true, "label": "Rotate texture +1°"},
"builder.uv_rot_cw_fine": {"key": KEY_PAGEDOWN, "shift": true, "label": "Rotate texture -1°"},
"builder.uv_fit": {"key": KEY_HOME, "label": "Fit texture to the face (again: next fit)"},
"builder.uv_align": {"key": KEY_END, "label": "Align texture to a face edge (again: next edge)"},
"builder.uv_fit_prev": {"key": KEY_HOME, "shift": true, "label": "Fit texture: previous fit"},
"builder.uv_align_prev": {"key": KEY_END, "shift": true, "label": "Align texture: previous edge"},
# HOTSPOT (#94 P2, docs/hotspot-trim-design.md §6): fit each selected face to the best
# rect of its trim sheet; re-roll picks another of the near-equal rects.
# Shift+U since 2026-10-12: Shift+F is Blender's fly (keymap step 1).
"builder.uv_hotspot": {"key": KEY_U, "shift": true, "label": "Hotspot: fit each face to its trim sheet"},
# Shift+Alt+U since keymap step 3: Shift+R is Blender's repeat-last.
"builder.uv_reroll": {"key": KEY_U, "shift": true, "alt": true, "label": "Hotspot: re-roll the rect choice"},
# Ctrl+Shift+U (#109 build tools): the family's third key — a command in a menu
# needs a key too. Free in Blender 2.7x (Window Ctrl+U, Screen Ctrl+Alt+U only).
"builder.uv_pin": {"key": KEY_U, "ctrl": true, "shift": true, "label": "Hotspot: pin the rect each face shows now"},
},
# THE NUMBER ROW, WHICH RULE 2 FORBIDS — and this is the exception that proves the
# context system works rather than a hole in it. `1`-`9` are weapon slots, so no
# TOOL may take them; but `build_mode` is EXCLUSIVE and suppresses `player`, so
# while building there are no weapons to conflict with. That is precisely what
# Blender does (1/2/3 select vertex/edge/face in Edit Mode and mean something else
# outside it), and precisely what an exclusive context is for. Put them in
# `builder` instead and they WOULD collide, because `builder` is additive and stays
# live during play on the level map.
#
# 1/2/3 keep Blender's order exactly, so the muscle memory transfers; `4` is the
# brush itself, which is Blender's Object Mode and has no number of its own there.
"build_mode": {
"build_mode.sel_vertex": {"key": KEY_1, "label": "Select mode: vertex"},
"build_mode.sel_edge": {"key": KEY_2, "label": "Select mode: edge"},
"build_mode.sel_face": {"key": KEY_3, "label": "Select mode: face"},
"build_mode.sel_brush": {"key": KEY_4, "label": "Select mode: whole brush"},
# CSG — PLAIN LETTERS, and the first attempt at these got it wrong in a way
# worth recording, because rule 1 and the operator rule contradict each other
# in build mode and rule 1 loses.
#
# These shipped as Ctrl+D / Ctrl+I / Ctrl+M, reasoning that CSG is an editor
# command and rule 1 reserves Ctrl+letter for those. But `Ctrl` is DESCEND
# while flying, so every CSG op also dropped you through the floor. And it
# cannot be fixed by consuming the event: descend is
# `Input.is_action_pressed("crouch")` POLLED in the physics step
# (player_procedural.gd:1892), not an event, so no input handler can intercept
# it. A held modifier that movement polls is unusable as a command binding, and
# that is a property of polling rather than of these three keys.
#
# So: build mode owns the plain letters movement does not (W A S D are out),
# exactly as §3 of docs/build-mode-operators.md says — "movement owns the
# modifiers, operators are unmodified letters". H for hole, I for intersect,
# J for join, which is Blender's Ctrl+J and the same idea.
#
# ⚠ STALE UNTIL NOW, and it read as an instruction: builder.undo is PLAIN Z
# (see its binding above), not Ctrl+Z. It was moved precisely because Ctrl is
# descend and undoing while flying also descended — keymap_gate asserts
# "Ctrl+Z now does NEITHER". _delete_aimed's message still said "Ctrl+Z to
# bring it back" long after that, so the product surface was telling people
# to press the one chord guaranteed to do nothing.
#
# MERGE IS "CONVEX MERGE" IN THE LABEL AND WILL STAY THAT WAY. It is the hull
# of both brushes' vertices, not a union — merging two separated boxes fills
# the gap between them. Someone reaching for union and getting a hull would
# rightly call it a bug, so the name has to carry what the geometry cannot.
# BOOL TOOL / HARDOPS KEYS (keymap step 1, §2a): Ctrl is only "descend" while
# FLYING now, and the `fly` context suppresses build_mode then — so a Ctrl chord
# can no longer drop you through the floor. (H is Blender's hide; I is free for
# inset later.)
"build_mode.csg_subtract": {"key": KEY_KP_SUBTRACT, "ctrl": true,
"label": "CSG: active MINUS the others (hole)"},
# LIVE ONLY: needs a shape as the cutter (docs/cutting-and-operations.md §9c).
"build_mode.csg_slice": {"key": KEY_KP_DIVIDE, "ctrl": true,
"label": "CSG: slice — keep both halves (live, shape cutter)"},
"build_mode.csg_intersect": {"key": KEY_KP_MULTIPLY, "ctrl": true,
"label": "CSG: active INTERSECT the others"},
"build_mode.csg_merge": {"key": KEY_J,
"label": "CSG: convex merge — a HULL, not a union"},
# Menu-only since Alt+H became Blender's unhide all (§9c).
"build_mode.csg_discard": {"label": "CSG: discard the cutters afterwards (on / off)"},
# FACES -> NEW BRUSHES (2026-10-05). J is hull (merge), E is extrude; Alt makes
# them act on the selected FACES rather than brushes.
"build_mode.faces_hull": {"key": KEY_J, "alt": true,
"label": "New brush: hull of the selected faces"},
"build_mode.faces_extrude": {"key": KEY_E, "alt": true,
"label": "New brushes: extrude each selected face one grid step"},
},
# INSIDE THE FLY MODAL (Shift+F; Blender 2.79 View3D Fly Modal, §2a). WASD are the
# movement actions (polled, as in RMB-look); these are the modal's own. Only live while
# the `fly` context is on, which suppresses every editor key.
"fly": {
# POSITION, like WASD (§6): up / down sit beside the movement keys on any layout.
"fly.up": {"key": KEY_E, "hold": true, "by": "position", "label": "Fly up"},
"fly.down": {"key": KEY_Q, "hold": true, "by": "position", "label": "Fly down"},
"fly.confirm": {"key": KEY_ENTER, "label": "Keep this view (also LMB / Space)"},
"fly.confirm_alt": {"key": KEY_SPACE, "label": "Keep this view"},
"fly.cancel": {"key": KEY_ESCAPE, "label": "Fly back to where you started (also RMB)"},
},
}
## The context STACK, innermost last. Order matters: an exclusive context suppresses
## the non-`always` contexts BELOW it, so pushing "builder" over "player" silences
## the player without either of them knowing about the other.
var _stack: Array[String] = ["player"]
var _overrides: Dictionary = {} # action -> binding, from disk
## KEY TRACE (HYPER_TPS_KEYTRACE=1): for an intermittent "the key did nothing".
## Logs every key press as it ENTERS the tree — with the GUI focus owner, the context
## stack and the mouse mode — plus each action tapped() matches, and each action the
## key would have matched but whose context was inactive. The map already prints when
## an operator activates, so a key's whole path is in the log. Off = no cost.
var _keytrace := OS.get_environment("HYPER_TPS_KEYTRACE") == "1"
## Sees every key FIRST: Godot calls _input from the root's LAST child backwards, so
## this keeps itself the last child (an autoload would see keys only after the game).
class _KeyTracer:
extends Node
var km: Node = null
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
get_viewport().gui_focus_changed.connect(func(c: Control) -> void:
print("[KeyTrace] focus -> %s" % (str(c.get_path()) if c != null else "none")))
func _process(_d: float) -> void:
var p := get_parent()
if p != null and p.get_child(p.get_child_count() - 1) != self:
p.move_child(self, -1)
func _input(event: InputEvent) -> void:
if not (event is InputEventKey) or not event.pressed or event.echo:
return
var f: Control = get_viewport().gui_get_focus_owner()
print("[KeyTrace] %s focus=%s ctx=%s mouse=%d" % [(event as InputEventKey).as_text(),
str(f.get_path()) if f != null else "none", str(km.get("_stack")), Input.mouse_mode])
func _ready() -> void:
if _keytrace:
var t := _KeyTracer.new()
t.name = "KeyTracer"
t.km = self
get_tree().root.add_child.call_deferred(t)
print("[KeyTrace] ON (HYPER_TPS_KEYTRACE=1)")
_load_overrides()
register_all()
for c: String in conflicts():
# A WARNING, not a failure: the same key meaning different things in
# different contexts is correct, and only becomes a bug when two bindings in
# the SAME context collide. That is what this reports.
push_warning("[Keymap] %s" % c)
process_mode = Node.PROCESS_MODE_ALWAYS
## FOCUS IS POLLED, not signalled: gui_focus_changed fires when a control GAINS focus
## but not when focus is released to nothing (release_focus, a click on empty space),
## and a text_input context that never popped would freeze every tool. One focus-owner
## read per frame is the whole cost.
func _process(_delta: float) -> void:
var vp := get_viewport()
if vp == null:
return
var typing := _is_text_field(vp.gui_get_focus_owner())
if typing != _stack.has("text_input"):
if typing:
push_context("text_input")
else:
pop_context("text_input")
static func _is_text_field(c: Control) -> bool:
if c == null or not c.is_visible_in_tree():
return false
if c is LineEdit:
return (c as LineEdit).editable
if c is TextEdit:
return (c as TextEdit).editable
return false
## May movement read the keyboard? False while a text field has focus — the keys are
## words, not steps. (Movement is POLLED, so contexts alone cannot stop it.)
func movement_allowed() -> bool:
return not _stack.has("text_input") and not _stack.has("menu")
## Is the camera FLYING right now — RMB held, or the Shift+F fly modal (keymap step 1,
## docs/keymap-blender-279.md §2a)? The build-mode camera moves only then.
func flying() -> bool:
return movement_allowed() and is_context_active("fly")
## Do Ctrl / Shift / Space belong to the fly camera (down / fast / up) right now? ONLY
## while flying (§2a): the `fly` context is exclusive, so a tool or a command never
## competes for them — the old "a tool owns them unless RMB is held" rule is this rule.
func fly_modifiers_allowed() -> bool:
return flying()
# ---- the Shortcut bridge (editor redesign E0, §1) -----------------------------
var _shortcuts: Dictionary = {} # action -> Shortcut
## ONE Shortcut per action, shared by every surface (toolbar button, menu item,
## palette). Its event is an InputEventAction, which matches whatever key the action
## is bound to in InputMap and prints THAT key — so a rebind (which re-registers the
## InputMap event) updates every tooltip and accelerator with no extra code. Godot's
## own editor shares one Ref<Shortcut> per command for the same reason.
func shortcut_of(action: String) -> Shortcut:
if _shortcuts.has(action):
return _shortcuts[action]
var sc := Shortcut.new()
var b := binding_of(action)
sc.resource_name = String(b.get("label", LABELS.get(action, action)))
var ev := InputEventAction.new()
ev.action = action
ev.pressed = true
sc.events = [ev]
_shortcuts[action] = sc
return sc
## Run an action as if its key were pressed — for menus, buttons and the palette. It
## goes through normal input dispatch, so the SAME handler (Keymap.tapped in the map's
## input code) runs: one code path for a key, a click and a search.
##
## It replays the action's BOUND KEY, not an InputEventAction: map_dc_level dispatches
## in _unhandled_KEY_input, which never sees a non-key event (an InputEventAction
## replay reached nothing — gate_editor_e0 caught it). Replaying the key also keeps
## every rule a key press obeys, e.g. an active operator's first refusal.
func run(action: String) -> void:
var b := binding_of(action)
# MENU-ONLY actions (no key) take the action path too — the key path would replay a
# keycode-0 key that matches nothing (gate_editor_cursor C6).
if b.is_empty() or b.has("mouse") or not b.has("key"):
for pressed: bool in [true, false]:
var ea := InputEventAction.new()
ea.action = action
ea.pressed = pressed
Input.parse_input_event(ea)
return
for pressed: bool in [true, false]:
var ev := InputEventKey.new()
ev.physical_keycode = int(b.get("key", 0)) as Key
ev.keycode = ev.physical_keycode
ev.ctrl_pressed = bool(b.get("ctrl", false))
ev.shift_pressed = bool(b.get("shift", false))
ev.alt_pressed = bool(b.get("alt", false))
ev.pressed = pressed
Input.parse_input_event(ev)
# ---- registration -----------------------------------------------------------
func register_all() -> void:
for context: String in DEFAULTS:
for action: String in (DEFAULTS[context] as Dictionary):
_register(action, binding_of(action))
func _register(action: String, b: Dictionary) -> void:
if b.is_empty():
return
if not InputMap.has_action(action):
InputMap.add_action(action)
InputMap.action_erase_events(action)
# MENU-ONLY actions (no key, no button — e.g. the 3D cursor's snap commands, run from
# the Shift+S menu): the action exists with NO event. A keyless InputEventKey would
# be physical keycode 0, which matches events nothing meant to bind.
if not b.has("key") and not b.has("mouse"):
return
if b.has("mouse"):
var mb := InputEventMouseButton.new()
mb.button_index = int(b["mouse"])
# Modifiers too (Shift+RMB places the 3D cursor); exact-match tapped() needs them.
mb.ctrl_pressed = bool(b.get("ctrl", false))
mb.shift_pressed = bool(b.get("shift", false))
mb.alt_pressed = bool(b.get("alt", false))
InputMap.action_add_event(action, mb)
return
var ev := InputEventKey.new()
# KEYBOARD LAYOUTS (keymap step 5, docs/keymap-blender-279.md §6). A LETTER command
# matches the CHARACTER the layout types (Blender: GHOST_SystemX11.cc 1050-1097 takes
# the KEYSYM) — on AZERTY "G for grab" is where the layout puts G. Everything else
# matches the key's POSITION: the number row (Blender forces X11 keycodes 10-19 to
# digits, so AZERTY's & é " ' still selects modes 1-4), numpad, [ ] - = (they move or
# vanish on many layouts), and whatever a binding marks `by: "position"` (the fly
# modal's E / Q, like WASD — where we deliberately differ from Blender, whose fly keys
# go by character and scatter on AZERTY).
if is_positional(int(b.get("key", 0)), b):
ev.physical_keycode = int(b.get("key", 0))
else:
ev.keycode = int(b.get("key", 0))
ev.ctrl_pressed = bool(b.get("ctrl", false))
ev.shift_pressed = bool(b.get("shift", false))
ev.alt_pressed = bool(b.get("alt", false))
InputMap.action_add_event(action, ev)
## The layout rule (§6) lives in KeyRules (autoloads/key_rules.gd) so operators and
## --script gates, which compile before this autoload exists, share it.
static func is_positional(key: int, b: Dictionary = {}) -> bool:
return KeyRules.is_positional(key, b)
static func key_of(e: InputEventKey) -> int:
return KeyRules.key_of(e)
## The binding in force for an action: the player's override if any, else default.
func binding_of(action: String) -> Dictionary:
if _overrides.has(action):
return _overrides[action]
for context: String in DEFAULTS:
var m: Dictionary = DEFAULTS[context]
if m.has(action):
return m[action]
return {}
func context_of(action: String) -> String:
for context: String in DEFAULTS:
if (DEFAULTS[context] as Dictionary).has(action):
return context
# Pre-existing actions: bindings in project.godot, membership here.
return String(MEMBERSHIP.get(action, ""))
# ---- contexts ---------------------------------------------------------------
## Enter a context. Re-pushing one that is already on the stack MOVES it to the top
## rather than duplicating it, so a map that is reloaded cannot leave a stale copy
## underneath and then pop the wrong one.
##
## OWNED HOLDS (2026-10-05). A context with an `owner` stays on the stack while ANY
## owner still holds it. Without this a MAP CHANGE killed every builder key: maps push
## `builder` in _ready and pop it in _exit_tree, and game.change_map() queue_frees the
## old map and adds the new one at once — so the old map's pop ran at the END of the
## frame, AFTER the new map's push, and took `builder` with it (N, Shift+Space, Delete
## dead until the next map load; mouse picks, outside the keymap, still worked).
## Reproduced and gated by scenes/test/gate_map_change_keys.gd. With no owner the
## behaviour is exactly the old one: one anonymous holder, one pop removes it.
var _holders: Dictionary = {} # context -> {owner instance id (0 = none): true}
func push_context(context: String, owner: Object = null) -> void:
if not CONTEXTS.has(context):
push_warning("[Keymap] unknown context %s" % context)
return
var k: int = owner.get_instance_id() if owner != null else 0
if not _holders.has(context):
_holders[context] = {}
(_holders[context] as Dictionary)[k] = true
_stack.erase(context)
_stack.append(context)
func pop_context(context: String, owner: Object = null) -> void:
var k: int = owner.get_instance_id() if owner != null else 0
if _holders.has(context):
var h: Dictionary = _holders[context]
h.erase(k)
if not h.is_empty():
return # someone else still holds it
_holders.erase(context)
_stack.erase(context)
## Is this context reachable right now?
##
## "" means an action nobody assigned a context to — active, because the alternative
## is silently swallowing input from code that predates this layer.
func is_context_active(context: String) -> bool:
if context == "":
return true
if bool((CONTEXTS.get(context, {}) as Dictionary).get("always", false)):
return true
var at: int = _stack.rfind(context)
if at < 0:
return false
# A context that only exists INSIDE another (builder inside build_mode).
var req: String = String((CONTEXTS.get(context, {}) as Dictionary).get("requires", ""))
if req != "" and not is_context_active(req):
return false
# Anything exclusive ABOVE it wins — unless that context explicitly keeps this one.
for i in range(at + 1, _stack.size()):
var above: Dictionary = CONTEXTS.get(_stack[i], {})
if not bool(above.get("exclusive", false)):
continue
var keeps: Array = above.get("keeps", [])
if not keeps.has(context):
return false
return true
## The stack, top last — for a debug overlay and for the gate.
func active_contexts() -> Array[String]:
var out: Array[String] = []
for c: String in _stack:
if is_context_active(c):
out.append(c)
return out
# ---- dispatch ---------------------------------------------------------------
## A discrete press. EXACT by construction: an action bound to plain Z must not
## fire on Ctrl+Z. Use this for every toggle and shortcut.
func tapped(event: InputEvent, action: String) -> bool:
if not is_context_active(context_of(action)):
# KEYTRACE: the key IS this action's, but its context is not active right now
# — the case that reads as "the key sometimes does nothing".
if _keytrace and event.is_action_pressed(action, false, not _any_mods(action)):
print("[KeyTrace] ~ %s: key matches, context '%s' NOT active (stack %s)"
% [action, context_of(action), str(_stack)])
return false
var hit := event.is_action_pressed(action, false, not _any_mods(action))
if hit and _keytrace:
print("[KeyTrace] -> %s" % action)
return hit
## The key-up of a hold action — the other half of builder.fill.
func released(event: InputEvent, action: String) -> bool:
if not is_context_active(context_of(action)):
return false
return event.is_action_released(action, not _any_mods(action))
func _any_mods(action: String) -> bool:
return bool(binding_of(action).get("any_mods", false))
## A continuous hold, polled rather than event-driven. NON-exact on purpose:
## movement has to keep working while a modifier is down.
func held(action: String) -> bool:
if not is_context_active(context_of(action)):
return false
return Input.is_action_pressed(action)
# ---- rebinding --------------------------------------------------------------
## Rebind an action. Returns "" on success, or the reason it was refused — a
## caller (a settings screen) needs to be able to say why rather than silently
## keeping the old key.
func rebind(action: String, key: int, ctrl := false, shift := false, alt := false) -> String:
if binding_of(action).is_empty():
return "unknown action %s" % action
var b: Dictionary = {"key": key, "ctrl": ctrl, "shift": shift, "alt": alt}
var old: Dictionary = binding_of(action)
if old.has("hold"):
b["hold"] = old["hold"]
if old.has("label"):
b["label"] = old["label"]
var clash := _clash_within_context(action, b)
if clash != "":
return "that key is already %s" % clash
_overrides[action] = b
_register(action, b)
_save_overrides()
return ""
func reset_binding(action: String) -> void:
_overrides.erase(action)
_register(action, binding_of(action))
_save_overrides()
## Every binding in force, declared here or in project.godot, for a settings screen
## or the help overlay. Grouped by context, and INCLUDING the pre-existing actions —
## a key list that showed only the new bindings would be worse than none.
func bindings() -> Array:
var out: Array = []
for context: String in DEFAULTS:
for action: String in (DEFAULTS[context] as Dictionary):
var b: Dictionary = binding_of(action)
out.append({
"action": action, "context": context,
"label": String(b.get("label", "")),
"keys": describe(action),
})
for action: String in MEMBERSHIP:
out.append({
"action": action, "context": String(MEMBERSHIP[action]),
"label": String(LABELS.get(action, action.capitalize())),
"keys": describe(action),
})
return out
## Readable names for the pre-existing actions, since an action id is not a label.
const LABELS: Dictionary = {
"move_forward": "Move forward", "move_back": "Move back",
"move_left": "Strafe left", "move_right": "Strafe right",
"jump": "Jump", "crouch": "Crouch", "sprint": "Sprint", "walk": "Walk",
"grapple": "Grapple", "toggle_light": "Toggle flashlight",
"toggle_chat": "Chat", "ui_cancel": "Release mouse / back out",
}
func _describe_event(k: InputEventKey) -> String:
var parts: Array[String] = []
if k.ctrl_pressed:
parts.append("Ctrl")
if k.shift_pressed:
parts.append("Shift")
if k.alt_pressed:
parts.append("Alt")
parts.append(OS.get_keycode_string(
k.physical_keycode if k.physical_keycode != 0 else k.keycode))
return "+".join(parts)
## Human-readable binding, e.g. "Ctrl+Shift+Z".
##
## Falls back to READING THE ENGINE for actions whose binding lives in
## project.godot, so a help screen built on this cannot silently omit movement —
## the most important half of the key list is exactly the half this file does not
## own.
func describe(action: String) -> String:
var b: Dictionary = binding_of(action)
if b.is_empty():
if not InputMap.has_action(action):
return ""
for ev: InputEvent in InputMap.action_get_events(action):
if ev is InputEventKey:
return _describe_event(ev as InputEventKey)
if ev is InputEventMouseButton:
return "Mouse %d" % (ev as InputEventMouseButton).button_index
return ""
if b.has("mouse"):
return {MOUSE_BUTTON_LEFT: "LMB", MOUSE_BUTTON_RIGHT: "RMB", MOUSE_BUTTON_MIDDLE: "MMB",
MOUSE_BUTTON_WHEEL_UP: "Wheel up", MOUSE_BUTTON_WHEEL_DOWN: "Wheel down"}.get(
int(b["mouse"]), "Mouse %d" % int(b["mouse"]))
var parts: Array[String] = []
if bool(b.get("ctrl", false)):
parts.append("Ctrl")
if bool(b.get("shift", false)):
parts.append("Shift")
if bool(b.get("alt", false)):
parts.append("Alt")
parts.append(key_name(int(b.get("key", 0)), b))
return "+".join(parts)
## What the key is CALLED on this keyboard (§6): a character binding by its character; a
## positional one by the label the OS layout prints on that key (AZERTY's number row
## reads & é " ', a German [ is Ü) — so the help never names a key the player cannot find.
static func key_name(key: int, b: Dictionary = {}) -> String:
# The headless server has no layout ("Not supported by this display server" per call).
if is_positional(key, b) and DisplayServer.get_name() != "headless":
var lab := int(DisplayServer.keyboard_get_label_from_physical(key))
if lab != 0 and lab != key:
return OS.get_keycode_string(lab)
return OS.get_keycode_string(key)
# ---- conflicts --------------------------------------------------------------
## Two actions in the SAME context bound to the same key+modifiers. Cross-context
## collisions are not reported: that is what contexts are for.
func conflicts() -> Array:
var out: Array = []
for context: String in DEFAULTS:
var seen: Dictionary = {}
for action: String in (DEFAULTS[context] as Dictionary):
var sig := describe(action)
if sig == "":
continue
if seen.has(sig):
out.append("%s: %s and %s are both %s" % [context, seen[sig], action, sig])
else:
seen[sig] = action
return out
func _clash_within_context(action: String, b: Dictionary) -> String:
var context := context_of(action)
var want := _sig_of(b)
for other: String in (DEFAULTS.get(context, {}) as Dictionary):
if other == action:
continue
if _sig_of(binding_of(other)) == want:
return other
return ""
func _sig_of(b: Dictionary) -> String:
if b.has("mouse"):
return "mouse%d" % int(b["mouse"])
return "%d|%s%s%s" % [int(b.get("key", 0)),
"c" if bool(b.get("ctrl", false)) else "",
"s" if bool(b.get("shift", false)) else "",
"a" if bool(b.get("alt", false)) else ""]
# ---- persistence ------------------------------------------------------------
func _load_overrides() -> void:
if not FileAccess.file_exists(SAVE_PATH):
return
var f := FileAccess.open(SAVE_PATH, FileAccess.READ)
if f == null:
return
var parsed: Variant = JSON.parse_string(f.get_as_text())
f.close()
if parsed is Dictionary:
_overrides = parsed
func _save_overrides() -> void:
var f := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if f == null:
push_warning("[Keymap] cannot write %s — rebinds will not persist" % SAVE_PATH)
return
f.store_string(JSON.stringify(_overrides))
f.close()