Main menu (state 0)¶
This page documents the classic Crimsonland.exe main menu (game state 0)
from decompiled code (Ghidra + Binary Ninja cross-checks). The goal is a
faithful reimplementation: same layout math, timings, and render passes.
Frame pipeline (state 0)¶
In the main frame renderer, state 0 follows the "menu/world" render path:
- Terrain background render.
- Optional fullscreen fade overlay (
screen_fade_alpha). - UI element timeline update + draw (
ui_elements_update_and_render). - Perk prompt (usually inactive in menu).
- Cursor draw.
Terrain regeneration notes¶
The main menu does not regenerate terrain when you navigate between menu screens
(Options/Statistics/etc). State 0 just renders whatever terrain texture is already
active.
Terrain generation is triggered elsewhere:
- Startup:
game_startup_init_preludecallsterrain_generate_random(). -
Demo → Menu:
ui_elements_update_and_rendercallsterrain_generate_random()whendemo_mode_active != 0andgame_state_pending == 0, right beforegame_state_set(0). -
Debug:
console_hotkey_updatechecks config var0x57and callsterrain_generate_random()(orterrain_generate(desc)in quest mode), then clears the config var.
Menu terrain selection (terrain_generate_random)¶
When menu terrain is regenerated, terrain_generate_random() chooses a descriptor
based on progression (quest_unlock_index) and random checks:
- default descriptor: terrain ids
(0, 1, 0)(q1 base/overlay/base) - if
quest_unlock_index >= 0x28(40) and(crt_rand() & 7) == 3: terrain ids(6, 7, 6)(q4) - else if
quest_unlock_index >= 0x1e(30) and(crt_rand() & 7) == 3: terrain ids(4, 5, 4)(q3) - else if
quest_unlock_index >= 0x14(20) and(crt_rand() & 7) == 3: terrain ids(2, 3, 2)(q2) - else keep default
(0, 1, 0)
Important parity detail: these are sequential if/else if checks with separate
crt_rand() calls, so higher unlock levels still allow lower-tier outcomes.
Shareware/demo behavior note: the frame loop clamps quest_unlock_index to 10
(if !game_is_full_version() && quest_unlock_index > 10) after processing menu
frame input. In practice this prevents q2/q3/q4 menu terrain variants in the demo build.
Keyboard navigation (state 0)¶
Main-menu focus navigation is Tab-based (not arrow keys):
Tabcycles focus forward;Shift+Tabcycles focus backward (ui_focus_update @ 0x0043d830).Enteractivates the focused element (ui_element_render @ 0x00446c40), but only once the element is enabled (fully visible).
UI element table and ordering¶
UI elements live in a fixed pointer table:
- Range:
0x0048f168 .. 0x0048f20b(0xA4bytes) - Count: 41 pointers
ui_elements_update_and_render iterates backwards:
- starts at
ui_element_table_start - decrements down to
ui_element_table_end
So earlier pointers in the table draw last (on top).
Assets and template rects (ui_menu_assets_init @ 0x00419dd0)¶
Menu UI templates are loaded once and then copied into per-screen elements.
ui_signCrimson.jaz (logo sign)¶
ui_element_set_rect(width, height, offsetX, offsetY):
width = 573.44height = 143.36offset = (-577.44, -62.0)
The quad lives mostly in negative X, so placing the element at
pos_x = screen_width + 4 anchors it to the right.
Animation note:
-
The logo sign is UI element table index
0(sub_446150returns 0), soui_element_updateforces its rotation angle negative (clockwise) during timeline transitions. -
It uses the same default
start_time_ms=300/end_time_ms=0window as other UI elements, but it is locked to a steady 0° pose during normal menu navigation. -
When
ui_elements_timelineovershootsui_elements_max_timeline()and is clamped,ui_elements_update_and_renderwrites1toui_sign_crimson_update_disabled(absolute0x00487292). When this byte is1,ui_element_updateearly-returns and the sign does not pivot during transitions to Play Game / Options / etc. -
The quit callback (
ui_menu_main_click_quit @ 0x00447450) clears0x00487292back to0, allowing the sign to pivot out during the last300msof the close. -
When
fx_detailis enabled (config_blob.reserved0[0xe] != 0),ui_element_renderalso draws a shadow pass with+7,+7offset and tint0x44444444using the same transform (rotation matrix).
Runtime verification (Frida):
- Script:
scripts/frida/menu_logo_pivot_trace.js - Output:
C:\share\frida\menu_logo_pivot_trace.jsonl(raw logs are gitignored; seeanalysis/frida/menu_logo_pivot_trace_summary.jsonfor this run) - Logs
logo_update/logo_renderwhileui_elements_timeline <= 350(or when the logo angle is non-zero). - Observed:
- Play / Options:
logo_update.update_disabled == 1andangle_deg == 0for the whole close (no pivot). - Quit:
logo_update.update_disabled == 0andangle_degramps from0to-90astimelinegoes299 -> 0.
ui_menuItem.jaz (menu item button)¶
width = 512.0height = 64.0offset = (-72.0, -60.0)- Template global:
ui_menu_item_element(0x0048fba8) with mode/texture fieldsui_menu_item_element_mode(0x0048fc8c) andui_menu_item_element_texture_handle(0x0048fc88).
The pivot is intentionally offset so the element can rotate in from the left.
ui_menuPanel.jaz (panel)¶
This is not used in state 0 (but used by other menus/screens):
width = 512.0height = 256.0offset = (20.0, -82.0)- Template global:
ui_menu_panel_template(0x0048fc90) with mode/texture fieldsui_menu_panel_template_mode(0x0048fd74) andui_menu_panel_template_texture_handle(0x0048fd70).
Panel screen animation (Play Game / Options)¶
Panel-based screens (e.g. Play Game, Options) use the panel template plus a single
BACK menu item, but they do not rotate in like the main-menu items.
In ui_menu_layout_init these elements have render_mode = 1 (element+0x4 == 1, offset mode), so
ui_element_render draws them using pos + offset_xy instead of the rotation matrix.
ui_element_update animates the X offset from +/-abs(width) to 0 over the default
[end_time_ms .. start_time_ms] = [0 .. 300] ms window.
Known positions (before widescreen shift):
- Play Game (state
1, screen updateplay_game_menu_update): - panel:
(-45, 210) - back:
(-55, 462) - Options (state
2, screen updateoptions_menu_update): - panel:
(-45, 210) - back:
(-55, 430)
Label overlay rect (inside ui_menuItem)¶
Each menu item has an overlay quad (later given UVs into ui_itemTexts):
width = 124.0height = 30.0offset = (270.0, -38.0)
Main menu composition (state 0)¶
game_state_set(0) activates:
- the logo sign
- menu item buttons (some conditional)
- no panel
The relevant table indices are:
| Table idx | Element | Role |
|---|---|---|
| 0 | DAT_00487290 |
Logo sign (ui_signCrimson) |
| 1 | DAT_004875a8 |
Unused/mystery (participates in layout adjustments) |
| 2 | DAT_00488208 |
Top item: BUY NOW (demo) or MODS (full). Rewrite note: BUY NOW is out of scope. |
| 3 | DAT_004878c0 |
PLAY GAME |
| 4 | DAT_00487bd8 |
OPTIONS |
| 5 | DAT_00487ef0 |
STATISTICS |
| 6 | DAT_00488520 / DAT_00488838 |
OTHER GAMES or QUIT depending on config var 100 |
| 7 | DAT_00488838 / DAT_00488520 |
QUIT or inactive placeholder |
Notes:
mods_any_available()gates theMODSbutton in full version builds.- A string config entry
grim_get_config_var(100)controls whether theOTHER GAMESslot is present, and swaps table indices6and7. main_menu_full_version_layout_latch(0x00486faa) prevents reapplying the full-version position/UV adjustments after they have run once.
Base positions and timings (ui_menu_layout_init @ 0x0044fcb0)¶
Logo position¶
pos_x = screen_width + 4pos_y = 70(or60whenscreen_width < 641)
Menu item positions (before adjustments)¶
All menu items start at pos_x = -60 and:
- slot 0:
y = 210 - slot 1:
y = 270 - slot 2:
y = 330 - slot 3:
y = 390 - slot 4:
y = 450 - slot 5:
y = 510(only whenOTHER GAMESis present; otherwiseQUITis at 450)
Stagger timing + diagonal X shift (table idx 1..7)¶
All elements default to:
start_time_ms = 300end_time_ms = 0
Then the layout loop adds +100, +200, ... +700 ms to both start_time_ms and
end_time_ms (for table indices 1..7). This keeps the interval length at
300ms while staggering each item by 100ms.
In the same loop, it applies a diagonal X offset to later entries:
| Table idx | Slot | Base X | Extra shift | Final X |
|---|---|---|---|---|
| 2 | 0 | -60 | 0 | -60 |
| 3 | 1 | -60 | -20 | -80 |
| 4 | 2 | -60 | -40 | -100 |
| 5 | 3 | -60 | -60 | -120 |
| 6 | 4 | -60 | -80 | -140 |
| 7 | 5 | -60 | -100 | -160 |
Resolution-dependent adjustments¶
Widescreen vertical shift (applied after layout)¶
After layout, all UI elements except the logo (table idx 0) receive:
pos_y += (screen_width / 640.0) * 150.0 - 150.0
Examples:
640→+0800→+37.51024→+901280→+150
Logo scaling (small and 800–1024 widths)¶
The logo quad is scaled in-place:
-
when
screen_width < 641: multiply vertex coords by0.8and add+10to several X coordinates. -
when
801 <= screen_width <= 1024: multiply by1.2and also add+10to X.
Small-width menu pack (screen_width < 641)¶
For table indices 1..7 the code:
- scales the main + overlay quads by
0.9 - applies a per-element local Y shift to compress the vertical spacing
The per-element shift is f = [-11, 0, 11, 22, 33, 44, 55] (for indices 1..7)
and each quad's local Y is adjusted by y -= f.
For menu slots (table idx 2..7) this corresponds to local Y shifts:
- slot 0:
0 - slot 1:
11 - slot 2:
22 - slot 3:
33 - slot 4:
44 - slot 5:
55
Label atlas (ui_itemTexts.jaz)¶
The menu labels are an 8-row atlas (row height = 1/8 = 0.125 in UV space):
| Row | Label |
|---|---|
| 0 | BUY NOW (out of scope for rewrite) |
| 1 | PLAY GAME |
| 2 | OPTIONS |
| 3 | STATISTICS |
| 4 | MODS |
| 5 | OTHER GAMES |
| 6 | QUIT |
| 7 | BACK |
When entering state 0, the game assigns overlay UVs for table indices 2..7.
Important behavior:
-
In full version, the top slot (idx
2) forces row4(MODS), then the row sequence resets back to0for the next items. -
The normal row sequence skips
4(so3 -> 5). - If config var
100is empty, table idx6is forced to row6(QUIT). The remaining row7is assigned to an element that is inactive in state0and therefore not visible.
Animation (ui_element_update @ 0x00446900)¶
Menu items use render_mode == 0 (element+0x4 == 0, "transform") and animate via rotation:
Note: element+0x2 is an update-disable flag; when non-zero ui_element_update returns immediately
(used to lock the logo sign during menu navigation).
- Fully hidden:
angle = ±pi/2 - Fully visible:
angle = 0 - During transition: linearly lerp angle from
pi/2to0over[end_time_ms, start_time_ms]
Rotation matrix:
slide_x is computed for all elements, but is only used when
render_mode == 1 (element+0x4 == 1, "offset"). For main menu items (render_mode == 0) it is
ignored by the render path.
Hit testing bounds (FUN_0044fb50 @ 0x0044fb50)¶
Bounds are derived from quad0 v0/v2 and pos_x/pos_y:
w = v2.x - v0.xh = v2.y - v0.y
Then:
left = pos_x + v0.x + w*0.54top = pos_y + v0.y + h*0.28right = pos_x + v2.x - w*0.05bottom = pos_y + v2.y - h*0.10
Per-element render passes (ui_element_render @ 0x00446c40)¶
The element renderer draws, in order:
- Optional shadow pass (when
fx_detail_0 != 0): - draw at
(pos_x + 7, pos_y + 7)with tint0x44444444 - Main quad(s)
- Overlay label quad
- "Glow" overlay re-draw in additive blend (clickable + enabled elements):
- always draws the overlay a second time with a different render state / blend mode
- if
counter_timeris in0..0xFF, it overrides the glow alpha:alpha_glow = 0xFF - counter_timer/2
Note: counter_timer is initialized to 0x100 in ui_element_init_defaults and (as far as we
can tell) only increments in ui_element_update, so this short alpha override
may never trigger for main-menu items unless something else resets the timer.
Overlay alpha for clickable elements:
alpha = 100 + floor(hover_amount * 155 / 1000)
Hover amount is updated per frame:
- hovered:
+= dt_ms * 6 - not hovered:
-= dt_ms * 2 - clamp to
[0, 1000]