1use schemars::JsonSchema;
12use serde::Deserialize;
13use unlit_ecs::mcp::serde_json::{self, Value, json};
14use unlit_ecs::mcp::{
15 CommandSender, McpServer, ToolResult, ToolRouter, ToolSource, WorldArg, command, tool,
16};
17use unlit_ecs::{World, bundle};
18use unlit3d::winit::builtin::ExitRequest;
19use unlit3dx::mcp::{pump_mcp, serve_mcp};
20use unlit3dx::prelude::*;
21
22use crate::interactive::{Interactive, InteractiveConfig, scene_status};
23
24pub fn run(soundfont: std::sync::Arc<[u8]>) {
26 let gpu = pollster::block_on(Gpu::headless()).expect("the headless run opens a GPU");
27 let (mut host, _input, interactive) = crate::interactive::run(InteractiveConfig {
28 device: gpu.device,
29 queue: gpu.queue,
30 capabilities: gpu.capabilities,
31 soundfont,
32 });
33 host.root_scope_mut().spawn(bundle![ExitRequest(false)]);
34 host.root_scope_mut().spawn(bundle![interactive]);
35 serve_mcp(&mut host);
36 log::info!("the interactive headless MCP server is ready");
37 loop {
38 pump_mcp(&mut host).expect("the MCP command queue applies");
39 let exit = host
40 .root_scope()
41 .query::<&ExitRequest>()
42 .iter()
43 .any(|request| request.0);
44 if exit {
45 break;
46 }
47 std::thread::park_timeout(std::time::Duration::from_millis(1));
48 }
49}
50
51fn status(host: &World, interactive: &Interactive) -> Value {
54 let worlds = interactive.worlds_handle();
55 let scene = scene_status(host, worlds);
56 let buttons = Interactive::panel_buttons(host, worlds)
57 .into_iter()
58 .map(|(label, rect)| (label.to_owned(), json!([rect.center().x, rect.center().y])))
59 .collect::<serde_json::Map<_, _>>();
60 json!({
61 "ready": true,
62 "frame": interactive.frame(),
63 "balls": scene.balls,
64 "sequence_paused": scene.sequence.map(|state| state.0),
65 "sequence_time": scene.sequence.map(|state| state.1),
66 "sequence_finished": scene.sequence.map(|state| state.2),
67 "buttons": buttons,
68 "render_world": { "Handle": interactive.render_world.to_bits() },
69 "audio_world": { "Handle": worlds.audio.entity.to_bits() },
70 "target": interactive.target.id().to_bits(),
71 })
72}
73
74#[derive(Debug, Deserialize, JsonSchema)]
75struct StepFrames {
76 #[serde(default)]
77 world: unlit_ecs::mcp::WorldId,
78 frames: u32,
80}
81
82unlit_ecs::mcp::inventory::submit! {
83 ToolSource { extend: register }
84}
85
86fn register(router: &mut ToolRouter<McpServer>) {
87 router.add_route(tool(
88 "step_frames",
89 "Advance the interactive headless scene by an exact number of fixed frames.",
90 step_frames,
91 ));
92 router.add_route(tool(
93 "scene_status",
94 "Report the interactive scene's frame, balls, MIDI sequence, button centres and MCP world ids.",
95 scene_status_tool,
96 ));
97}
98
99async fn step_frames(commands: CommandSender, args: StepFrames) -> ToolResult {
100 command(&commands, args.world, move |host| {
101 if args.frames == 0 {
102 return Err("frames must be at least 1".to_owned());
103 }
104 let mut interactive = host.root_scope().query::<&mut Interactive>();
105 let Some(interactive) = interactive.iter().next() else {
106 return Err("the host is not an interactive headless scene".to_owned());
107 };
108 interactive.step(host, args.frames);
109 interactive.draw(host);
114 Ok(json!({ "frame": interactive.frame() }))
115 })
116 .await
117}
118
119async fn scene_status_tool(commands: CommandSender, args: WorldArg) -> ToolResult {
120 command(&commands, args.world, |host| {
121 let mut interactive = host.root_scope().query::<&Interactive>();
122 let Some(interactive) = interactive.iter().next() else {
123 return Err("the host is not an interactive headless scene".to_owned());
124 };
125 Ok(status(host, interactive))
126 })
127 .await
128}