Start Menu
The key words MUST, MUST NOT, REQUIRED, SHOULD, MAY, and OPTIONAL below are used as in RFC 2119.
This is the runtime behavior reference. For the player-facing view with a live visual preview, see the Start Menu journey page.
The start menu is a PSO-style, non-pausing overlay with an L-shaped backdrop: a left strip plus a bottom strip, leaving the upper-right of the screen clear so the player can still see the world. It is a single autoload CanvasLayer (PsoStartMenu) driven by one Mode enum; exactly one Mode is active at a time, held in _mode. Input routing and drawing both dispatch on that mode. This page is the behavior reference the planned three-way split (see Implemented by) MUST preserve.
Opening and closing
The menu is toggled with Start / ESC via toggle(), which calls open() when closed and close() when open. open() resets _mode to MAIN and clears every per-mode cursor (_menu_idx, _sub_idx, the equip / palette / mag indices, scroll, and the one-shot action message) so reopening always lands on a clean main menu rather than resuming in a sub-mode the player had backed out of. is_open() reports the current state.
Because it is an autoload, the menu MUST be available globally — it works in both the field and the city without each scene instantiating it. It is registered with process_mode = PROCESS_MODE_ALWAYS and it MUST NOT pause the game: opening it pushes a modal flag (GameState.push_modal()) but does not set the tree paused. As a direct consequence, the player can still walk around with the analog stick while the menu is open — _unhandled_input explicitly lets the four movement actions pass through unconsumed, while the d-pad / right stick navigate the menu. Every other gameplay input (camera, interact, quick-weapon, palette) is consumed while the menu is open.
Opening is gated: from the closed state the menu only opens on pause when there is no other overlay on the SceneManager overlay stack and the current scene is a gameplay scene. While open, the joypad Start button always closes the menu regardless of mode; ESC / pause closes only from MAIN and otherwise acts as Back into the active mode handler.
Confirm / interact precedence
A single confirm/action press can be bound to more than one consumer (a world interaction such as a teleporter / telepipe / NPC / pickup, a palette / free action, and the menu's own Accept can share a physical button under some control schemes). The field input path MUST resolve each press through exactly one consumer, in this fixed priority order (highest first):
| # | Condition | Outcome |
|---|---|---|
| 1 | An active modal / the Start Menu is open (GameState.is_gameplay_blocked() is true) | The confirm press MUST be consumed by the modal. World interaction and palette/free actions MUST NOT fire. Movement MUST stay enabled (it runs from the ungated _physics_process, not the input path). |
| 2 | No modal, and a world-interaction prompt (teleporter / telepipe / NPC / pickup) is in range when interact is pressed | The world interaction MUST take precedence over and CONSUME the press ahead of any palette/free action bound to the same button — the palette action MUST NOT also fire on that press (cross-ref issue #423). |
| 3 | Neither 1 nor 2 applies | A palette / free action (and only then) fires. Inside the city the combat palette stays suppressed regardless. |
Closing the menu MUST restore world interaction on the next frame with no buffered confirm leaking through (the press that closed the menu MUST NOT also trigger a world interaction or palette action). Movement is NOT suppressed while the menu is open — the analog stick keeps the player walking (tester Sazonde99); only confirm/interact/palette are gated. This row table makes the world > palette ordering explicit and is consistent with the invariant above ("movement input MUST pass through while open, all other gameplay input MUST be consumed").
Modes (views)
MAIN
The menu spine. Up / Down move the cursor over the dynamically built label list; Left / Right flip the four info pages (also bound to LB / RB); Accept enters the highlighted sub-view via _enter_sub; Back / Cancel closes the menu. The label list comes from _get_menu_labels() — Items, Equip, optionally Techs, then Palette, Mags, Quest, System — so its size is the wrap bound for the cursor.
ITEMS (and ITEMS_MOVE)
Lists the inventory in storage order. Accept on a row opens an item modal offering Equip / Unequip (when the item is equippable), Use, Drop (disabled for equipped gear), and Sort; choices are greyed PSO-style when unavailable. Use calls Inventory.use_item and writes a one-shot result line (restored HP / PP, learned a disk, telepipe placed, etc.). Sort offers Auto (rebuild in canonical order) or Manual, and Manual enters ITEMS_MOVE, a relocate sub-mode where Up / Down pick the destination row and Accept drops the held item there (Back cancels the move). The move origin is tracked by item id so the cursor survives a list that reorders under it.
The items list MUST respect its panel bounds: it scrolls by whole rows and clips to the inner panel, so a row is never painted past the panel border (the list must not "pop out" the bottom of the UI). Only fully-visible rows render; the visible count follows from the panel height and row metrics, with ▲ / ▼ more cues shown while content extends above / below the window. Row padding matches the 2D shop lists (the same name-font size and row height) so the inventory does not read as cramped relative to the shops.
A disabled inventory row MUST read as disabled and offer no Use — the visual marker and the action agree (the grey-tier marker == action rule; see Equip Legality). A row is disabled when the item is permanently class-unusable (ShopNav.sell_cannot_use — gear the class can't equip, or a disk the class can never learn → the ✕ marker) or temporarily useless (ShopNav.sell_disabled — a technique disk already known at this level or higher, or below the required player level → greyed, no ✕). In both cases the row's usable flag is false, so the item modal MUST NOT offer Use (only Drop / Sort remain): buying five Lv.1 Foie disks and learning one leaves the other four greyed and un-usable. The disabled style MUST be clearly distinct from a normal row — a faded text colour and a dimmed icon, not a near-normal shade.
EQUIP (and EQUIP_PICK)
Lists the character's equipment slots — a Weapon slot, the armor Frame slot, and one Unit slot per the equipped frame instance's own unit-slot count (a per-instance rolled value, not the resource's max_slots — see Inventory; so the unit slots are built dynamically, and an unarmored character shows none). Accept on a slot enters EQUIP_PICK, which lists the inventory items that fit that slot (_get_equip_candidates); Accept there equips the chosen item via _do_equip and returns to EQUIP, Back returns without changing the loadout. Equipping a weapon or mag refreshes the player's held model; changing the armor frame to a different item unequips every currently-equipped unit (not only those beyond the new frame's capacity) — see the equipping-gear contract under Inventory.
TECHS
Lists the character's techniques; Accept casts a learned technique when the player is in the field (routes to the player node's _cast_technique). This view MAY be absent: see Techs gating below.
PALETTE (and PALETTE_PICK)
Edits the action palette. PALETTE shows the three slots of the current page; Up / Down move between the three slots, Left / Right flip palette pages, Accept enters PALETTE_PICK for the highlighted slot (seeding the picker cursor to the slot's current action). PALETTE_PICK is a two-column grid of assignable actions (combat / recovery on the left, techniques on the right) navigated with all four directions, with edge wrapping that crosses columns; Accept assigns the action to the slot (when available) and returns to PALETTE, Back returns without assigning.
MAGS (and MAG_FEED)
Lists the character's mags. Accept on a mag enters MAG_FEED, which lists feedable inventory items; Accept feeds the selected item to the mag (_do_feed_mag → MagManager.feed_mag, consuming one item on success), Back returns to MAGS.
QUEST
Read-only view of the current quest's info / objectives. It has no sub-navigation — its only input is Back to MAIN (_input_back).
SYSTEM (and OPTIONS, DEBUG)
A fixed four-item list (SYSTEM_LABELS): Save (writes the save and plays the saved SFX), Return to Title (saves, closes the menu, and navigates to the title scene — leaving the open state entirely; the in-memory session teardown, including the city-hub position cache, is delegated to the title scene's SessionManager.reset_all_state() — see Session Model), Options (enters OPTIONS), and Debug (enters DEBUG). Back returns to MAIN. Player-facing settings and developer tooling are separated: everyday settings live under OPTIONS, and all debug toggles and cheats live under DEBUG. Each submenu's Back MUST return to SYSTEM with the cursor restored to the row that opened it (Options for OPTIONS, Debug for DEBUG).
OPTIONS
An adjustable settings list (_get_options_list) of player-facing settings only: Music Volume and SFX Volume are stepped with Left / Right; the remaining rows are toggles or actions fired with Accept — Controls: Reconfigure (closes the menu, clears the input config, and pushes the input-select scene as an overlay), On-Screen Controls, camera-rotation / camera-Y options, and Auto-Sort Inventory. OPTIONS MUST NOT contain debug toggles or cheats; those belong to DEBUG. Back returns to SYSTEM with the cursor restored to the Options row.
DEBUG
A developer-tools list (_get_debug_list) reached from System → Debug, dispatched by _toggle_debug. Row 0 is Unlock All Missions, a one-shot action (not a toggle); the remaining rows are the DebugConfig boolean flags (floor collision, gate dots, hitboxes, combo timing, time/room, frame profiler, player position, equip-all, reveal-map), fired ON/OFF with Accept. Up / Down navigate; Accept fires the highlighted row; Back returns to SYSTEM with the cursor on the Debug row.
Unlock All Missions (GameState.unlock_all_missions()) MUST mark every real quest complete — appending each quest id from QuestLoader.list_quests() to GameState.completed_missions, skipping the manifest sentinel and hello_quest (which are not missions) — so the guild counter, whose availability gates read is_mission_completed for parent_quest / required_quests, surfaces the whole roster as available. It MUST be idempotent (re-running adds nothing) and MUST return the count of newly cleared missions. It affects mission availability only; it MUST NOT unlock difficulty tiers (that axis is governed by Difficulty Unlock). Beta/disabled quests stay hard-locked by the guild's own disabled flag regardless. The menu action MUST persist the result via SaveManager.save_game() and report the count in the description line.
Techs gating
The Techs view MAY be absent. _can_use_techs() looks up the active character's class and returns false when its race is CAST; _get_menu_labels() (and the parallel _get_menu_descs()) omit the Techs entry in that case. Therefore, for a CAST character the main menu MUST NOT offer Techs, and for any non-CAST character it MUST. Because the label list is the cursor's wrap bound, the same gate that hides the label also keeps the cursor from ever landing on a missing row.
Persistence across an area transition
Because the menu is an autoload CanvasLayer, it survives a full
area transition (SceneManager.goto_scene → change_scene_to_file):
the node is not freed and rebuilt with the scene. Its canvas, however, only
repaints on input or open (queue_redraw), so a transition that fires
a redraw mid-rebuild can repaint at a moment a freshly load()ed
visual is transiently unavailable.
Therefore every menu visual that is drawn while a view is open MUST
be cached on the persistent autoload rather than re-loaded inside the draw call,
so a redraw can never silently skip it. The action-palette icons already follow
this (_get_action_icon → _icon_cache). The Palette
HUD-preview background image (palette_bg.png /
palette_bg_r.png) MUST likewise be cached
(_get_palette_bg → _pal_bg_cache); the renderer
MUST NOT load() it inline per draw. Nulls
MUST NOT be cached, preserving recovery after a late pack mount.
Additionally, the menu MUST re-issue a redraw on the area-load
signal (SceneManager.scene_changed) when it is open, so a Palette page
that was open across the warp repaints in place from the cache. The player
MUST NOT have to exit and re-enter the page to recover the
background. The redraw handler MUST be guarded on the open state
so overlay pushes (which also emit scene_changed) do not trigger
spurious repaints on a closed menu.
Data contract
A set of pure data helpers feeds both rendering (the _draw_* family) and input (the _input_* router), so the two halves never disagree about what a view contains:
_get_menu_labels()— the main-menu label list (Techs included iff_can_use_techs())._category_to_type(category)— maps a display category to an item type (e.g.Weapon→weapon,Disk→tech, with atoolfallback)._get_item_category(item_id)— the display category for an item id._item_fits_slot(item_id, slot_key)— whether an item is equippable into a given slot._can_use_techs()— the CAST gate above.
These helpers are locked by test_start_menu_data() in scripts/tools/test_runner.gd, which asserts the category mappings, the fits-slot results, and the invariant that Techs is present in the labels exactly when _can_use_techs() is true. They are the regression net for the split below, and the split MUST keep them behaving identically.
Invariants
- Exactly one
Modeis active at a time; every sub-view returns toMAINon Back, and closing fromMAINcloses the overlay. - The menu MUST NOT pause the game (
PROCESS_MODE_ALWAYS, modal flag only); movement input MUST pass through while open, all other gameplay input MUST be consumed. - One confirm/action press MUST resolve to exactly one consumer in the order modal > world-interaction > palette/free (see Confirm / interact precedence); a world interaction MUST consume the press and suppress a palette action bound to the same button, and closing the menu MUST NOT leak a buffered confirm into a world interaction on the next frame.
- The menu MUST be globally available (autoload) so it opens in both field and city.
open()MUST reset toMAINand clear all per-mode cursors.- The
Techsview MUST be hidden for CAST characters and present otherwise. - Rendering and input MUST derive their view contents from the same data helpers.
- Every menu visual drawn while open MUST be cached on the persistent autoload (never
load()ed inline per draw), and the menu MUST repaint onscene_changedwhen open — so an open Palette page survives an area transition without an exit/re-enter to recover its background (#421). - The confirm/interact precedence MUST be invariant to whether the menu was opened in the current area or carried across an area transition (the autoload survives the
change_scene_to_filetree rebuild with_is_openand theGameStatemodal block intact). After any area load, an open menu MUST still consume the confirm/interact press so a newly-spawned world interactable (teleporter / NPC / pickup) under the player does not also fire (#426 edge case, Rozalin v0.38.18: menu opened in area A + transition to area B double-fired both the menu and the teleporter). Guarded at unit scope bytest_menu_carry_survives_scene_signal()and at runtime by thePSZ_AUTOPILOT_MENU_CARRY=1autopilot probe (open in office → carry across the office→counter transition → interact at the guild counter NPC must be consumed).
Implemented by
- scripts/3d/field/pso_start_menu.gd (
PsoStartMenu) — the entire menu today: theModeenum,open()/close()/toggle()/is_open(), the_unhandled_inputrouter and every_input_*handler, the_draw_*rendering family, the action helpers (equip / drop / feed / use), and the data helpers above.
This 2,279-line autoload is slated to split by concern; this page is the contract that split MUST preserve:
- StartMenuRenderer — the
_draw_*family (backdrop, status bars, per-view panels, scanlines, list / desc helpers). - StartMenuInput — the
_unhandled_inputrouter plus the per-mode_input_*handlers and the nav-repeat / right-stick scroll logic. - StartMenuActions — the equip / drop / feed / use operations (
_do_equip,_do_move,_do_feed_mag,_execute_use_item) plus the data getters (_get_menu_labels,_category_to_type,_get_item_category,_item_fits_slot,_can_use_techs).
The Mode enum, the transition rules in the diagram, and the data contract are the seams the split crosses; whichever unit ends up owning each piece, the contract above MUST hold.