use std::collections::HashSet;
use super::expand::{asset_name, type_norm};
use super::ui_spec::{centered_label, font_sizes, label_value, sprite};
use concinnity_asset::cook::{MainMenu, SettingsProfile};
use concinnity_core::gfx::overlay::UI_REFERENCE_SIZE;
use concinnity_world::spec::{asset, spec_to_value};
const AVG_ADVANCE_RATIO: f32 = 0.5;
const SETTINGS_TABS: [(&str, &str); 3] = [
("video", "Video"),
("audio", "Audio"),
("controls", "Controls"),
];
fn settings_tabs(profile: SettingsProfile) -> &'static [(&'static str, &'static str)] {
match profile {
SettingsProfile::Full => &SETTINGS_TABS,
SettingsProfile::Minimal => &SETTINGS_TABS[..2],
}
}
const VIDEO_ROWS: [(&str, &str); 7] = [
("vsync", "Vsync"),
("fps_cap", "Frame Rate"),
("window_mode", "Window Mode"),
("resolution", "Resolution"),
("perf_stats", "Display Performance Stats"),
("show_fps", "Show Framerate"),
("show_vram", "Show VRAM Usage"),
];
const VIDEO_MINIMAL_ROWS: [(&str, &str); 4] = [
("window_mode", "Window Mode"),
("resolution", "Resolution"),
("vsync", "Vsync"),
("fps_cap", "Frame Rate"),
];
const VIDEO_ADVANCED_ROWS: [(&str, &str); 8] = [
("render_scale", "Render Scale"),
("upscale_backend", "Upscaler"),
("temporal_upscaling", "Temporal Upscaling"),
("hdr_display", "HDR Display"),
("hdr_pq", "HDR10 (PQ)"),
("frames_in_flight", "Frame Buffering"),
("occlusion_two_pass", "Occlusion Culling"),
("texture_quality", "Texture Quality"),
];
const VIDEO_ADVANCED_SLIDERS: [(&str, &str); 8] = [
("exposure", "Exposure"),
("bloom_intensity", "Bloom"),
("bloom_threshold", "Bloom Threshold"),
("bloom_knee", "Bloom Knee"),
("vignette", "Vignette"),
("lut_strength", "Color Grade"),
("ambient_intensity", "Ambient"),
("fov", "Field of View"),
];
const VIDEO_QUALITY_ROWS: [(&str, &str); 15] = [
("aa_mode", "Anti-Aliasing"),
("ssao", "Ambient Occlusion"),
("ssr", "Screen-Space Reflections"),
("ray_traced_reflections", "Ray-Traced Reflections"),
("reflection_blur_resolution", "Reflection Blur"),
("ssgi", "Global Illumination"),
("ssgi_resolution", "GI Resolution"),
("ssgi_rays", "GI Rays"),
("ssgi_steps", "GI Steps"),
("shadow_map_size", "Shadow Resolution"),
("shadow_update", "Shadow Update"),
("shadow_distance", "Shadow Distance"),
("shadow_cascades", "Shadow Cascades"),
("auto_exposure", "Auto Exposure"),
("anisotropy", "Anisotropic Filtering"),
];
const VIDEO_QUALITY_SLIDERS: [(&str, &str); 9] = [
("ssao_radius", "AO Radius"),
("ssao_intensity", "AO Intensity"),
("ssr_intensity", "Reflection Intensity"),
("ssr_max_distance", "Reflection Distance"),
("ssgi_intensity", "GI Intensity"),
("ssgi_max_distance", "GI Distance"),
("auto_exposure_min_ev", "Auto Exposure Min"),
("auto_exposure_max_ev", "Auto Exposure Max"),
("auto_exposure_speed", "Auto Exposure Speed"),
];
const AUDIO_ROWS: [(&str, &str); 4] = [
("master_volume", "Master Volume"),
("music_volume", "Music Volume"),
("sfx_volume", "SFX Volume"),
("voice_volume", "Voice Volume"),
];
const CONTROLS_SLIDERS: [(&str, &str); 1] = [("mouse_sensitivity", "Sensitivity")];
const CONTROLS_REBINDS: [(&str, &str); 7] = [
("Move Forward", "key_forward"),
("Move Back", "key_backward"),
("Move Left", "key_left"),
("Move Right", "key_right"),
("Sprint", "key_sprint"),
("Jump", "key_jump"),
("Interact", "key_interact"),
];
const CONTROLS_KEYS: [(&str, &str); 1] = [("Pause", "Esc")];
const CONTROLS_PAD_SLIDERS: [(&str, &str); 2] = [
("gamepad_look_sensitivity", "Stick Sensitivity"),
("gamepad_deadzone", "Stick Deadzone"),
];
const CONTROLS_PAD_REBINDS: [(&str, &str); 3] = [
("Sprint", "pad_sprint"),
("Jump", "pad_jump"),
("Interact", "pad_interact"),
];
const SETTINGS_ROW_WIDTH_MULT: f32 = 1.85;
const SETTINGS_ROW_SCALE: f32 = 0.6;
const SETTINGS_SIDE_MARGIN: f32 = 90.0;
const SETTINGS_CONTROL_FRAC: f32 = 0.42;
const SETTINGS_CONTROL_WIDTH: f32 = 360.0;
const SETTINGS_ROW_BG: [f32; 4] = [0.10, 0.13, 0.24, 0.55];
const SETTINGS_HEADER_BG: [f32; 4] = [0.16, 0.20, 0.34, 0.70];
const SETTINGS_ROW_PAD: f32 = 18.0;
const TOP_MARGIN_FRAC: f32 = 0.07;
const VISIBLE_SETTINGS_ROWS: usize = 6;
const SCROLLBAR_GAP: f32 = 12.0;
const SCROLLBAR_WIDTH: f32 = 8.0;
const BACK_GAP: f32 = 24.0;
pub(crate) fn expand_main_menus(assets: &mut Vec<serde_json::Value>) -> Result<(), String> {
if !assets.iter().any(|v| type_norm(v) == "mainmenu") {
return Ok(());
}
let (win_w, win_h) = (UI_REFERENCE_SIZE[0], UI_REFERENCE_SIZE[1]);
let font_px_by_name = font_sizes(assets);
let mut taken: HashSet<String> = assets
.iter()
.filter(|v| type_norm(v) != "mainmenu")
.map(asset_name)
.filter(|n| !n.is_empty())
.collect();
let mut result: Vec<serde_json::Value> = Vec::new();
for value in assets.drain(..) {
if type_norm(&value) != "mainmenu" {
result.push(value);
continue;
}
let menu_name = asset_name(&value);
if menu_name.is_empty() {
return Err("MainMenu: missing `name`".to_string());
}
let args = value
.get("args")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
let menu: MainMenu = serde_json::from_value(args)
.map_err(|e| format!("MainMenu '{}': invalid args: {}", menu_name, e))?;
let font_px = if menu.font.is_empty() {
menu.font_px
} else {
*font_px_by_name.get(&menu.font).unwrap_or(&menu.font_px)
};
for entry in expand_one(&menu_name, &menu, win_w, win_h, font_px) {
let name = asset_name(&entry);
if !name.is_empty() && !taken.insert(name.clone()) {
return Err(format!(
"MainMenu '{}': generated asset name '{}' collides with an existing \
asset; rename the menu or the conflicting asset",
menu_name, name
));
}
result.push(entry);
}
}
*assets = result;
Ok(())
}
fn expand_one(
menu_name: &str,
menu: &MainMenu,
win_w: f32,
win_h: f32,
font_px: f32,
) -> Vec<serde_json::Value> {
let mut out = Vec::new();
let mut wants_settings = false;
let font_name = if menu.font.is_empty() {
let name = format!("{}_font", menu_name);
out.push(spec_to_value(&asset::font(&name, font_px as u32)));
name
} else {
menu.font.clone()
};
let items: Vec<(String, String)> = menu
.items
.iter()
.map(|item| {
let action = match item.action.trim().to_lowercase().as_str() {
"return" | "close" => "screen:hide".to_string(),
"settings" => {
wants_settings = true;
format!("screen:show:{}_settings_video", menu_name)
}
_ => item.action.clone(),
};
(item.label.clone(), action)
})
.collect();
out.extend(emit_menu_screen(
menu_name,
&menu.title,
&items,
menu,
&font_name,
MenuMetrics {
win_w,
win_h,
font_px,
},
menu.initial,
));
if !menu.toggle_key.is_empty() {
out.push(serde_json::json!({
"name": format!("{}_toggle", menu_name),
"type": "KeyBinding",
"args": { "key": menu.toggle_key, "action": format!("screen:toggle:{}", menu_name) }
}));
}
if wants_settings || !menu.settings_back_action.is_empty() {
for (suffix, _) in settings_tabs(menu.settings_profile) {
out.extend(emit_settings_tab(
menu_name, suffix, menu, &font_name, win_w, win_h, font_px,
));
}
}
out
}
fn emit_settings_tab(
menu_name: &str,
active: &str,
style: &MainMenu,
font: &str,
win_w: f32,
win_h: f32,
font_px: f32,
) -> Vec<serde_json::Value> {
let screen = format!("{}_settings_{}", menu_name, active);
let mut out = Vec::new();
out.push(spec_to_value(&asset::screen(&screen, false)));
if style.dim[3] > 0.0 {
out.push(sprite(
&format!("{}_dim", screen),
0.0,
0.0,
win_w,
win_h,
style.dim,
));
}
let pitch = style.button_height + style.row_gap;
let center_x = if style.centered { win_w / 2.0 } else { style.x };
let start_y = if style.centered {
win_h * TOP_MARGIN_FRAC
} else {
style.y
};
let row_y = |i: usize| start_y + i as f32 * pitch;
let text_y = |i: usize, scale: f32| row_y(i) + (style.button_height - font_px * scale) / 2.0;
let row_scale = style.text_scale * SETTINGS_ROW_SCALE;
let (row_x, row_width) = if style.centered {
let w = win_w - 2.0 * SETTINGS_SIDE_MARGIN - SCROLLBAR_GAP - SCROLLBAR_WIDTH;
(SETTINGS_SIDE_MARGIN, w)
} else {
let w = (style.button_width * SETTINGS_ROW_WIDTH_MULT).min(win_w - 80.0);
(center_x - w / 2.0, w)
};
let content_x = row_x + SETTINGS_ROW_PAD;
let content_w = (row_width - 2.0 * SETTINGS_ROW_PAD).max(0.0);
let control_x = (content_x + content_w * SETTINGS_CONTROL_FRAC)
.max(content_x + content_w - SETTINGS_CONTROL_WIDTH);
let tabs = settings_tabs(style.settings_profile);
let tab_scale = style.text_scale * 1.1;
let tab_text_y = text_y(0, tab_scale);
let tab_gap = font_px * AVG_ADVANCE_RATIO * tab_scale * 1.2;
let tab_widths: Vec<f32> = tabs
.iter()
.map(|&(_, label)| text_width(label, font_px, tab_scale))
.collect();
let tabs_total: f32 = tab_widths.iter().sum::<f32>() + tab_gap * (tabs.len() as f32 - 1.0);
let mut tab_x = center_x - tabs_total / 2.0;
for (&(suffix, label), w) in tabs.iter().zip(&tab_widths) {
let is_active = suffix == active;
let color = if is_active {
style.hover_color
} else {
style.text_color
};
let label_name = format!("{}_tab_{}", screen, suffix);
out.push(label_value(
&label_name,
label,
font,
tab_x,
tab_text_y,
color,
tab_scale,
));
if is_active {
let mark_h = (font_px * tab_scale * 0.08).max(2.0);
out.push(sprite(
&format!("{}_tabmark", screen),
tab_x,
tab_text_y + font_px * tab_scale + mark_h,
*w,
mark_h,
opaque(style.hover_color),
));
} else {
out.push(spec_to_value(
&asset::hit_region(
format!("{}_tabbtn_{}", screen, suffix),
[tab_x, row_y(0), *w, style.button_height],
format!("screen:show:{}_settings_{}", menu_name, suffix),
)
.set("label", label_name)
.set("hover_color", style.hover_color)
.set("hover_scale", tab_scale * style.hover_scale),
));
}
tab_x += w + tab_gap;
}
let band_top = row_y(1);
let band_h = VISIBLE_SETTINGS_ROWS as f32 * pitch;
let text_y_at = |y: f32, scale: f32| y + (style.button_height - font_px * scale) / 2.0;
let (body, groups) = settings_body_rows(active, style.settings_profile);
let mut scroll_rows: Vec<serde_json::Value> = Vec::new();
for (j, row) in body.iter().enumerate() {
let base_y = band_top + j as f32 * pitch;
let is_header = matches!(*row, BodyRow::GroupHeader(..));
let bg_name = format!("{}_bg_{}", screen, j);
out.push(sprite(
&bg_name,
row_x,
base_y,
row_width,
style.button_height,
if is_header {
SETTINGS_HEADER_BG
} else {
SETTINGS_ROW_BG
},
));
let (mut elements, group): (Vec<String>, i32) = match *row {
BodyRow::Option(setting, label, group) => {
let name = format!("{}_opt_{}", screen, setting);
out.push(option_select_row(&SettingsRow {
name: &name,
setting,
label,
font,
x: content_x,
y: base_y,
width: content_w,
scale: row_scale,
style,
}));
(super::option_select::element_names(&name, setting), group)
}
BodyRow::Slider(setting, label, group) => {
let name = format!("{}_sld_{}", screen, setting);
out.push(slider_row(&SettingsRow {
name: &name,
setting,
label,
font,
x: content_x,
y: base_y,
width: content_w,
scale: row_scale,
style,
}));
(super::slider::element_names(&name), group)
}
BodyRow::Key(action_label, key, idx, group) => {
let name = format!("{}_keyname_{}", screen, idx);
let val = format!("{}_keyval_{}", screen, idx);
out.push(label_value(
&name,
action_label,
font,
content_x,
text_y_at(base_y, row_scale),
style.text_color,
row_scale,
));
out.push(label_value(
&val,
key,
font,
control_x,
text_y_at(base_y, row_scale),
style.text_color,
row_scale,
));
(vec![name, val], group)
}
BodyRow::Rebind(action_label, setting, idx, group) => {
let name = format!("{}_rebind_name_{}", screen, idx);
let val = format!("{}_rebind_val_{}", screen, idx);
out.push(label_value(
&name,
action_label,
font,
content_x,
text_y_at(base_y, row_scale),
style.text_color,
row_scale,
));
out.push(label_value(
&val,
"--",
font,
control_x,
text_y_at(base_y, row_scale),
style.text_color,
row_scale,
));
let ctrl_w = (content_x + content_w - control_x).max(0.0);
out.push(spec_to_value(
&asset::hit_region(
format!("{}_rebind_btn_{}", screen, idx),
[control_x, base_y, ctrl_w, style.button_height],
format!("setting:{}:rebind", setting),
)
.set("label", val.clone())
.set("hover_color", style.hover_color)
.set("hover_scale", row_scale * style.hover_scale),
));
(vec![name, val], group)
}
BodyRow::GroupHeader(gid, title) => {
let collapsed = groups.iter().any(|g| g.gid == gid && g.collapsed);
let header = format!("{}_grphdr_{}", screen, gid);
let header_scale = row_scale * 1.05;
out.push(label_value(
&header,
&format!("{} {}", if collapsed { "+" } else { "-" }, title),
font,
content_x,
text_y_at(base_y, header_scale),
style.hover_color,
header_scale,
));
out.push(spec_to_value(
&asset::hit_region(
format!("{}_grpbtn_{}", screen, gid),
[row_x, base_y, row_width, style.button_height],
format!("group:toggle:{}", gid),
)
.set("label", header.clone())
.set("hover_color", style.hover_color)
.set("hover_scale", header_scale * style.hover_scale),
));
(vec![header], -1)
}
};
elements.insert(0, bg_name);
scroll_rows.push(serde_json::json!({
"elements": elements,
"base_y": base_y,
"height": pitch,
"group": group,
}));
}
let track_x = row_x + row_width + SCROLLBAR_GAP;
let track_tint = [
style.text_color[0],
style.text_color[1],
style.text_color[2],
0.25,
];
out.push(sprite(
&format!("{}_scrolltrack", screen),
track_x,
band_top,
SCROLLBAR_WIDTH,
band_h,
track_tint,
));
out.push(sprite(
&format!("{}_scrollthumb", screen),
track_x,
band_top,
SCROLLBAR_WIDTH,
band_h * 0.4,
opaque(style.hover_color),
));
let scroll_groups: Vec<serde_json::Value> = groups
.iter()
.map(|g| {
serde_json::json!({
"collapsed": g.collapsed,
"header": format!("{}_grphdr_{}", screen, g.gid),
"title": g.title,
})
})
.collect();
out.push(serde_json::json!({
"name": format!("{}_scroll", screen),
"type": "ScrollPanel",
"args": {
"x": row_x, "y": band_top, "width": row_width, "height": band_h,
"rows": scroll_rows,
"groups": scroll_groups,
"thumb": format!("{}_scrollthumb", screen),
"track": format!("{}_scrolltrack", screen),
"track_x": track_x, "track_y": band_top,
"track_w": SCROLLBAR_WIDTH, "track_h": band_h,
}
}));
let back_y = band_top + band_h + BACK_GAP;
let back_label = format!("{}_label_back", screen);
let back_action = if style.settings_back_action.is_empty() {
format!("screen:show:{}", menu_name)
} else {
style.settings_back_action.clone()
};
out.push(centered_label(
&back_label,
"Back",
font,
center_x,
text_y_at(back_y, style.text_scale),
style.text_color,
style.text_scale,
));
out.push(spec_to_value(
&asset::hit_region(
format!("{}_btn_back", screen),
[
center_x - style.button_width / 2.0,
back_y,
style.button_width,
style.button_height,
],
back_action,
)
.set("label", back_label)
.set("hover_color", style.hover_color)
.set("hover_scale", style.text_scale * style.hover_scale),
));
if style.cursor {
out.push(cursor_sprite(&format!("{}_cursor", screen), style));
}
out
}
fn cursor_sprite(name: &str, style: &MainMenu) -> serde_json::Value {
spec_to_value(
&asset::sprite(
name,
[0.0, 0.0, style.cursor_size, style.cursor_size],
style.cursor_color,
)
.set("follow_cursor", true),
)
}
#[derive(Clone, Copy)]
enum BodyRow {
Option(&'static str, &'static str, i32),
Slider(&'static str, &'static str, i32),
Key(&'static str, &'static str, usize, i32),
Rebind(&'static str, &'static str, usize, i32),
GroupHeader(usize, &'static str),
}
struct GroupSpec {
gid: usize,
title: &'static str,
collapsed: bool,
}
fn settings_body_rows(active: &str, profile: SettingsProfile) -> (Vec<BodyRow>, Vec<GroupSpec>) {
if profile == SettingsProfile::Minimal && active != "audio" {
let rows = VIDEO_MINIMAL_ROWS
.iter()
.map(|&(s, l)| BodyRow::Option(s, l, -1))
.collect();
return (rows, Vec::new());
}
match active {
"audio" => (
AUDIO_ROWS
.iter()
.map(|&(s, l)| BodyRow::Option(s, l, -1))
.collect(),
Vec::new(),
),
"controls" => {
let mut rows: Vec<BodyRow> = CONTROLS_SLIDERS
.iter()
.map(|&(s, l)| BodyRow::Slider(s, l, -1))
.collect();
for (i, &(label, setting)) in CONTROLS_REBINDS.iter().enumerate() {
rows.push(BodyRow::Rebind(label, setting, i, -1));
}
for (i, &(action, key)) in CONTROLS_KEYS.iter().enumerate() {
rows.push(BodyRow::Key(action, key, i, -1));
}
rows.push(BodyRow::GroupHeader(0, "Gamepad"));
for &(s, l) in &CONTROLS_PAD_SLIDERS {
rows.push(BodyRow::Slider(s, l, 0));
}
for (i, &(label, setting)) in CONTROLS_PAD_REBINDS.iter().enumerate() {
rows.push(BodyRow::Rebind(
label,
setting,
CONTROLS_REBINDS.len() + i,
0,
));
}
(
rows,
vec![GroupSpec {
gid: 0,
title: "Gamepad",
collapsed: true,
}],
)
}
_ => {
let mut rows: Vec<BodyRow> =
vec![BodyRow::Option("graphics_quality", "Graphics Quality", -1)];
rows.extend(VIDEO_ROWS.iter().map(|&(s, l)| BodyRow::Option(s, l, -1)));
rows.push(BodyRow::GroupHeader(0, "Quality"));
for &(s, l) in &VIDEO_QUALITY_ROWS {
rows.push(BodyRow::Option(s, l, 0));
}
for &(s, l) in &VIDEO_QUALITY_SLIDERS {
rows.push(BodyRow::Slider(s, l, 0));
}
rows.push(BodyRow::GroupHeader(1, "Advanced"));
for &(s, l) in &VIDEO_ADVANCED_ROWS {
rows.push(BodyRow::Option(s, l, 1));
}
for &(s, l) in &VIDEO_ADVANCED_SLIDERS {
rows.push(BodyRow::Slider(s, l, 1));
}
(
rows,
vec![
GroupSpec {
gid: 0,
title: "Quality",
collapsed: true,
},
GroupSpec {
gid: 1,
title: "Advanced",
collapsed: true,
},
],
)
}
}
}
struct SettingsRow<'a> {
name: &'a str,
setting: &'a str,
label: &'a str,
font: &'a str,
x: f32,
y: f32,
width: f32,
scale: f32,
style: &'a MainMenu,
}
fn option_select_row(row: &SettingsRow) -> serde_json::Value {
let &SettingsRow {
name,
setting,
label,
font,
x,
y,
width,
scale,
style,
} = row;
serde_json::json!({
"name": name,
"type": "OptionSelect",
"args": {
"setting": setting,
"label": label,
"x": x,
"y": y,
"width": width,
"height": style.button_height,
"font": font,
"text_color": style.text_color,
"value_color": style.text_color,
"text_scale": scale,
"hover_color": style.hover_color,
"hover_scale": scale * style.hover_scale,
}
})
}
fn slider_row(row: &SettingsRow) -> serde_json::Value {
let &SettingsRow {
name,
setting,
label,
font,
x,
y,
width,
scale,
style,
} = row;
serde_json::json!({
"name": name,
"type": "Slider",
"args": {
"setting": setting,
"label": label,
"x": x,
"y": y,
"width": width,
"height": style.button_height,
"font": font,
"text_color": style.text_color,
"value_color": style.text_color,
"text_scale": scale,
"handle_color": [
style.hover_color[0], style.hover_color[1], style.hover_color[2], 1.0
],
}
})
}
#[derive(Clone, Copy)]
struct MenuMetrics {
win_w: f32,
win_h: f32,
font_px: f32,
}
fn emit_menu_screen(
screen: &str,
title: &str,
items: &[(String, String)],
style: &MainMenu,
font: &str,
metrics: MenuMetrics,
initial: bool,
) -> Vec<serde_json::Value> {
let MenuMetrics {
win_w,
win_h,
font_px,
} = metrics;
let mut out = Vec::new();
out.push(spec_to_value(&asset::screen(screen, initial)));
if style.dim[3] > 0.0 {
out.push(sprite(
&format!("{}_dim", screen),
0.0,
0.0,
win_w,
win_h,
style.dim,
));
}
let line_h = font_px * style.text_scale;
let center_x = if style.centered { win_w / 2.0 } else { style.x };
let has_title = !title.is_empty();
let pitch = style.button_height + style.row_gap;
let start_y = if style.centered {
win_h * TOP_MARGIN_FRAC
} else {
style.y
};
let mut row = 0usize;
if has_title {
let title_scale = style.text_scale * 1.4;
out.push(centered_label(
&format!("{}_title", screen),
title,
font,
center_x,
start_y + (style.button_height - font_px * title_scale) / 2.0,
style.text_color,
title_scale,
));
row += 1;
}
for (i, (label, action)) in items.iter().enumerate() {
let row_y = start_y + row as f32 * pitch;
let label_name = format!("{}_label_{}", screen, i);
out.push(centered_label(
&label_name,
label,
font,
center_x,
row_y + (style.button_height - line_h) / 2.0,
style.text_color,
style.text_scale,
));
out.push(spec_to_value(
&asset::hit_region(
format!("{}_btn_{}", screen, i),
[
center_x - style.button_width / 2.0,
row_y,
style.button_width,
style.button_height,
],
action.clone(),
)
.set("label", label_name)
.set("hover_color", style.hover_color)
.set("hover_scale", style.text_scale * style.hover_scale),
));
row += 1;
}
if style.cursor {
out.push(cursor_sprite(&format!("{}_cursor", screen), style));
}
out
}
fn text_width(text: &str, font_px: f32, scale: f32) -> f32 {
text.chars().count() as f32 * font_px * AVG_ADVANCE_RATIO * scale
}
fn opaque(rgb: [f32; 3]) -> [f32; 4] {
[rgb[0], rgb[1], rgb[2], 1.0]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore]
fn dump_settings_tab_probe_world() {
let tab = std::env::var("CN_PROBE_TAB").unwrap_or_else(|_| "controls".to_string());
let profile = std::env::var("CN_PROBE_PROFILE").unwrap_or_else(|_| "full".to_string());
let out = std::env::var("CN_PROBE_OUT").unwrap_or_else(|_| "world.jsonl".to_string());
let menu_args = if profile == "minimal" {
serde_json::json!({
"title": "Paused",
"settings_profile": "minimal",
"items": [
{"label": "Resume", "action": "return"},
{"label": "Save", "action": "story:save"},
{"label": "Load", "action": "story:load"},
{"label": "Settings", "action": "settings"},
{"label": "Quit to Title", "action": "quit"},
],
})
} else {
serde_json::json!({"title": "Probe", "settings_profile": profile})
};
let mut assets = vec![
serde_json::json!({"name":"win","type":"Window","args":{"width":1280,"height":720}}),
serde_json::json!({"name":"gfx","type":"GraphicsConfig","args":{}}),
serde_json::json!({"name":"main_menu","type":"MainMenu","args": menu_args}),
];
expand_main_menus(&mut assets).unwrap();
let target = if tab == "menu" {
"main_menu".to_string()
} else {
format!("main_menu_settings_{tab}")
};
for v in &mut assets {
if type_norm(v) == "screen" {
v["args"]["initial"] = serde_json::json!(asset_name(v) == target);
}
}
let mut body = String::new();
for v in &assets {
body.push_str(&serde_json::to_string(v).unwrap());
body.push('\n');
}
std::fs::write(&out, body).unwrap();
}
fn names(assets: &[serde_json::Value]) -> Vec<String> {
assets.iter().map(asset_name).collect()
}
fn by_name<'a>(assets: &'a [serde_json::Value], name: &str) -> &'a serde_json::Value {
assets
.iter()
.find(|v| asset_name(v) == name)
.unwrap_or_else(|| panic!("no asset named {name}"))
}
#[test]
fn passes_through_without_menus() {
let mut assets = vec![serde_json::json!({"name":"x","type":"Window","args":{}})];
expand_main_menus(&mut assets).unwrap();
assert_eq!(assets.len(), 1);
assert_eq!(assets[0]["type"], "Window");
}
#[test]
fn bare_menu_expands_to_default_layout() {
let mut assets = vec![serde_json::json!({"name":"main_menu","type":"MainMenu"})];
expand_main_menus(&mut assets).unwrap();
assert!(!assets.iter().any(|v| type_norm(v) == "mainmenu"));
assert_eq!(by_name(&assets, "main_menu")["type"], "Screen");
assert_eq!(by_name(&assets, "main_menu")["args"]["initial"], false);
assert_eq!(by_name(&assets, "main_menu_toggle")["type"], "KeyBinding");
assert_eq!(
by_name(&assets, "main_menu_toggle")["args"]["action"],
"screen:toggle:main_menu"
);
let ns = names(&assets);
for i in 0..3 {
assert!(ns.contains(&format!("main_menu_label_{i}")));
assert!(ns.contains(&format!("main_menu_btn_{i}")));
}
assert_eq!(
by_name(&assets, "main_menu_btn_0")["args"]["action"],
"screen:hide"
);
assert_eq!(
by_name(&assets, "main_menu_btn_1")["args"]["action"],
"screen:show:main_menu_settings_video"
);
assert_eq!(
by_name(&assets, "main_menu_btn_2")["args"]["action"],
"quit"
);
assert_eq!(
by_name(&assets, "main_menu_settings_video")["type"],
"Screen"
);
assert_eq!(
by_name(&assets, "main_menu_settings_video")["args"]["initial"],
false
);
assert_eq!(
by_name(&assets, "main_menu_settings_video_btn_back")["args"]["action"],
"screen:show:main_menu"
);
assert_eq!(
by_name(&assets, "main_menu_settings_video_tab_video")["args"]["content"],
"Video"
);
let opt = by_name(&assets, "main_menu_settings_video_opt_vsync");
assert_eq!(opt["type"], "OptionSelect");
assert_eq!(opt["args"]["setting"], "vsync");
assert_eq!(
by_name(&assets, "main_menu_cursor")["args"]["follow_cursor"],
true
);
assert_eq!(by_name(&assets, "main_menu_dim")["type"], "Sprite");
}
#[test]
fn missing_name_is_an_error() {
let mut assets = vec![serde_json::json!({"type":"MainMenu","args":{}})];
let err = expand_main_menus(&mut assets).unwrap_err();
assert_eq!(err, "MainMenu: missing `name`");
}
#[test]
fn invalid_args_name_the_menu() {
let mut assets = vec![serde_json::json!({
"name":"m","type":"MainMenu","args":{"button_width":"wide"}
})];
let err = expand_main_menus(&mut assets).unwrap_err();
assert!(err.contains("MainMenu 'm'"), "{err}");
assert!(err.contains("invalid args"), "{err}");
}
#[test]
fn a_non_centered_menu_anchors_its_column_at_x_and_y() {
let mut assets = vec![serde_json::json!({
"name":"m","type":"MainMenu",
"args":{"centered":false,"x":200.0,"y":80.0,"button_width":300.0}
})];
expand_main_menus(&mut assets).unwrap();
let btn = by_name(&assets, "m_btn_0");
assert_eq!(btn["args"]["x"], 200.0 - 300.0 / 2.0);
assert_eq!(btn["args"]["y"], 80.0);
assert_eq!(by_name(&assets, "m_label_0")["args"]["x"], 200.0);
}
#[test]
fn a_non_centered_settings_tab_keeps_the_column_row_width() {
let mut assets = vec![serde_json::json!({
"name":"m","type":"MainMenu",
"args":{"centered":false,"x":300.0,"y":100.0,"button_width":300.0}
})];
expand_main_menus(&mut assets).unwrap();
let card = by_name(&assets, "m_settings_video_bg_0");
assert_eq!(card["args"]["width"], 555.0);
assert_eq!(card["args"]["x"], 300.0 - 555.0 / 2.0);
let tab_y = by_name(&assets, "m_settings_video_tabbtn_audio")["args"]["y"]
.as_f64()
.unwrap();
assert_eq!(tab_y, 100.0);
assert!(card["args"]["y"].as_f64().unwrap() > tab_y);
}
#[test]
fn video_tab_emits_a_row_per_setting() {
let mut assets = vec![serde_json::json!({"name":"m","type":"MainMenu"})];
expand_main_menus(&mut assets).unwrap();
for (setting, label) in [
("vsync", "Vsync"),
("fps_cap", "Frame Rate"),
("window_mode", "Window Mode"),
("resolution", "Resolution"),
("render_scale", "Render Scale"),
("upscale_backend", "Upscaler"),
] {
let opt = by_name(&assets, &format!("m_settings_video_opt_{setting}"));
assert_eq!(opt["type"], "OptionSelect");
assert_eq!(opt["args"]["setting"], setting);
assert_eq!(opt["args"]["label"], label);
}
}
#[test]
fn video_tab_leads_with_the_master_quality_row() {
let mut assets = vec![serde_json::json!({"name":"m","type":"MainMenu"})];
expand_main_menus(&mut assets).unwrap();
let opt = by_name(&assets, "m_settings_video_opt_graphics_quality");
assert_eq!(opt["type"], "OptionSelect");
assert_eq!(opt["args"]["setting"], "graphics_quality");
assert_eq!(opt["args"]["label"], "Graphics Quality");
let master_pos = assets
.iter()
.position(|v| asset_name(v) == "m_settings_video_opt_graphics_quality")
.expect("master row");
let vsync_pos = assets
.iter()
.position(|v| asset_name(v) == "m_settings_video_opt_vsync")
.expect("vsync row");
assert!(
master_pos < vsync_pos,
"master quality row should lead the tab"
);
}
#[test]
fn video_tab_emits_an_exposure_slider() {
let mut assets = vec![serde_json::json!({"name":"m","type":"MainMenu"})];
expand_main_menus(&mut assets).unwrap();
let sld = by_name(&assets, "m_settings_video_sld_exposure");
assert_eq!(sld["type"], "Slider");
assert_eq!(sld["args"]["setting"], "exposure");
assert_eq!(sld["args"]["label"], "Exposure");
assert_eq!(sld["args"]["font"], "m_font");
assert!(
!assets
.iter()
.any(|v| asset_name(v) == "m_settings_audio_sld_exposure")
);
}
#[test]
fn settings_emits_a_screen_per_tab() {
let mut assets = vec![serde_json::json!({"name":"m","type":"MainMenu"})];
expand_main_menus(&mut assets).unwrap();
for suffix in ["video", "audio", "controls"] {
let screen = by_name(&assets, &format!("m_settings_{suffix}"));
assert_eq!(screen["type"], "Screen", "tab screen {suffix} missing");
assert_eq!(screen["args"]["initial"], false);
assert_eq!(
by_name(&assets, &format!("m_settings_{suffix}_btn_back"))["args"]["action"],
"screen:show:m"
);
}
}
#[test]
fn audio_and_controls_tabs_carry_their_rows() {
let mut assets = vec![serde_json::json!({"name":"m","type":"MainMenu"})];
expand_main_menus(&mut assets).unwrap();
let vol = by_name(&assets, "m_settings_audio_opt_master_volume");
assert_eq!(vol["type"], "OptionSelect");
assert_eq!(vol["args"]["setting"], "master_volume");
let sens = by_name(&assets, "m_settings_controls_sld_mouse_sensitivity");
assert_eq!(sens["type"], "Slider");
assert_eq!(sens["args"]["setting"], "mouse_sensitivity");
assert_eq!(
by_name(&assets, "m_settings_controls_keyname_0")["args"]["content"],
"Pause"
);
assert_eq!(
by_name(&assets, "m_settings_controls_keyval_0")["args"]["content"],
"Esc"
);
assert!(
!assets
.iter()
.any(|v| asset_name(v) == "m_settings_controls_keyname_0_btn")
);
}
#[test]
fn controls_tab_emits_rebind_rows() {
let mut assets = vec![serde_json::json!({"name":"m","type":"MainMenu"})];
expand_main_menus(&mut assets).unwrap();
for (i, (label, setting)) in [
("Move Forward", "key_forward"),
("Move Back", "key_backward"),
("Move Left", "key_left"),
("Move Right", "key_right"),
("Sprint", "key_sprint"),
("Jump", "key_jump"),
("Interact", "key_interact"),
("Sprint", "pad_sprint"),
("Jump", "pad_jump"),
("Interact", "pad_interact"),
]
.iter()
.enumerate()
{
assert_eq!(
by_name(&assets, &format!("m_settings_controls_rebind_name_{i}"))["args"]["content"],
*label
);
assert_eq!(
by_name(&assets, &format!("m_settings_controls_rebind_val_{i}"))["args"]["content"],
"--"
);
let btn = by_name(&assets, &format!("m_settings_controls_rebind_btn_{i}"));
assert_eq!(btn["type"], "HitRegion");
assert_eq!(btn["args"]["action"], format!("setting:{setting}:rebind"));
assert_eq!(
btn["args"]["label"],
format!("m_settings_controls_rebind_val_{i}")
);
}
assert!(
!assets
.iter()
.any(|v| asset_name(v).starts_with("m_settings_controls_rebind_btn_10"))
);
assert_eq!(
by_name(&assets, "m_settings_controls_grphdr_0")["args"]["content"],
"+ Gamepad"
);
let slider = by_name(&assets, "m_settings_controls_sld_gamepad_look_sensitivity");
assert_eq!(slider["type"], "Slider");
assert_eq!(slider["args"]["setting"], "gamepad_look_sensitivity");
let slider = by_name(&assets, "m_settings_controls_sld_gamepad_deadzone");
assert_eq!(slider["args"]["setting"], "gamepad_deadzone");
}
#[test]
fn tab_bar_switches_between_tabs() {
let mut assets = vec![serde_json::json!({"name":"m","type":"MainMenu"})];
expand_main_menus(&mut assets).unwrap();
assert_eq!(
by_name(&assets, "m_settings_video_tabmark")["type"],
"Sprite"
);
assert!(
!assets
.iter()
.any(|v| asset_name(v) == "m_settings_video_tabbtn_video")
);
assert_eq!(
by_name(&assets, "m_settings_video_tabbtn_audio")["args"]["action"],
"screen:show:m_settings_audio"
);
assert_eq!(
by_name(&assets, "m_settings_video_tabbtn_controls")["args"]["action"],
"screen:show:m_settings_controls"
);
assert_eq!(
by_name(&assets, "m_settings_controls_tabbtn_video")["args"]["action"],
"screen:show:m_settings_video"
);
}
#[test]
fn labels_are_not_centered_so_layout_wins() {
let mut assets = vec![serde_json::json!({"name":"m","type":"MainMenu"})];
expand_main_menus(&mut assets).unwrap();
assert_eq!(by_name(&assets, "m_label_0")["args"]["centered"], false);
}
#[test]
fn custom_items_pass_actions_through_verbatim() {
let mut assets = vec![serde_json::json!({
"name": "title",
"type": "MainMenu",
"args": { "items": [
{"label":"New Game","action":"scene:level_1"},
{"label":"Quit","action":"quit"}
]}
})];
expand_main_menus(&mut assets).unwrap();
assert_eq!(
by_name(&assets, "title_btn_0")["args"]["action"],
"scene:level_1"
);
assert_eq!(by_name(&assets, "title_btn_1")["args"]["action"], "quit");
assert!(!assets.iter().any(|v| asset_name(v) == "title_settings"));
}
#[test]
fn toggle_key_empty_emits_no_binding() {
let mut assets = vec![serde_json::json!({
"name": "m", "type": "MainMenu", "args": { "toggle_key": "" }
})];
expand_main_menus(&mut assets).unwrap();
assert!(!assets.iter().any(|v| type_norm(v) == "keybinding"));
}
#[test]
fn cursor_disabled_emits_no_cursor_sprite() {
let mut assets = vec![serde_json::json!({
"name": "m", "type": "MainMenu", "args": { "cursor": false }
})];
expand_main_menus(&mut assets).unwrap();
assert!(!assets.iter().any(|v| asset_name(v) == "m_cursor"));
}
#[test]
fn dim_alpha_zero_emits_no_backdrop() {
let mut assets = vec![serde_json::json!({
"name": "m", "type": "MainMenu", "args": { "dim": [0.0, 0.0, 0.0, 0.0] }
})];
expand_main_menus(&mut assets).unwrap();
assert!(!assets.iter().any(|v| asset_name(v) == "m_dim"));
}
#[test]
fn default_menu_emits_opaque_backdrop() {
let mut assets = vec![serde_json::json!({
"name": "main_menu", "type": "MainMenu",
"args": { "title": "Bistro_v5_2", "initial": false }
})];
expand_main_menus(&mut assets).unwrap();
let tint = &by_name(&assets, "main_menu_dim")["args"]["tint"];
assert_eq!(
tint[3].as_f64().unwrap(),
1.0,
"default menu backdrop must be opaque (tint={tint})"
);
}
#[test]
fn generated_name_collision_is_an_error() {
let mut assets = vec![
serde_json::json!({"name":"m","type":"MainMenu","args":{"toggle_key":""}}),
serde_json::json!({"name":"m_btn_0","type":"Sprite","args":{}}),
];
let err = expand_main_menus(&mut assets).unwrap_err();
assert!(err.contains("m_btn_0"));
assert!(err.contains("collides"));
}
#[test]
fn title_emits_a_heading_label() {
let mut assets = vec![serde_json::json!({
"name": "m", "type": "MainMenu", "args": { "title": "Paused" }
})];
expand_main_menus(&mut assets).unwrap();
assert_eq!(by_name(&assets, "m_title")["args"]["content"], "Paused");
}
#[test]
fn emits_own_font_and_labels_reference_it() {
let mut assets =
vec![serde_json::json!({"name":"m","type":"MainMenu","args":{"toggle_key":""}})];
expand_main_menus(&mut assets).unwrap();
let font = by_name(&assets, "m_font");
assert_eq!(font["type"], "Font");
assert!(font["args"].get("path").is_none());
assert_eq!(by_name(&assets, "m_label_0")["args"]["font"], "m_font");
assert_eq!(
by_name(&assets, "m_settings_video_label_back")["args"]["font"],
"m_font"
);
assert_eq!(
by_name(&assets, "m_settings_video_opt_vsync")["args"]["font"],
"m_font"
);
}
#[test]
fn emitted_font_size_follows_font_px() {
let mut assets =
vec![serde_json::json!({"name":"m","type":"MainMenu","args":{"toggle_key":""}})];
expand_main_menus(&mut assets).unwrap();
assert_eq!(by_name(&assets, "m_font")["args"]["size_px"], 48);
let mut assets = vec![serde_json::json!({
"name": "m",
"type": "MainMenu",
"args": { "toggle_key": "", "font_px": 32 }
})];
expand_main_menus(&mut assets).unwrap();
assert_eq!(by_name(&assets, "m_font")["args"]["size_px"], 32);
}
#[test]
fn custom_font_is_used_and_none_emitted() {
let mut assets = vec![
serde_json::json!({"name":"f","type":"Font","args":{"path":"my.ttf","size_px":32}}),
serde_json::json!({"name":"m","type":"MainMenu","args":{"font":"f","toggle_key":""}}),
];
expand_main_menus(&mut assets).unwrap();
assert!(!assets.iter().any(|v| asset_name(v) == "m_font"));
assert_eq!(by_name(&assets, "m_label_0")["args"]["font"], "f");
}
#[test]
fn settings_chrome_and_band_fit_on_screen() {
let [ref_w, ref_h] = UI_REFERENCE_SIZE;
let mut assets = vec![serde_json::json!({"name":"m","type":"MainMenu"})];
expand_main_menus(&mut assets).unwrap();
for suffix in ["video", "audio", "controls"] {
let screen = format!("m_settings_{suffix}");
let panel = by_name(&assets, &format!("{screen}_scroll"));
let by = panel["args"]["y"].as_f64().unwrap();
let bh = panel["args"]["height"].as_f64().unwrap();
assert!(
by >= 0.0 && by + bh <= ref_h as f64,
"{screen} band off-screen"
);
let back = by_name(&assets, &format!("{screen}_btn_back"));
let back_y = back["args"]["y"].as_f64().unwrap();
let back_h = back["args"]["height"].as_f64().unwrap();
assert!(back_y >= by + bh, "{screen} Back overlaps the band");
assert!(back_y + back_h <= ref_h as f64, "{screen} Back off-screen");
let track = by_name(&assets, &format!("{screen}_scrolltrack"));
let tx = track["args"]["x"].as_f64().unwrap();
let tw = track["args"]["width"].as_f64().unwrap();
assert!(
tx + tw <= ref_w as f64,
"{screen} scrollbar off the right edge"
);
}
}
#[test]
fn settings_tab_emits_a_scroll_panel() {
let mut assets = vec![serde_json::json!({"name":"m","type":"MainMenu"})];
expand_main_menus(&mut assets).unwrap();
let panel = by_name(&assets, "m_settings_video_scroll");
assert_eq!(panel["type"], "ScrollPanel");
assert_eq!(panel["args"]["thumb"], "m_settings_video_scrollthumb");
assert_eq!(panel["args"]["track"], "m_settings_video_scrolltrack");
assert_eq!(
by_name(&assets, "m_settings_video_scrollthumb")["type"],
"Sprite"
);
let rows = panel["args"]["rows"].as_array().unwrap();
let vsync_row = rows
.iter()
.find(|r| {
r["elements"]
.as_array()
.unwrap()
.iter()
.any(|e| e == "m_settings_video_opt_vsync_value")
})
.expect("a row listing the vsync value label");
assert_eq!(vsync_row["group"], -1);
}
#[test]
fn video_advanced_group_collapses_render_scale_and_exposure() {
let mut assets = vec![serde_json::json!({"name":"m","type":"MainMenu"})];
expand_main_menus(&mut assets).unwrap();
assert_eq!(
by_name(&assets, "m_settings_video_grphdr_1")["args"]["content"],
"+ Advanced"
);
assert_eq!(
by_name(&assets, "m_settings_video_grpbtn_1")["args"]["action"],
"group:toggle:1"
);
let panel = by_name(&assets, "m_settings_video_scroll");
let groups = panel["args"]["groups"].as_array().unwrap();
assert_eq!(groups.len(), 2);
let advanced = groups
.iter()
.find(|g| g["header"] == "m_settings_video_grphdr_1")
.expect("Advanced group present");
assert_eq!(advanced["collapsed"], true);
let rows = panel["args"]["rows"].as_array().unwrap();
let in_advanced = |needle: &str| {
rows.iter()
.find(|r| {
r["elements"]
.as_array()
.unwrap()
.iter()
.any(|e| e.as_str().unwrap().contains(needle))
})
.map(|r| r["group"].as_i64().unwrap())
};
assert_eq!(in_advanced("opt_render_scale"), Some(1));
assert_eq!(in_advanced("sld_exposure"), Some(1));
for key in [
"sld_bloom_intensity",
"sld_bloom_threshold",
"sld_bloom_knee",
"sld_vignette",
"sld_lut_strength",
"sld_ambient_intensity",
"sld_fov",
] {
assert_eq!(
in_advanced(key),
Some(1),
"{key} should be in the Advanced group"
);
}
for key in [
"opt_upscale_backend",
"opt_temporal_upscaling",
"opt_hdr_display",
"opt_hdr_pq",
"opt_frames_in_flight",
"opt_occlusion_two_pass",
"opt_texture_quality",
] {
assert_eq!(
in_advanced(key),
Some(1),
"{key} should be in the Advanced group"
);
}
}
#[test]
fn video_quality_group_holds_render_feature_toggles() {
let mut assets = vec![serde_json::json!({"name":"m","type":"MainMenu"})];
expand_main_menus(&mut assets).unwrap();
assert_eq!(
by_name(&assets, "m_settings_video_grphdr_0")["args"]["content"],
"+ Quality"
);
assert_eq!(
by_name(&assets, "m_settings_video_grpbtn_0")["args"]["action"],
"group:toggle:0"
);
let panel = by_name(&assets, "m_settings_video_scroll");
let groups = panel["args"]["groups"].as_array().unwrap();
let quality = groups
.iter()
.find(|g| g["header"] == "m_settings_video_grphdr_0")
.expect("Quality group present");
assert_eq!(quality["collapsed"], true);
assert_eq!(quality["title"], "Quality");
let rows = panel["args"]["rows"].as_array().unwrap();
let group_of = |needle: &str| {
rows.iter()
.find(|r| {
r["elements"]
.as_array()
.unwrap()
.iter()
.any(|e| e.as_str().unwrap().contains(needle))
})
.map(|r| r["group"].as_i64().unwrap())
};
for key in [
"opt_aa_mode",
"opt_ssao",
"opt_ssr",
"opt_ray_traced_reflections",
"opt_reflection_blur_resolution",
"opt_ssgi",
"opt_ssgi_resolution",
"opt_ssgi_rays",
"opt_ssgi_steps",
"opt_shadow_map_size",
"opt_shadow_update",
"opt_shadow_distance",
"opt_shadow_cascades",
"opt_auto_exposure",
"opt_anisotropy",
"sld_ssao_radius",
"sld_ssao_intensity",
"sld_ssr_intensity",
"sld_ssr_max_distance",
"sld_ssgi_intensity",
"sld_ssgi_max_distance",
"sld_auto_exposure_min_ev",
"sld_auto_exposure_max_ev",
"sld_auto_exposure_speed",
] {
assert_eq!(
group_of(key),
Some(0),
"{key} should be in the Quality group"
);
}
}
#[test]
fn group_toggle_gid_indexes_its_own_group() {
let mut assets = vec![serde_json::json!({"name":"m","type":"MainMenu"})];
expand_main_menus(&mut assets).unwrap();
let panel = by_name(&assets, "m_settings_video_scroll");
let groups = panel["args"]["groups"].as_array().unwrap();
for (gid, group) in groups.iter().enumerate() {
let header_name = format!("m_settings_video_grphdr_{gid}");
assert_eq!(group["header"], header_name, "group {gid} out of gid order");
assert_eq!(
by_name(&assets, &format!("m_settings_video_grpbtn_{gid}"))["args"]["action"],
format!("group:toggle:{gid}")
);
let content = by_name(&assets, &header_name)["args"]["content"]
.as_str()
.unwrap()
.to_string();
let title = group["title"].as_str().unwrap();
assert!(
content.ends_with(title),
"header {content:?} does not match group {title:?} at index {gid}"
);
}
}
#[test]
fn settings_rows_have_card_backgrounds() {
let mut assets = vec![serde_json::json!({"name":"m","type":"MainMenu"})];
expand_main_menus(&mut assets).unwrap();
let panel = by_name(&assets, "m_settings_video_scroll");
let rows = panel["args"]["rows"].as_array().unwrap();
for row in rows {
let elems = row["elements"].as_array().unwrap();
let bg = elems[0].as_str().unwrap();
assert!(
bg.contains("_bg_"),
"row missing a leading bg element: {elems:?}"
);
let sprite = by_name(&assets, bg);
assert_eq!(sprite["type"], "Sprite");
let alpha = sprite["args"]["tint"][3].as_f64().unwrap();
assert!(
alpha > 0.0 && alpha < 1.0,
"bg card should be semi-transparent"
);
}
let names: Vec<String> = assets.iter().map(asset_name).collect();
let bg_idx = names
.iter()
.position(|n| n == "m_settings_video_bg_0")
.expect("first row card");
let content_idx = names
.iter()
.position(|n| n == "m_settings_video_opt_vsync")
.expect("vsync row");
assert!(
bg_idx < content_idx,
"card must precede row content for z-order"
);
}
#[test]
fn default_menu_hover_is_color_only() {
let mut assets =
vec![serde_json::json!({"name":"m","type":"MainMenu","args":{"title":"Paused"}})];
expand_main_menus(&mut assets).unwrap();
let label_scale = |name: &str| by_name(&assets, name)["args"]["scale"].as_f64().unwrap();
let mut checked = 0;
for v in &assets {
if type_norm(v) != "hitregion" {
continue;
}
let args = &v["args"];
let (Some(label), Some(hs)) = (args["label"].as_str(), args["hover_scale"].as_f64())
else {
continue;
};
if label.is_empty() {
continue;
}
let ls = label_scale(label);
assert!(
(hs - ls).abs() < 1e-6,
"{}: hover_scale {hs} != label scale {ls}",
asset_name(v)
);
checked += 1;
}
assert!(checked > 0, "no labeled hover regions were checked");
for v in &assets {
if type_norm(v) != "optionselect" {
continue;
}
let ts = v["args"]["text_scale"].as_f64().unwrap();
let hs = v["args"]["hover_scale"].as_f64().unwrap();
assert!(
(hs - ts).abs() < 1e-6,
"optionselect hover_scale {hs} != text_scale {ts}"
);
}
}
#[test]
fn hover_scale_multiplies_label_scale() {
let mut assets = vec![serde_json::json!({
"name":"m","type":"MainMenu","args":{"hover_scale":2.0,"toggle_key":""}
})];
expand_main_menus(&mut assets).unwrap();
let label_scale = by_name(&assets, "m_label_0")["args"]["scale"]
.as_f64()
.unwrap();
let region_hs = by_name(&assets, "m_btn_0")["args"]["hover_scale"]
.as_f64()
.unwrap();
assert!(
(region_hs - label_scale * 2.0).abs() < 1e-6,
"expected {} got {region_hs}",
label_scale * 2.0
);
}
#[test]
fn settings_row_content_is_inset_evenly_within_card() {
let mut assets = vec![serde_json::json!({"name":"m","type":"MainMenu"})];
expand_main_menus(&mut assets).unwrap();
let card = by_name(&assets, "m_settings_video_bg_0");
let opt = by_name(&assets, "m_settings_video_opt_vsync");
let card_x = card["args"]["x"].as_f64().unwrap();
let card_w = card["args"]["width"].as_f64().unwrap();
let opt_x = opt["args"]["x"].as_f64().unwrap();
let opt_w = opt["args"]["width"].as_f64().unwrap();
let left_pad = opt_x - card_x;
let right_pad = (card_x + card_w) - (opt_x + opt_w);
assert!(left_pad > 0.0, "content should be inset from the left edge");
assert!(
(left_pad - right_pad).abs() < 1e-3,
"left pad {left_pad} != right pad {right_pad}"
);
}
#[test]
fn minimal_profile_emits_only_video_and_audio_tabs() {
let mut assets = vec![serde_json::json!({
"name": "m", "type": "MainMenu", "args": { "settings_profile": "minimal" }
})];
expand_main_menus(&mut assets).unwrap();
assert_eq!(by_name(&assets, "m_settings_video")["type"], "Screen");
assert_eq!(by_name(&assets, "m_settings_audio")["type"], "Screen");
assert!(
!assets
.iter()
.any(|v| asset_name(v) == "m_settings_controls"),
"Minimal must not emit a Controls tab screen"
);
assert_eq!(
by_name(&assets, "m_settings_video_tabbtn_audio")["args"]["action"],
"screen:show:m_settings_audio"
);
assert!(
!assets
.iter()
.any(|v| asset_name(v) == "m_settings_video_tabbtn_controls"),
"Minimal Video tab bar must not offer Controls"
);
}
#[test]
fn minimal_video_tab_is_trimmed_to_output_basics() {
let mut assets = vec![serde_json::json!({
"name": "m", "type": "MainMenu", "args": { "settings_profile": "minimal" }
})];
expand_main_menus(&mut assets).unwrap();
for setting in ["window_mode", "resolution", "vsync", "fps_cap"] {
let opt = by_name(&assets, &format!("m_settings_video_opt_{setting}"));
assert_eq!(opt["type"], "OptionSelect");
assert_eq!(opt["args"]["setting"], setting);
}
for dropped in [
"m_settings_video_opt_graphics_quality",
"m_settings_video_opt_perf_stats",
"m_settings_video_opt_show_fps",
"m_settings_video_opt_show_vram",
"m_settings_video_opt_ssgi",
"m_settings_video_grphdr_0",
"m_settings_video_grphdr_1",
] {
assert!(
!assets.iter().any(|v| asset_name(v) == dropped),
"Minimal Video must not emit {dropped}"
);
}
let panel = by_name(&assets, "m_settings_video_scroll");
assert!(panel["args"]["groups"].as_array().unwrap().is_empty());
}
#[test]
fn minimal_audio_tab_keeps_master_volume() {
let mut assets = vec![serde_json::json!({
"name": "m", "type": "MainMenu", "args": { "settings_profile": "minimal" }
})];
expand_main_menus(&mut assets).unwrap();
let vol = by_name(&assets, "m_settings_audio_opt_master_volume");
assert_eq!(vol["type"], "OptionSelect");
assert_eq!(vol["args"]["setting"], "master_volume");
}
#[test]
fn settings_back_action_generates_screen_and_overrides_back() {
let mut assets = vec![serde_json::json!({
"name": "m",
"type": "MainMenu",
"args": {
"settings_profile": "minimal",
"settings_back_action": "story:settings_back",
"items": [{"label": "Settings", "action": "story:settings"}]
}
})];
expand_main_menus(&mut assets).unwrap();
assert_eq!(by_name(&assets, "m_settings_video")["type"], "Screen");
for suffix in ["video", "audio"] {
assert_eq!(
by_name(&assets, &format!("m_settings_{suffix}_btn_back"))["args"]["action"],
"story:settings_back"
);
}
}
#[test]
fn menu_labels_center_with_real_metrics() {
let mut assets =
vec![serde_json::json!({"name":"m","type":"MainMenu","args":{"title":"Paused"}})];
expand_main_menus(&mut assets).unwrap();
assert_eq!(by_name(&assets, "m_title")["args"]["align"], "center");
assert_eq!(by_name(&assets, "m_label_0")["args"]["align"], "center");
assert_eq!(
by_name(&assets, "m_settings_video_label_back")["args"]["align"],
"center"
);
}
#[test]
fn full_profile_still_emits_controls_and_quality() {
let mut assets = vec![serde_json::json!({"name": "m", "type": "MainMenu"})];
expand_main_menus(&mut assets).unwrap();
assert_eq!(by_name(&assets, "m_settings_controls")["type"], "Screen");
assert_eq!(
by_name(&assets, "m_settings_video_opt_graphics_quality")["type"],
"OptionSelect"
);
}
}