mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Tell input apart by who caused it, and record repros for bug reports
An agent driving the control socket could not tell its own input from a person's, and a player had no way to hand over a session that reproduces a bug. Both are now in the agent surface: - Every input event carries an origin: user (the window's own keyboard, mouse or pad), agent (the control socket) or game (input the game made up). state and status gain an input section; input-log returns the newest 512 events; a person's input during an agent's run logs once and pushes a user-input event, so wait-for event user-input catches it. - record-start/stop/status/list write inputs, second-by-second state, events and, in single player, a copy of the world. repro-export zips that with logs/latest.log, the newest crash report and a status. record-replay loads the world, restores the start state and feeds the recorded keys, mouse, wheel, look, pad buttons and control lines back at their ticks, best effort. F3+R and F3+E, and the AI Agent page, give a player the same without horizon-ctl. The commands come from the one command table, so horizon-ctl and MCP get them together (also on the pending shared horizon-mcp crate). Co-Authored-By: JustKarkat's AI agent (ZCode) <[email protected]>
This commit is contained in:
1 parent
20c6194da6
commit
f1cf4aa2bc
21 files changed
+2645
-165
No files matched your search
@@ -73,6 +73,9 @@ pub const MAPPINGS: &[(&str, &str, &str)] = &[
|
||||
("key.debug.networkCharts", "key.keyboard.3", "debug"),
|
||||
("key.debug.lightmapTexture", "key.keyboard.4", "debug"),
|
||||
("key.debug.improvedTransparency", "key.keyboard.x", "debug"),
|
||||
// Horizon's own: a repro recording (F3+R) and its bundle (F3+E).
|
||||
("key.debug.recordSession", "key.keyboard.r", "debug"),
|
||||
("key.debug.exportRepro", "key.keyboard.e", "debug"),
|
||||
];
|
||||
/// Mappings Options builds as `ToggleKeyMapping` (hold or toggle by option).
|
||||
pub fn is_toggle(name: &str) -> bool {
|
||||
|
||||
@@ -1308,6 +1308,10 @@ enum Act {
|
||||
/// Credits & Attribution's Credits button (WinScreen without the poem).
|
||||
Credits,
|
||||
CopyAgentDetails,
|
||||
/// AI Agent: start or stop the repro recording.
|
||||
ToggleRecording,
|
||||
/// AI Agent: write the repro bundle now.
|
||||
ExportRepro,
|
||||
}
|
||||
enum WKind {
|
||||
Button {
|
||||
@@ -1435,6 +1439,10 @@ pub enum Event {
|
||||
Credits,
|
||||
/// Copy how an agent connects (the AI Agent page).
|
||||
CopyAgentDetails,
|
||||
/// The AI Agent page's recording button: start or stop.
|
||||
ToggleRecording,
|
||||
/// The AI Agent page's export button.
|
||||
ExportRepro,
|
||||
/// The narrator option changed (after its `Changed`): the option's
|
||||
/// `onValueUpdate`, `GameNarrator.updateNarratorStatus`.
|
||||
Narrator,
|
||||
@@ -2376,6 +2384,22 @@ impl OptionsUi {
|
||||
Act::CopyAgentDetails,
|
||||
)),
|
||||
)];
|
||||
rows.push(Row::Buttons(
|
||||
button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Start/Stop Recording"),
|
||||
Act::ToggleRecording,
|
||||
),
|
||||
Some(button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Export Repro Bundle"),
|
||||
Act::ExportRepro,
|
||||
)),
|
||||
));
|
||||
rows.extend(self.agent_status.iter().cloned().map(Row::Note));
|
||||
rows
|
||||
}
|
||||
@@ -3994,6 +4018,8 @@ impl OptionsUi {
|
||||
}
|
||||
Act::Credits => events.push(Event::Credits),
|
||||
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
|
||||
Act::ToggleRecording => events.push(Event::ToggleRecording),
|
||||
Act::ExportRepro => events.push(Event::ExportRepro),
|
||||
Act::None => {}
|
||||
}
|
||||
}
|
||||
@@ -4917,6 +4943,8 @@ impl OptionsUi {
|
||||
}
|
||||
Act::Credits => events.push(Event::Credits),
|
||||
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
|
||||
Act::ToggleRecording => events.push(Event::ToggleRecording),
|
||||
Act::ExportRepro => events.push(Event::ExportRepro),
|
||||
Act::None => {}
|
||||
}
|
||||
return events;
|
||||
|
||||
@@ -204,6 +204,9 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
cmd!("events", Observe, "events [--since SEQ]", "what happened since SEQ, oldest first: screens, chat, deaths, respawns, items gained or lost, dimensions, worlds",
|
||||
"Every event has seq and tick; pass the reply's last as --since next time. The game keeps the newest 256.",
|
||||
["horizon-ctl events", "horizon-ctl events --since 42"], &[flag("since", Ty::Int, "only events after this seq")], mcp: true, local: false),
|
||||
cmd!("input-log", Observe, "input-log [--since SEQ] [--limit N] [--origin user|agent|game]", "recent input and who caused it: a person, an agent or the game",
|
||||
"Every event has seq, tick and origin. `user` is the window's own keyboard, mouse or pad; `agent` is the control socket (this tool); `game` is input the game made up. A `user-input` event in `events` means a person stepped in while an agent was driving. The game keeps the newest 512.",
|
||||
["horizon-ctl input-log", "horizon-ctl input-log --origin user --limit 20", "horizon-ctl input-log --since 120"], &[flag("since", Ty::Int, "only events after this seq"), flag("limit", Ty::Int, "newest N events"), flag("origin", Ty::Str, "user, agent or game")], mcp: true, local: false),
|
||||
cmd!("wait-ready", Session, "wait-ready [SECONDS]", "wait until the player has spawned and no chunks are pending",
|
||||
"Polls status every 250 ms; replies with the status, or an error after SECONDS (default 120).",
|
||||
["horizon-ctl wait-ready 300"], &[opt("seconds", Ty::Num, "how long to wait (default 120)")], mcp: true, local: true),
|
||||
@@ -449,6 +452,23 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
cmd!("fly", Scripting, "fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]", "fly the camera along a line, then reply with the frame summary",
|
||||
"The reply's tick_ms and tick_ms_max are the world's smoothed tick time over the flight (the server thread's when single player runs on one). With raw, the reply also lists every frame time in milliseconds (frame_ms).",
|
||||
["horizon-ctl fly 0 100 0 500 100 0 20"], &[], mcp: false, local: false),
|
||||
// Repro recordings
|
||||
cmd!("record-start", Scripting, "record-start [NAME] [--no-world]", "record input, state and the world for a bug repro",
|
||||
"Writes recordings/<stamp>-NAME/: inputs.jsonl (who caused each input: user, agent or game), state.jsonl (the player and world every second), events.jsonl and, in single player, a copy of the world. The recording starts from a fresh save. Replies once the world copy is done.",
|
||||
["horizon-ctl record-start repro-bug", "horizon-ctl record-start --no-world"], &[opt("name", Ty::Str, "folder name after the timestamp (default session)"), flag("no-world", Ty::Bool, "do not copy the world (a server session)")], mcp: true, local: false),
|
||||
cmd!("record-stop", Scripting, "record-stop [--export]", "stop the recording (and write the repro zip)",
|
||||
"Without --export only the recording folder is closed; repro-export zips it later. With --export the reply carries the zip path.",
|
||||
["horizon-ctl record-stop", "horizon-ctl record-stop --export"], &[flag("export", Ty::Bool, "write the repro bundle too")], mcp: true, local: false),
|
||||
cmd!("record-status", Scripting, "record-status", "the running recording and replay, if any", "",
|
||||
["horizon-ctl record-status"], &[], mcp: true, local: false),
|
||||
cmd!("record-list", Scripting, "record-list", "the recordings and repro bundles on disk", "", ["horizon-ctl record-list"], &[], mcp: true, local: false),
|
||||
cmd!("record-replay", Scripting, "record-replay NAME [--stop] [--in-place] [--timeout S]", "replay a recording, best effort",
|
||||
"Loads the recording's world copy into saves/, opens it and feeds the recorded input back at its ticks. --in-place feeds the input into the world already open (a server recording needs it). --stop ends a running replay. Same build and machine only; state.jsonl is the oracle, not a promise.",
|
||||
["horizon-ctl record-replay repro-bug", "horizon-ctl record-replay repro-bug --stop", "horizon-ctl record-replay server-fight --in-place"],
|
||||
&[opt("name", Ty::Str, "recording from record-list"), flag("stop", Ty::Bool, "stop the running replay"), flag("in-place", Ty::Bool, "feed the open world instead of opening the copy"), flag("timeout", Ty::Num, "give up after this many seconds (default 300)")], mcp: true, local: false),
|
||||
cmd!("repro-export", Scripting, "repro-export [NAME] [PATH] [--no-world]", "zip a recording with the logs, crash report and status",
|
||||
"Writes repro/<stamp>-NAME.zip: the recording, logs/latest.log, the newest crash report, a status.json and a README. The player attaches it to the bug report; nothing is uploaded. Stops a running recording first.",
|
||||
["horizon-ctl repro-export", "horizon-ctl repro-export repro-bug out.zip"], &[opt("name", Ty::Str, "recording from record-list (default the newest)"), opt("path", Ty::Str, "where to write the zip"), flag("no-world", Ty::Bool, "leave the world out")], mcp: true, local: false),
|
||||
];
|
||||
|
||||
pub fn find(name: &str) -> Option<&'static Cmd> {
|
||||
|
||||
@@ -28,10 +28,14 @@ pub enum DebugAction {
|
||||
NetworkCharts,
|
||||
LightmapTexture,
|
||||
ImprovedTransparency,
|
||||
/// F3+R: start a repro recording, or stop it and write its bundle.
|
||||
RecordSession,
|
||||
/// F3+E: write the bundle now.
|
||||
ExportRepro,
|
||||
}
|
||||
|
||||
/// The mappings `handleDebugKeys` tests, in its order.
|
||||
pub const ACTIONS: [(&str, DebugAction); 20] = [
|
||||
pub const ACTIONS: [(&str, DebugAction); 22] = [
|
||||
("key.debug.reloadChunk", DebugAction::ReloadChunks),
|
||||
("key.debug.showHitboxes", DebugAction::ShowHitboxes),
|
||||
("key.debug.clearChat", DebugAction::ClearChat),
|
||||
@@ -67,6 +71,8 @@ pub const ACTIONS: [(&str, DebugAction); 20] = [
|
||||
"key.debug.improvedTransparency",
|
||||
DebugAction::ImprovedTransparency,
|
||||
),
|
||||
("key.debug.recordSession", DebugAction::RecordSession),
|
||||
("key.debug.exportRepro", DebugAction::ExportRepro),
|
||||
];
|
||||
|
||||
/// GLFW's key actions.
|
||||
|
||||
@@ -141,6 +141,7 @@ mod hand;
|
||||
mod headless;
|
||||
mod ime;
|
||||
mod initial_spawn;
|
||||
mod input_trace;
|
||||
mod integrated;
|
||||
mod interactions;
|
||||
mod item_draw;
|
||||
@@ -265,6 +266,7 @@ mod remote_stations;
|
||||
mod remote_vehicles;
|
||||
mod render_control;
|
||||
mod render_tick;
|
||||
mod repro;
|
||||
mod respawn_anchor;
|
||||
mod saves_control;
|
||||
mod saving;
|
||||
@@ -2734,6 +2736,10 @@ struct Game<'a> {
|
||||
ui_wait: ui_control::UiWait,
|
||||
/// What happened, for `events` and `wait-for` (game/events.rs).
|
||||
events: events::Events,
|
||||
/// Who caused each input event: a person, an agent or the game (game/input_trace.rs).
|
||||
input_trace: input_trace::InputTrace,
|
||||
/// A repro recording, export or replay (game/repro.rs).
|
||||
repro: repro::Repro,
|
||||
/// The overworld's last `sky_darken` and its inputs (time, rain, thunder).
|
||||
sky_darken_memo: std::cell::Cell<Option<((i64, u32, u32), i32)>>,
|
||||
/// The last water plane `water_plane` found, kept while none is in reach.
|
||||
@@ -7024,6 +7030,7 @@ impl Game<'_> {
|
||||
None => 0.0,
|
||||
};
|
||||
self.ticks += 1;
|
||||
self.repro_tick();
|
||||
// An in-frame LAN host's player has the entity id its guests see it by.
|
||||
if !self.serving()
|
||||
&& let (Some(host), Some(p)) = (&self.lan, self.player.as_mut())
|
||||
@@ -12228,6 +12235,8 @@ impl Game<'_> {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
// A repro recording is finished and bundled before the process may go away.
|
||||
self.repro_on_exit();
|
||||
if self.title.is_some() || self.saved_for_exit {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -16434,7 +16443,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.handle_window_event(event_loop, id, event);
|
||||
self.handle_window_event(event_loop, id, event, input_trace::InputOrigin::User);
|
||||
if let Some((event, modifiers)) = key {
|
||||
self.native_input_key(&event, modifiers);
|
||||
}
|
||||
@@ -16449,6 +16458,10 @@ impl ApplicationHandler for Game<'_> {
|
||||
&& let DeviceEvent::MouseMotion { delta: (x, y) } = event
|
||||
{
|
||||
// MouseHandler.onMove with the pointer grabbed; applied once per frame.
|
||||
self.note_input(
|
||||
input_trace::InputOrigin::User,
|
||||
input_trace::TraceKind::Motion { x, y },
|
||||
);
|
||||
self.mouse.on_move(0.0, 0.0, x, y, true, true);
|
||||
self.last_input = Instant::now();
|
||||
}
|
||||
@@ -16474,6 +16487,7 @@ impl Game<'_> {
|
||||
event_loop: &dyn EventLoopCtl,
|
||||
id: WindowId,
|
||||
mut event: WindowEvent,
|
||||
origin: input_trace::InputOrigin,
|
||||
) {
|
||||
// A headless game has no window: its events (the frame, the control socket's input)
|
||||
// carry `WindowId::dummy()`.
|
||||
@@ -16496,6 +16510,9 @@ impl Game<'_> {
|
||||
{
|
||||
*button = MouseButton::Right;
|
||||
}
|
||||
// Who caused it, before the event is handled: the trace wants the
|
||||
// window's own keys, never the ones the socket made up.
|
||||
self.trace_window_event(origin, &event);
|
||||
if let WindowEvent::KeyboardInput { event, .. } = &event
|
||||
&& let PhysicalKey::Code(key) = event.physical_key
|
||||
{
|
||||
@@ -17965,13 +17982,23 @@ impl Game<'_> {
|
||||
// Offscreen frames don't wait for the window to be drawn: an
|
||||
// occluded window (a locked macOS session) gets no redraws.
|
||||
let id = w.id();
|
||||
self.handle_window_event(event_loop, id, WindowEvent::RedrawRequested);
|
||||
self.handle_window_event(
|
||||
event_loop,
|
||||
id,
|
||||
WindowEvent::RedrawRequested,
|
||||
input_trace::InputOrigin::Game,
|
||||
);
|
||||
} else {
|
||||
w.request_redraw();
|
||||
}
|
||||
} else if self.options.headless {
|
||||
// The headless loop paces the frames itself (`run_headless`).
|
||||
self.handle_window_event(event_loop, WindowId::dummy(), WindowEvent::RedrawRequested);
|
||||
self.handle_window_event(
|
||||
event_loop,
|
||||
WindowId::dummy(),
|
||||
WindowEvent::RedrawRequested,
|
||||
input_trace::InputOrigin::Game,
|
||||
);
|
||||
return;
|
||||
}
|
||||
event_loop.set_control_flow(ControlFlow::Poll);
|
||||
@@ -18386,6 +18413,8 @@ impl<'a> Game<'a> {
|
||||
flight: None,
|
||||
ui_wait: Default::default(),
|
||||
events: Default::default(),
|
||||
input_trace: Default::default(),
|
||||
repro: Default::default(),
|
||||
agent: agent::Agent::default(),
|
||||
view_frustum: None,
|
||||
entity_culler: None,
|
||||
|
||||
@@ -466,7 +466,7 @@ impl Game<'_> {
|
||||
json!({ "kind": kind, "block": block, "entity": entity })
|
||||
}
|
||||
|
||||
fn state_value(&self) -> Value {
|
||||
pub(super) fn state_value(&self) -> Value {
|
||||
let player = self.player.as_ref().map(|p| {
|
||||
json!({
|
||||
"position": [p.position.x, p.position.y, p.position.z],
|
||||
@@ -497,7 +497,7 @@ impl Game<'_> {
|
||||
"effects": self.potions.effects.list.iter().map(|e| json!({ "id": e.id, "duration_ticks": e.duration, "amplifier": e.amplifier })).collect::<Vec<_>>(),
|
||||
})
|
||||
});
|
||||
json!({
|
||||
let mut state = json!({
|
||||
"dialog": self.dialog_status(),
|
||||
"screen": self.screen_label(),
|
||||
"menu": (self.title.is_some() || self.split_profiles_open()).then(|| self.title_value()),
|
||||
@@ -527,7 +527,13 @@ impl Game<'_> {
|
||||
"looking_at": self.looking_at(),
|
||||
"busy": self.agent.task.as_ref().map(|t| t.name),
|
||||
"ticks": self.ticks,
|
||||
})
|
||||
});
|
||||
// Who caused the input, and whether a repro recording runs.
|
||||
let mut input = self.input_trace.summary();
|
||||
self.repro
|
||||
.fill_summary(&mut input, self.input_trace.events());
|
||||
state["input"] = input;
|
||||
state
|
||||
}
|
||||
|
||||
/// Splitscreen's profile page, a joining player's (`split_profiles`).
|
||||
@@ -657,7 +663,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn entities_value(&self, radius: f64, kind: Option<&str>) -> Value {
|
||||
pub(super) fn entities_value(&self, radius: f64, kind: Option<&str>) -> Value {
|
||||
let Some(p) = &self.player else {
|
||||
return json!({ "entities": [] });
|
||||
};
|
||||
@@ -711,7 +717,7 @@ impl Game<'_> {
|
||||
json!({ "entities": list.into_iter().map(|(_, v)| v).collect::<Vec<_>>() })
|
||||
}
|
||||
|
||||
fn inventory_value(&self) -> Value {
|
||||
pub(super) fn inventory_value(&self) -> Value {
|
||||
let armor = ["head", "chest", "legs", "feet"];
|
||||
json!({
|
||||
"selected": self.selected + 1,
|
||||
@@ -1002,6 +1008,7 @@ impl Game<'_> {
|
||||
device_id,
|
||||
position,
|
||||
},
|
||||
super::input_trace::InputOrigin::Agent,
|
||||
);
|
||||
if i == 0 {
|
||||
self.handle_window_event(
|
||||
@@ -1012,6 +1019,7 @@ impl Game<'_> {
|
||||
state: ElementState::Pressed,
|
||||
button,
|
||||
},
|
||||
super::input_trace::InputOrigin::Agent,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1023,6 +1031,7 @@ impl Game<'_> {
|
||||
state: ElementState::Released,
|
||||
button,
|
||||
},
|
||||
super::input_trace::InputOrigin::Agent,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1582,6 +1591,7 @@ impl Game<'_> {
|
||||
device_id,
|
||||
position,
|
||||
},
|
||||
super::input_trace::InputOrigin::Agent,
|
||||
);
|
||||
for _ in 0..steps.abs() as u32 {
|
||||
self.handle_window_event(
|
||||
@@ -1592,6 +1602,7 @@ impl Game<'_> {
|
||||
delta: MouseScrollDelta::LineDelta(0.0, steps.signum()),
|
||||
phase: TouchPhase::Moved,
|
||||
},
|
||||
super::input_trace::InputOrigin::Agent,
|
||||
);
|
||||
}
|
||||
ok(json!({ "ok": true, "screen": self.screen_label() }))
|
||||
|
||||
@@ -137,6 +137,7 @@ impl Game<'_> {
|
||||
}
|
||||
horizon_client::widgets::end_frame(self.frames);
|
||||
self.narrate_screen();
|
||||
self.poll_repro(event_loop);
|
||||
self.advance_agent();
|
||||
for request in self.ui_wait.ready() {
|
||||
self.answer(request, event_loop);
|
||||
@@ -159,6 +160,13 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
fn answer(&mut self, request: horizon_client::control::Request, event_loop: &dyn EventLoopCtl) {
|
||||
// Whatever the socket asked for is an agent's input, named for `input-log`.
|
||||
self.note_input(
|
||||
super::input_trace::InputOrigin::Agent,
|
||||
super::input_trace::TraceKind::Command {
|
||||
line: request.line.clone(),
|
||||
},
|
||||
);
|
||||
let reply = match self.control_command(&request.line, &request.reply, event_loop) {
|
||||
Ok(Some(value)) => value,
|
||||
// Deferred: the command keeps its own reply channel.
|
||||
@@ -253,6 +261,15 @@ impl Game<'_> {
|
||||
result
|
||||
}
|
||||
|
||||
/// A recorded control line fed back by a replay: not new input, so the
|
||||
/// trace does not see it, and its reply has no one waiting.
|
||||
pub(super) fn replay_line(&mut self, line: &str, event_loop: &dyn EventLoopCtl) {
|
||||
let (reply, _) = std::sync::mpsc::channel();
|
||||
if let Err(e) = self.control_command(line, &reply, event_loop) {
|
||||
log::warn!("Horizon: replay: {line}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn control_command_inner(
|
||||
&mut self,
|
||||
line: &str,
|
||||
@@ -281,6 +298,12 @@ impl Game<'_> {
|
||||
if let Some(value) = self.events_command(&words, reply)? {
|
||||
return Ok(value);
|
||||
}
|
||||
if let Some(value) = self.input_trace.command(&words)? {
|
||||
return Ok(Some(value));
|
||||
}
|
||||
if let Some(value) = self.repro_command(&words, line, reply)? {
|
||||
return Ok(value);
|
||||
}
|
||||
if let Some(value) = self.saves_control(&words, line, reply)? {
|
||||
return Ok(value);
|
||||
}
|
||||
@@ -313,6 +336,8 @@ impl Game<'_> {
|
||||
.chain(super::events::HELP)
|
||||
.chain(super::saves_control::HELP)
|
||||
.chain(super::pad::HELP)
|
||||
.chain(super::input_trace::HELP)
|
||||
.chain(super::repro::HELP)
|
||||
.collect::<Vec<_>>() }))),
|
||||
"setblock" | "fill" | "particle" | "signedit" => self.render_command(&words),
|
||||
"section-draws" => {
|
||||
@@ -399,148 +424,7 @@ impl Game<'_> {
|
||||
}
|
||||
"pad" => Ok(Some(self.pad_command(&words)?)),
|
||||
"key" => {
|
||||
let key = key_code(arg(1)?).ok_or_else(|| format!("unknown key {}", words[1]))?;
|
||||
let action = arg(2)?;
|
||||
if !matches!(action, "down" | "up" | "tap" | "repeat") {
|
||||
return Err(format!(
|
||||
"key action must be down, up, tap or repeat, not {action}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
// ConfirmScreen.keyPressed: Escape answers no, nothing else goes past it.
|
||||
if self.command_prompt.is_some() {
|
||||
if key == KeyCode::Escape && arg(2)? != "up" {
|
||||
self.answer_command_prompt(false);
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
// A server's dialog takes the keys, in configuration too.
|
||||
if self.dialog_open() {
|
||||
if arg(2)? != "up" {
|
||||
self.dialog_key(key, None, None)?;
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
self.require_world()?;
|
||||
// KeyboardHandler.keyPress: the open screen's narration hears it.
|
||||
super::narrator::narrate_key(&mut self.narration, key, arg(2)? != "up");
|
||||
// The sign editor takes raw presses (Enter, arrows, Backspace, Escape).
|
||||
if self.sign_edit.is_some() {
|
||||
if arg(2)? != "up" {
|
||||
self.sign_input(Some(key), None);
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
// A held edit modifier (`key super down`) and A/C/V/X: the
|
||||
// open book's Select All, Copy, Paste or Cut.
|
||||
if self.book.is_some() && arg(2)? != "up" {
|
||||
let held = |k: [KeyCode; 2]| k.iter().any(|k| self.keys.contains(k));
|
||||
let edit = if cfg!(target_os = "macos") {
|
||||
held([KeyCode::SuperLeft, KeyCode::SuperRight])
|
||||
} else {
|
||||
held([KeyCode::ControlLeft, KeyCode::ControlRight])
|
||||
};
|
||||
let name = arg(1)?;
|
||||
let letter = name.chars().next().filter(|_| name.len() == 1);
|
||||
let shift = held([KeyCode::ShiftLeft, KeyCode::ShiftRight]);
|
||||
if let Some(shortcut) =
|
||||
horizon_client::clipboard::edit_shortcut(letter, edit, shift, false)
|
||||
{
|
||||
self.book_shortcut(shortcut);
|
||||
return ok();
|
||||
}
|
||||
}
|
||||
// KeyboardHandler.keyPress: the debug keys see the physical key first.
|
||||
let physical = key;
|
||||
{
|
||||
use horizon_client::debug_keys::KeyAction;
|
||||
let action = arg(2)?;
|
||||
self.press_modifier_key(key, action != "up");
|
||||
if action != "up" {
|
||||
self.physical_keys.insert(key);
|
||||
if self.debug_key(key, KeyAction::Press) {
|
||||
if action == "tap" {
|
||||
self.physical_keys.remove(&key);
|
||||
self.debug_key(key, KeyAction::Release);
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
}
|
||||
if action == "up" {
|
||||
self.physical_keys.remove(&key);
|
||||
self.debug_key(key, KeyAction::Release);
|
||||
}
|
||||
}
|
||||
// Native mods see the key as the keyboard hook does.
|
||||
let action = arg(2)?;
|
||||
// ScreenEvent.KeyPressed.Pre: the open screen hears of the
|
||||
// press before it acts on it, as the window's key event hands
|
||||
// it over. A cancelled press goes no further — not to the
|
||||
// screens, not to `native_key` — and its release still comes.
|
||||
if action != "up" && self.screen_key_pressed(physical, action == "repeat") {
|
||||
if action == "tap" {
|
||||
self.physical_keys.remove(&physical);
|
||||
self.native_key_event(physical, false, false);
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
self.native_key_event(physical, action != "up", action == "repeat");
|
||||
if action == "tap" {
|
||||
self.native_key_event(physical, false, false);
|
||||
}
|
||||
// The merchant's focused offer and the recipe book's last
|
||||
// recipe take Enter and Space, as the window's.
|
||||
if action != "up"
|
||||
&& self.screen == Screen::Inventory
|
||||
&& (self.merchant_key(physical) || self.recipe_book_key(physical))
|
||||
{
|
||||
return ok();
|
||||
}
|
||||
// Text screens consume raw presses; releases follow key binds.
|
||||
let typing = matches!(
|
||||
self.screen,
|
||||
Screen::Chat | Screen::Options | Screen::CommandBlock | Screen::BlockEditor
|
||||
);
|
||||
let key = if typing && arg(2)? != "up" {
|
||||
key
|
||||
} else {
|
||||
match self.settings.keys.translate(key) {
|
||||
Some(Ok(k)) => k,
|
||||
Some(Err(button)) => {
|
||||
let action = arg(2)?;
|
||||
if self.screen == Screen::Playing {
|
||||
if action != "up" {
|
||||
self.play_mouse(button, true)?;
|
||||
}
|
||||
// A repeat keeps the key held, as the window's.
|
||||
if action == "up" || action == "tap" {
|
||||
self.play_mouse(button, false)?;
|
||||
}
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
None => return ok(),
|
||||
}
|
||||
};
|
||||
match arg(2)? {
|
||||
"down" => self.control_key_down(key)?,
|
||||
"up" => {
|
||||
self.keys.remove(&key);
|
||||
self.physical_keys.remove(&key);
|
||||
}
|
||||
// The system's auto-repeat while the key is held.
|
||||
"repeat" => {
|
||||
self.physical_keys.insert(physical);
|
||||
self.mapped_key(key, true, true)?;
|
||||
}
|
||||
"tap" => {
|
||||
self.control_key_down(key)?;
|
||||
self.keys.remove(&key);
|
||||
self.physical_keys.remove(&physical);
|
||||
self.debug_key(physical, horizon_client::debug_keys::KeyAction::Release);
|
||||
}
|
||||
_ => unreachable!("checked above"),
|
||||
}
|
||||
self.control_key(arg(1)?, arg(2)?)?;
|
||||
ok()
|
||||
}
|
||||
"release" => {
|
||||
@@ -1017,6 +901,146 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One `key` line: what the window's keyboard path does for it. A replay
|
||||
/// feeds recorded keys back through here.
|
||||
pub(super) fn control_key(&mut self, name: &str, action: &str) -> Result<()> {
|
||||
let key = key_code(name).ok_or_else(|| format!("unknown key {name}"))?;
|
||||
if !matches!(action, "down" | "up" | "tap" | "repeat") {
|
||||
return Err(format!("key action must be down, up, tap or repeat, not {action}").into());
|
||||
}
|
||||
// ConfirmScreen.keyPressed: Escape answers no, nothing else goes past it.
|
||||
if self.command_prompt.is_some() {
|
||||
if key == KeyCode::Escape && action != "up" {
|
||||
self.answer_command_prompt(false);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// A server's dialog takes the keys, in configuration too.
|
||||
if self.dialog_open() {
|
||||
if action != "up" {
|
||||
self.dialog_key(key, None, None)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
self.require_world()?;
|
||||
// KeyboardHandler.keyPress: the open screen's narration hears it.
|
||||
super::narrator::narrate_key(&mut self.narration, key, action != "up");
|
||||
// The sign editor takes raw presses (Enter, arrows, Backspace, Escape).
|
||||
if self.sign_edit.is_some() {
|
||||
if action != "up" {
|
||||
self.sign_input(Some(key), None);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// A held edit modifier (`key super down`) and A/C/V/X: the
|
||||
// open book's Select All, Copy, Paste or Cut.
|
||||
if self.book.is_some() && action != "up" {
|
||||
let held = |k: [KeyCode; 2]| k.iter().any(|k| self.keys.contains(k));
|
||||
let edit = if cfg!(target_os = "macos") {
|
||||
held([KeyCode::SuperLeft, KeyCode::SuperRight])
|
||||
} else {
|
||||
held([KeyCode::ControlLeft, KeyCode::ControlRight])
|
||||
};
|
||||
let letter = name.chars().next().filter(|_| name.len() == 1);
|
||||
let shift = held([KeyCode::ShiftLeft, KeyCode::ShiftRight]);
|
||||
if let Some(shortcut) =
|
||||
horizon_client::clipboard::edit_shortcut(letter, edit, shift, false)
|
||||
{
|
||||
self.book_shortcut(shortcut);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// KeyboardHandler.keyPress: the debug keys see the physical key first.
|
||||
let physical = key;
|
||||
{
|
||||
use horizon_client::debug_keys::KeyAction;
|
||||
self.press_modifier_key(key, action != "up");
|
||||
if action != "up" {
|
||||
self.physical_keys.insert(key);
|
||||
if self.debug_key(key, KeyAction::Press) {
|
||||
if action == "tap" {
|
||||
self.physical_keys.remove(&key);
|
||||
self.debug_key(key, KeyAction::Release);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if action == "up" {
|
||||
self.physical_keys.remove(&key);
|
||||
self.debug_key(key, KeyAction::Release);
|
||||
}
|
||||
}
|
||||
// Native mods see the key as the keyboard hook does.
|
||||
// ScreenEvent.KeyPressed.Pre: the open screen hears of the
|
||||
// press before it acts on it, as the window's key event hands
|
||||
// it over. A cancelled press goes no further — not to the
|
||||
// screens, not to `native_key` — and its release still comes.
|
||||
if action != "up" && self.screen_key_pressed(physical, action == "repeat") {
|
||||
if action == "tap" {
|
||||
self.physical_keys.remove(&physical);
|
||||
self.native_key_event(physical, false, false);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
self.native_key_event(physical, action != "up", action == "repeat");
|
||||
if action == "tap" {
|
||||
self.native_key_event(physical, false, false);
|
||||
}
|
||||
// The merchant's focused offer and the recipe book's last
|
||||
// recipe take Enter and Space, as the window's.
|
||||
if action != "up"
|
||||
&& self.screen == Screen::Inventory
|
||||
&& (self.merchant_key(physical) || self.recipe_book_key(physical))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
// Text screens consume raw presses; releases follow key binds.
|
||||
let typing = matches!(
|
||||
self.screen,
|
||||
Screen::Chat | Screen::Options | Screen::CommandBlock | Screen::BlockEditor
|
||||
);
|
||||
let key = if typing && action != "up" {
|
||||
key
|
||||
} else {
|
||||
match self.settings.keys.translate(key) {
|
||||
Some(Ok(k)) => k,
|
||||
Some(Err(button)) => {
|
||||
if self.screen == Screen::Playing {
|
||||
if action != "up" {
|
||||
self.play_mouse(button, true)?;
|
||||
}
|
||||
// A repeat keeps the key held, as the window's.
|
||||
if action == "up" || action == "tap" {
|
||||
self.play_mouse(button, false)?;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
None => return Ok(()),
|
||||
}
|
||||
};
|
||||
match action {
|
||||
"down" => self.control_key_down(key)?,
|
||||
"up" => {
|
||||
self.keys.remove(&key);
|
||||
self.physical_keys.remove(&key);
|
||||
}
|
||||
// The system's auto-repeat while the key is held.
|
||||
"repeat" => {
|
||||
self.physical_keys.insert(physical);
|
||||
self.mapped_key(key, true, true)?;
|
||||
}
|
||||
"tap" => {
|
||||
self.control_key_down(key)?;
|
||||
self.keys.remove(&key);
|
||||
self.physical_keys.remove(&physical);
|
||||
self.debug_key(physical, horizon_client::debug_keys::KeyAction::Release);
|
||||
}
|
||||
_ => unreachable!("checked above"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn control_key_down(&mut self, key: KeyCode) -> Result<()> {
|
||||
self.physical_keys.insert(key);
|
||||
// The window's shortcuts come before any screen, here too.
|
||||
@@ -1124,7 +1148,7 @@ impl Game<'_> {
|
||||
json!({ "ok": true, "players": players })
|
||||
}
|
||||
|
||||
fn control_status(&self) -> Value {
|
||||
pub(super) fn control_status(&self) -> Value {
|
||||
let player = self.player.as_ref().map(|p| {
|
||||
json!({
|
||||
"position": [p.position.x, p.position.y, p.position.z],
|
||||
@@ -1294,6 +1318,11 @@ impl Game<'_> {
|
||||
status["border"] = json!({ "center": self.border.center(), "size": self.border.size() });
|
||||
// The scoreboard sidebar the HUD shows.
|
||||
status["sidebar"] = self.sidebar_status();
|
||||
// Who caused the input, and whether a repro recording runs.
|
||||
let mut input = self.input_trace.summary();
|
||||
self.repro
|
||||
.fill_summary(&mut input, self.input_trace.events());
|
||||
status["input"] = input;
|
||||
status
|
||||
}
|
||||
}
|
||||
@@ -1324,6 +1353,10 @@ fn released_modifier(line: &str) -> Option<KeyCode> {
|
||||
|
||||
fn key_code(name: &str) -> Option<KeyCode> {
|
||||
let name = name.to_ascii_lowercase();
|
||||
// The game's own names (`key.keyboard.w`), which a recording writes.
|
||||
if let Some(code) = horizon_client::keybinds::key_code(&name) {
|
||||
return Some(code);
|
||||
}
|
||||
let letters = [
|
||||
KeyCode::KeyA,
|
||||
KeyCode::KeyB,
|
||||
|
||||
@@ -710,6 +710,15 @@ impl Game<'_> {
|
||||
self.toggle_debug_options();
|
||||
true
|
||||
}
|
||||
// Horizon's own: a repro recording for a bug report (game/repro.rs).
|
||||
DebugAction::RecordSession => {
|
||||
self.toggle_recording_key();
|
||||
true
|
||||
}
|
||||
DebugAction::ExportRepro => {
|
||||
self.export_repro_key();
|
||||
true
|
||||
}
|
||||
// Screens and overlays Horizon does not draw yet (the profiler and
|
||||
// its chart, the texture dump): the keys are used up as in vanilla.
|
||||
DebugAction::DumpDynamicTextures
|
||||
|
||||
@@ -64,6 +64,10 @@ impl Events {
|
||||
}
|
||||
self.log.push_back(event);
|
||||
}
|
||||
/// An event from outside the once-a-turn comparison (`user-input`).
|
||||
pub(super) fn note(&mut self, tick: u64, event: Value) {
|
||||
self.push(tick, event);
|
||||
}
|
||||
fn since(&self, seq: u64) -> impl Iterator<Item = &Value> {
|
||||
self.log
|
||||
.iter()
|
||||
@@ -181,7 +185,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn loaded(&self) -> bool {
|
||||
pub(super) fn loaded(&self) -> bool {
|
||||
self.title.is_none()
|
||||
&& self.player.is_some()
|
||||
&& self.streaming.pending() + self.pending_uploads.len() == 0
|
||||
@@ -207,6 +211,7 @@ impl Game<'_> {
|
||||
None => self.ui.chat.recent_lines(new_chat),
|
||||
};
|
||||
for event in diff(before, &now, &lines) {
|
||||
self.repro.note_event(self.ticks, &event);
|
||||
self.events.push(self.ticks, event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
//! Who caused an input event: a person at the keyboard (`user`), an agent on the
|
||||
//! control socket (`agent`), or the game itself (`game`, made-up input no one
|
||||
//! asked for). Agents read this with `input-log` and the `input` section of
|
||||
//! `state` and `status`, so a script can tell that a person took over instead of
|
||||
//! mistaking their keystrokes for its own. A `user-input` event also lands in
|
||||
//! `events` (and so in `wait-for event user-input`) when a person steps in
|
||||
//! while an agent was driving.
|
||||
//!
|
||||
//! The events are also what a repro recording writes to `inputs.jsonl`
|
||||
//! (game/repro.rs).
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{Result, keybinds};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
sync::mpsc::Sender,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use winit::{
|
||||
event::{ElementState, MouseScrollDelta, WindowEvent},
|
||||
keyboard::PhysicalKey,
|
||||
};
|
||||
|
||||
pub(super) const HELP: &[&str] = &[
|
||||
"input-log [--since SEQ] [--limit N] [--origin user|agent|game] recent input and who caused it",
|
||||
];
|
||||
|
||||
/// Input events kept for `input-log`.
|
||||
const KEPT: usize = 512;
|
||||
/// A person's input this soon after an agent command is an intrusion.
|
||||
const INTRUSION_GAP: Duration = Duration::from_secs(5);
|
||||
/// At most one `user-input` event per this long, so holding a key does not flood.
|
||||
const INTRUSION_QUIET: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Who caused an input event.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(super) enum InputOrigin {
|
||||
/// The window's own events: someone at this computer's keyboard, mouse or pad.
|
||||
User,
|
||||
/// The control socket: `horizon-ctl`, an MCP client, or a script.
|
||||
Agent,
|
||||
/// Neither: input the game made up itself (pad menus, UI tasks).
|
||||
Game,
|
||||
}
|
||||
|
||||
impl InputOrigin {
|
||||
pub(super) fn name(self) -> &'static str {
|
||||
match self {
|
||||
InputOrigin::User => "user",
|
||||
InputOrigin::Agent => "agent",
|
||||
InputOrigin::Game => "game",
|
||||
}
|
||||
}
|
||||
fn index(self) -> usize {
|
||||
self as usize
|
||||
}
|
||||
fn parse(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
"user" => Some(InputOrigin::User),
|
||||
"agent" => Some(InputOrigin::Agent),
|
||||
"game" => Some(InputOrigin::Game),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One input event, as `input-log` shows it and the recording writes it.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub(super) struct TraceEvent {
|
||||
pub seq: u64,
|
||||
pub tick: u64,
|
||||
/// Milliseconds since the first event this run; the clock a replay keeps.
|
||||
pub ms: u64,
|
||||
pub origin: InputOrigin,
|
||||
#[serde(flatten)]
|
||||
pub kind: TraceKind,
|
||||
}
|
||||
|
||||
/// What was pressed, clicked or typed. Key and button names are the game's own
|
||||
/// (`key.keyboard.w`, `key.mouse.left`), so a recording can feed them back.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "kebab-case")]
|
||||
pub(super) enum TraceKind {
|
||||
Key {
|
||||
key: String,
|
||||
/// `down`, `up` or `repeat` (the system's auto-repeat).
|
||||
action: String,
|
||||
/// The text the key wrote, when it wrote any: chat and signs need it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
text: Option<String>,
|
||||
},
|
||||
Mouse {
|
||||
button: String,
|
||||
action: String,
|
||||
},
|
||||
Wheel {
|
||||
x: f64,
|
||||
y: f64,
|
||||
/// The window gave lines (a notched wheel) rather than pixels.
|
||||
lines: bool,
|
||||
},
|
||||
/// The pointer's screen position, kept while a screen is open: GUI clicks
|
||||
/// are replayed at it.
|
||||
Cursor {
|
||||
x: f64,
|
||||
y: f64,
|
||||
},
|
||||
/// Relative mouse motion, which turns the view.
|
||||
Motion {
|
||||
x: f64,
|
||||
y: f64,
|
||||
},
|
||||
Pad {
|
||||
button: String,
|
||||
action: String,
|
||||
},
|
||||
/// A control-socket line: `key`, `mouse`, `goto`, anything an agent ran.
|
||||
Command {
|
||||
line: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl TraceKind {
|
||||
pub(super) fn name(&self) -> &'static str {
|
||||
match self {
|
||||
TraceKind::Key { .. } => "key",
|
||||
TraceKind::Mouse { .. } => "mouse",
|
||||
TraceKind::Wheel { .. } => "wheel",
|
||||
TraceKind::Cursor { .. } => "cursor",
|
||||
TraceKind::Motion { .. } => "motion",
|
||||
TraceKind::Pad { .. } => "pad",
|
||||
TraceKind::Command { .. } => "command",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The recent input, the counters and the recording's line writer.
|
||||
#[derive(Default)]
|
||||
pub(super) struct InputTrace {
|
||||
seq: u64,
|
||||
log: VecDeque<TraceEvent>,
|
||||
counts: [u64; 3],
|
||||
last: Option<InputOrigin>,
|
||||
last_user: Option<Instant>,
|
||||
last_agent: Option<Instant>,
|
||||
/// The person's events since the last agent command.
|
||||
user_since_agent: u64,
|
||||
quiet_until: Option<Instant>,
|
||||
/// The first event's wall clock and tick.
|
||||
started: Option<(Instant, u64)>,
|
||||
/// The recording's `inputs.jsonl` writer; `None` when nothing records.
|
||||
writer: Option<Sender<String>>,
|
||||
written: u64,
|
||||
/// A replay feeds events back: they are not new input and are not recorded.
|
||||
suppress: bool,
|
||||
}
|
||||
|
||||
impl InputTrace {
|
||||
/// Record one event; whether it is a person intruding on an agent's run.
|
||||
fn record(&mut self, tick: u64, origin: InputOrigin, kind: TraceKind) -> bool {
|
||||
if self.suppress {
|
||||
return false;
|
||||
}
|
||||
let now = Instant::now();
|
||||
let started = self.started.get_or_insert((now, tick)).0;
|
||||
let ms = now.duration_since(started).as_millis() as u64;
|
||||
|
||||
let intrusion = origin == InputOrigin::User
|
||||
&& self
|
||||
.last_agent
|
||||
.is_some_and(|t| now.duration_since(t) < INTRUSION_GAP)
|
||||
&& self.quiet_until.is_none_or(|t| now >= t);
|
||||
if intrusion {
|
||||
self.quiet_until = Some(now + INTRUSION_QUIET);
|
||||
}
|
||||
|
||||
self.seq += 1;
|
||||
let event = TraceEvent {
|
||||
seq: self.seq,
|
||||
tick,
|
||||
ms,
|
||||
origin,
|
||||
kind,
|
||||
};
|
||||
if self.writer.is_some() {
|
||||
let mut failed = false;
|
||||
match serde_json::to_string(&event) {
|
||||
Ok(line) => {
|
||||
if let Some(writer) = &self.writer {
|
||||
failed = writer.send(line).is_err();
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("Horizon: input trace: {e}"),
|
||||
}
|
||||
if failed {
|
||||
self.writer = None;
|
||||
log::warn!("Horizon: input trace stopped: its writer is gone");
|
||||
} else {
|
||||
self.written += 1;
|
||||
}
|
||||
}
|
||||
if self.log.len() == KEPT {
|
||||
self.log.pop_front();
|
||||
}
|
||||
self.log.push_back(event);
|
||||
self.counts[origin.index()] += 1;
|
||||
self.last = Some(origin);
|
||||
match origin {
|
||||
InputOrigin::User => {
|
||||
self.last_user = Some(now);
|
||||
self.user_since_agent += 1;
|
||||
}
|
||||
InputOrigin::Agent => {
|
||||
self.last_agent = Some(now);
|
||||
self.user_since_agent = 0;
|
||||
}
|
||||
InputOrigin::Game => {}
|
||||
}
|
||||
intrusion
|
||||
}
|
||||
|
||||
/// The `input` section of `state` and `status`.
|
||||
pub(super) fn summary(&self) -> Value {
|
||||
json!({
|
||||
"last_origin": self.last.map(InputOrigin::name),
|
||||
"counts": {
|
||||
"user": self.counts[InputOrigin::User.index()],
|
||||
"agent": self.counts[InputOrigin::Agent.index()],
|
||||
"game": self.counts[InputOrigin::Game.index()],
|
||||
},
|
||||
"user_since_agent": self.user_since_agent,
|
||||
"last_user_age_s": self
|
||||
.last_user
|
||||
.map(|t| (t.elapsed().as_secs_f64() * 10.0).round() / 10.0),
|
||||
"events": self.seq,
|
||||
})
|
||||
}
|
||||
|
||||
/// Start writing the events to a recording.
|
||||
pub(super) fn start_file(&mut self, writer: Sender<String>) {
|
||||
self.writer = Some(writer);
|
||||
self.written = 0;
|
||||
}
|
||||
/// Stop writing; how many events the recording received.
|
||||
pub(super) fn stop_file(&mut self) -> u64 {
|
||||
self.writer = None;
|
||||
std::mem::take(&mut self.written)
|
||||
}
|
||||
/// How many events this run has seen.
|
||||
pub(super) fn events(&self) -> u64 {
|
||||
self.seq
|
||||
}
|
||||
/// A replay's own events are not new input.
|
||||
pub(super) fn set_suppress(&mut self, suppress: bool) {
|
||||
self.suppress = suppress;
|
||||
}
|
||||
|
||||
/// `input-log`.
|
||||
pub(super) fn command(&self, words: &[&str]) -> Result<Option<Value>> {
|
||||
if words[0] != "input-log" {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut since = 0;
|
||||
let mut limit = usize::MAX;
|
||||
let mut origin = None;
|
||||
let mut i = 1;
|
||||
while i < words.len() {
|
||||
let value = words
|
||||
.get(i + 1)
|
||||
.ok_or_else(|| format!("{} takes a value", words[i]))?;
|
||||
match words[i] {
|
||||
"--since" => since = value.parse().map_err(|_| "--since takes a number")?,
|
||||
"--limit" => limit = value.parse().map_err(|_| "--limit takes a number")?,
|
||||
"--origin" => {
|
||||
origin = Some(
|
||||
InputOrigin::parse(value).ok_or("--origin takes user, agent or game")?,
|
||||
)
|
||||
}
|
||||
other => return Err(format!("input-log: unknown argument {other}").into()),
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
let mut events: Vec<&TraceEvent> = self
|
||||
.log
|
||||
.iter()
|
||||
.filter(|e| e.seq > since && origin.is_none_or(|o| e.origin == o))
|
||||
.collect();
|
||||
if events.len() > limit {
|
||||
events.drain(..events.len() - limit);
|
||||
}
|
||||
Ok(Some(json!({
|
||||
"events": events,
|
||||
"last": self.seq,
|
||||
"input": self.summary(),
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Record an input event and, when a person steps into an agent's run, tell
|
||||
/// every watcher once.
|
||||
pub(super) fn note_input(&mut self, origin: InputOrigin, kind: TraceKind) {
|
||||
let kind_name = kind.name();
|
||||
if self.input_trace.record(self.ticks, origin, kind) {
|
||||
let event = json!({ "event": "user-input", "input": kind_name });
|
||||
self.repro.note_event(self.ticks, &event);
|
||||
self.events.note(self.ticks, event);
|
||||
log::info!(
|
||||
"Horizon: {kind_name} input from a person while an agent was driving \
|
||||
(input-log has the run)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the window events that are input. Agent-made window events are left
|
||||
/// out: the control-socket line that caused them is recorded instead.
|
||||
pub(super) fn trace_window_event(&mut self, origin: InputOrigin, event: &WindowEvent) {
|
||||
if origin == InputOrigin::Agent {
|
||||
return;
|
||||
}
|
||||
match event {
|
||||
WindowEvent::KeyboardInput {
|
||||
event,
|
||||
is_synthetic,
|
||||
..
|
||||
} => {
|
||||
let PhysicalKey::Code(code) = event.physical_key else {
|
||||
return;
|
||||
};
|
||||
let Some(name) = keybinds::key_name(code) else {
|
||||
return;
|
||||
};
|
||||
let action = match (event.state, event.repeat) {
|
||||
(ElementState::Released, _) => "up",
|
||||
(_, true) => "repeat",
|
||||
(ElementState::Pressed, false) => "down",
|
||||
};
|
||||
let text = event
|
||||
.text
|
||||
.as_ref()
|
||||
.map(ToString::to_string)
|
||||
.filter(|t| !t.is_empty());
|
||||
// The platform made this one up (an IME's dead key): not a person.
|
||||
let origin = if *is_synthetic {
|
||||
InputOrigin::Game
|
||||
} else {
|
||||
origin
|
||||
};
|
||||
self.note_input(
|
||||
origin,
|
||||
TraceKind::Key {
|
||||
key: name.to_owned(),
|
||||
action: action.to_owned(),
|
||||
text,
|
||||
},
|
||||
);
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } => self.note_input(
|
||||
origin,
|
||||
TraceKind::Mouse {
|
||||
button: keybinds::mouse_name(*button),
|
||||
action: match state {
|
||||
ElementState::Pressed => "down".to_owned(),
|
||||
ElementState::Released => "up".to_owned(),
|
||||
},
|
||||
},
|
||||
),
|
||||
WindowEvent::MouseWheel { delta, .. } => {
|
||||
let (x, y, lines) = match delta {
|
||||
MouseScrollDelta::LineDelta(x, y) => (f64::from(*x), f64::from(*y), true),
|
||||
MouseScrollDelta::PixelDelta(p) => (p.x, p.y, false),
|
||||
};
|
||||
self.note_input(origin, TraceKind::Wheel { x, y, lines });
|
||||
}
|
||||
// GUI clicks land where the pointer is; in the world the pointer is
|
||||
// grabbed and turns the view through `device_event` instead.
|
||||
WindowEvent::CursorMoved { position, .. } if self.screen != Screen::Playing => {
|
||||
self.note_input(
|
||||
origin,
|
||||
TraceKind::Cursor {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key(name: &str) -> TraceKind {
|
||||
TraceKind::Key {
|
||||
key: name.to_owned(),
|
||||
action: "down".to_owned(),
|
||||
text: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origins_round_trip() {
|
||||
for origin in [InputOrigin::User, InputOrigin::Agent, InputOrigin::Game] {
|
||||
assert_eq!(InputOrigin::parse(origin.name()), Some(origin));
|
||||
}
|
||||
assert_eq!(InputOrigin::parse("nobody"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ring_keeps_the_newest_and_counts_every_origin() {
|
||||
let mut trace = InputTrace::default();
|
||||
for i in 0..=(KEPT as u64 + 10) {
|
||||
trace.record(i, InputOrigin::User, key("key.keyboard.w"));
|
||||
}
|
||||
assert_eq!(trace.log.len(), KEPT);
|
||||
assert_eq!(trace.seq, KEPT as u64 + 11);
|
||||
assert_eq!(trace.log.front().unwrap().seq, 12);
|
||||
assert_eq!(trace.counts[InputOrigin::User.index()], KEPT as u64 + 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_person_after_an_agent_is_an_intrusion_once() {
|
||||
let mut trace = InputTrace::default();
|
||||
trace.record(1, InputOrigin::Agent, key("key.keyboard.w"));
|
||||
assert!(trace.record(2, InputOrigin::User, key("key.keyboard.a")));
|
||||
// The quiet period keeps a held key from flooding the event log.
|
||||
assert!(!trace.record(3, InputOrigin::User, key("key.keyboard.a")));
|
||||
assert_eq!(trace.user_since_agent, 2);
|
||||
// An agent command resets the count.
|
||||
trace.record(4, InputOrigin::Agent, key("key.keyboard.w"));
|
||||
assert_eq!(trace.user_since_agent, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_intrusion_without_an_agent() {
|
||||
let mut trace = InputTrace::default();
|
||||
assert!(!trace.record(1, InputOrigin::User, key("key.keyboard.w")));
|
||||
// The game's own input is not a person either.
|
||||
trace.record(2, InputOrigin::Agent, key("key"));
|
||||
assert!(!trace.record(3, InputOrigin::Game, key("key.keyboard.w")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_log_filters_since_origin_and_limit() {
|
||||
let mut trace = InputTrace::default();
|
||||
for (i, origin) in [
|
||||
InputOrigin::Agent,
|
||||
InputOrigin::User,
|
||||
InputOrigin::Game,
|
||||
InputOrigin::User,
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
trace.record(i as u64, origin, key("key.keyboard.w"));
|
||||
}
|
||||
let all = trace.command(&["input-log"]).unwrap().unwrap();
|
||||
assert_eq!(all["events"].as_array().unwrap().len(), 4);
|
||||
assert_eq!(all["last"], 4);
|
||||
|
||||
let users = trace
|
||||
.command(&["input-log", "--origin", "user"])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(users["events"].as_array().unwrap().len(), 2);
|
||||
assert!(
|
||||
users["events"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|e| e["origin"] == "user")
|
||||
);
|
||||
|
||||
let newest = trace
|
||||
.command(&["input-log", "--since", "2", "--limit", "1"])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let events = newest["events"].as_array().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0]["seq"], 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_recording_writer_gets_one_json_line_per_event() {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut trace = InputTrace::default();
|
||||
trace.start_file(tx);
|
||||
trace.record(7, InputOrigin::User, key("key.keyboard.w"));
|
||||
trace.record(8, InputOrigin::Agent, key("key.keyboard.s"));
|
||||
assert_eq!(trace.stop_file(), 2);
|
||||
let lines: Vec<String> = rx.try_iter().collect();
|
||||
assert_eq!(lines.len(), 2);
|
||||
let first: Value = serde_json::from_str(&lines[0]).unwrap();
|
||||
assert_eq!(first["tick"], 7);
|
||||
assert_eq!(first["origin"], "user");
|
||||
assert_eq!(first["kind"], "key");
|
||||
assert_eq!(first["key"], "key.keyboard.w");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_suppressed_replay_records_nothing() {
|
||||
let mut trace = InputTrace::default();
|
||||
trace.set_suppress(true);
|
||||
assert!(!trace.record(1, InputOrigin::User, key("key.keyboard.w")));
|
||||
assert_eq!(trace.seq, 0);
|
||||
assert!(trace.summary()["last_origin"].is_null());
|
||||
}
|
||||
}
|
||||
@@ -331,6 +331,9 @@ impl Game<'_> {
|
||||
let text = self.agent_details();
|
||||
self.clipboard.set(&text);
|
||||
}
|
||||
// The AI Agent page's repro buttons (game/repro.rs).
|
||||
Event::ToggleRecording => self.toggle_recording_key(),
|
||||
Event::ExportRepro => self.export_repro_key(),
|
||||
Event::Close if self.ui.options.resource_error.is_some() => {}
|
||||
Event::Close => {
|
||||
self.settings.save(&Self::options_path());
|
||||
|
||||
@@ -45,6 +45,9 @@ pub(super) struct Pads {
|
||||
late_releases: Vec<Button>,
|
||||
/// The merged state of the last poll.
|
||||
pub(super) state: PadState,
|
||||
/// The hardware's buttons at the last poll, for `input-log`'s `pad`
|
||||
/// events: the merged state hides which source moved.
|
||||
last_hardware: PadState,
|
||||
/// The pad drove the game last (the mouse and keyboard hand control back).
|
||||
pub(super) in_use: bool,
|
||||
pub(super) style: GlyphStyle,
|
||||
@@ -125,6 +128,29 @@ mod hardware {
|
||||
}
|
||||
|
||||
impl Pads {
|
||||
/// The digital buttons the person's pad changed since the last poll.
|
||||
pub(super) fn hardware_edges(&mut self, hardware: PadState) -> Vec<(Button, bool)> {
|
||||
let edges = Button::ALL
|
||||
.into_iter()
|
||||
.filter_map(|button| {
|
||||
let was = self.last_hardware.held(button);
|
||||
let now = hardware.held(button);
|
||||
(was != now).then_some((button, now))
|
||||
})
|
||||
.collect();
|
||||
self.last_hardware = hardware;
|
||||
edges
|
||||
}
|
||||
|
||||
/// A replay's pad button, in place of `pad`'s line.
|
||||
pub(super) fn inject_button(&mut self, button: Button, down: bool) {
|
||||
self.injected.set(button, down);
|
||||
}
|
||||
/// A replay ended: let go of everything it held.
|
||||
pub(super) fn clear_injected(&mut self) {
|
||||
self.injected = PadState::default();
|
||||
}
|
||||
|
||||
/// Include releases delayed until the next simulation tick.
|
||||
pub(super) fn holds_action(&self, settings: &gamepad::PadSettings, action: &str) -> bool {
|
||||
settings.binding(action).is_some_and(|button| {
|
||||
@@ -159,6 +185,18 @@ impl Game<'_> {
|
||||
Some(state) => state,
|
||||
None => self.pad.read(),
|
||||
};
|
||||
// A person's pad, for `input-log` and the repro recording; the virtual
|
||||
// pad is an agent's `pad` line and is recorded as that.
|
||||
let hardware_edges = self.pad.hardware_edges(hardware);
|
||||
for (button, down) in hardware_edges {
|
||||
self.note_input(
|
||||
super::input_trace::InputOrigin::User,
|
||||
super::input_trace::TraceKind::Pad {
|
||||
button: button.name().to_owned(),
|
||||
action: if down { "down" } else { "up" }.to_owned(),
|
||||
},
|
||||
);
|
||||
}
|
||||
self.ui.options.pad_style = self.settings.controller.glyphs.resolve(self.pad.style);
|
||||
let state = hardware.merge(&self.pad.injected);
|
||||
self.pad.virtual_input = self.pad.injected != PadState::default();
|
||||
|
||||
@@ -328,7 +328,7 @@ impl Game<'_> {
|
||||
event: WindowEvent,
|
||||
) {
|
||||
let was = std::mem::replace(&mut self.pad.menu.synthetic, true);
|
||||
self.handle_window_event(event_loop, id, event);
|
||||
self.handle_window_event(event_loop, id, event, super::input_trace::InputOrigin::Game);
|
||||
self.pad.menu.synthetic = was;
|
||||
}
|
||||
|
||||
|
||||
@@ -5489,6 +5489,7 @@ impl Game<'_> {
|
||||
|
||||
fn tick_remote_level(&mut self) -> Result<()> {
|
||||
self.ticks += 1;
|
||||
self.repro_tick();
|
||||
// Hud.tick ends with ChatComponent.tick.
|
||||
self.ui.chat.tick(&self.gui.atlas, self.ticks);
|
||||
self.tick_sign_editor();
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -585,7 +585,11 @@ impl Loading {
|
||||
Event::NativeSettings(_) | Event::NativeButton(..) => {}
|
||||
// The startup menu runs before the game opens a control socket,
|
||||
// and before its sound engine (no clicks, no volume previews).
|
||||
Event::ButtonClick | Event::CopyAgentDetails | Event::Volume(..) => {}
|
||||
Event::ButtonClick
|
||||
| Event::CopyAgentDetails
|
||||
| Event::ToggleRecording
|
||||
| Event::ExportRepro
|
||||
| Event::Volume(..) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
use crate::save::{read_dat, write_dat};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::{
|
||||
fs, io,
|
||||
fs,
|
||||
io::{self, Seek, Write},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
@@ -150,11 +151,24 @@ pub fn backup(saves: &Path, folder: &str) -> crate::Result<u64> {
|
||||
result
|
||||
}
|
||||
|
||||
fn write_tree(
|
||||
zip: &mut zip::ZipWriter<fs::File>,
|
||||
fn write_tree<W: Write + Seek>(
|
||||
zip: &mut zip::ZipWriter<W>,
|
||||
root: &Path,
|
||||
dir: &Path,
|
||||
folder: &str,
|
||||
) -> crate::Result<()> {
|
||||
write_tree_skipping(zip, root, dir, folder, &["session.lock"])
|
||||
}
|
||||
|
||||
/// Every regular file under `root` (named `<prefix>/<relative>` in the zip),
|
||||
/// except the names in `skip`. Symlinks are refused. The repro bundle zips a
|
||||
/// whole recording with it.
|
||||
pub fn write_tree_skipping<W: Write + Seek>(
|
||||
zip: &mut zip::ZipWriter<W>,
|
||||
root: &Path,
|
||||
dir: &Path,
|
||||
prefix: &str,
|
||||
skip: &[&str],
|
||||
) -> crate::Result<()> {
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
@@ -164,10 +178,10 @@ fn write_tree(
|
||||
return Err(format!("Cannot back up symbolic link: {}", path.display()).into());
|
||||
}
|
||||
if kind.is_dir() {
|
||||
write_tree(zip, root, &path, folder)?;
|
||||
} else if kind.is_file() && entry.file_name() != "session.lock" {
|
||||
write_tree_skipping(zip, root, &path, prefix, skip)?;
|
||||
} else if kind.is_file() && !skip.iter().any(|name| entry.file_name() == *name) {
|
||||
let relative = path.strip_prefix(root)?;
|
||||
let name = format!("{folder}/{}", relative.to_string_lossy().replace('\\', "/"));
|
||||
let name = format!("{prefix}/{}", relative.to_string_lossy().replace('\\', "/"));
|
||||
zip.start_file(
|
||||
name,
|
||||
zip::write::SimpleFileOptions::default()
|
||||
|
||||
+49
-2
@@ -272,6 +272,49 @@ turn the switch off.
|
||||
dimensions and worlds (the newest 256). Take `last` before an action, and wait with `--since`,
|
||||
so the action's event can't slip in before the wait.
|
||||
|
||||
### Input provenance
|
||||
|
||||
Every input event carries who caused it: `user` (this computer's keyboard, mouse or pad),
|
||||
`agent` (the control socket: `horizon-ctl`, an MCP client) or `game` (input the game made up
|
||||
itself, such as a pad menu's click). This is how an agent tells its own actions from a person
|
||||
who took over mid-test.
|
||||
|
||||
- `state` and `status` have an `input` section: `last_origin`, per-origin `counts`,
|
||||
`user_since_agent` and `last_user_age_s`.
|
||||
- `input-log [--since SEQ] [--limit N] [--origin ORIGIN]` lists the newest 512 input events:
|
||||
`seq`, `tick`, `origin`, and the key/button/motion/pad event or control-socket line.
|
||||
- When a person's input arrives while an agent was driving, the game logs one line and pushes a
|
||||
`user-input` event: `wait-for event user-input --timeout 600` makes a script notice at once
|
||||
instead of blaming the person's keystroke on itself.
|
||||
- Things that are not input (frames, resizes) are not events; typed text is never logged above
|
||||
debug level, though the recording keeps it so chat can be replayed.
|
||||
|
||||
### Repro recordings
|
||||
|
||||
A recording is one folder, a bundle is that folder plus the logs in one zip:
|
||||
|
||||
- `record-start [NAME] [--no-world]` saves the world, copies it into
|
||||
`recordings/<stamp>-NAME/world/` (single player) and starts writing:
|
||||
`inputs.jsonl` (the provenance events above, with ticks), `state.jsonl` (the full
|
||||
`state_value()` every second, entities every ten), `events.jsonl` and `session.json`.
|
||||
A `--no-world` recording keeps only the input and state, for a server session.
|
||||
- `record-stop [--export]` closes it; `repro-export [NAME] [PATH]` writes
|
||||
`repro/<stamp>-NAME.zip` with the recording, `logs/latest.log`, the newest crash report, a
|
||||
fresh `status.json` and a README. Nothing is uploaded automatically: the player attaches the
|
||||
zip to the bug report.
|
||||
- A recording stops itself at 1,000,000 input events or two hours, and is finished and
|
||||
bundled when the game exits.
|
||||
- A player without `horizon-ctl` uses the debug keys: **F3+R** starts a recording and F3+R again
|
||||
stops it and writes the bundle (the chat shows the path); **F3+E** writes the bundle of the
|
||||
newest recording. The AI Agent page has the same two buttons and a live recording line.
|
||||
- `record-replay NAME [--stop] [--in-place] [--timeout S]` is the best effort replay: it copies
|
||||
the recording's world into `saves/`, opens it, and feeds the recorded keys, mouse, wheel, look,
|
||||
pad buttons and control-socket lines back at their original ticks. It skips its own
|
||||
`record-*`, `repro-export`, `input-log`, `launch`, `mcp`, `bench` and `quit` lines. It is not
|
||||
frame-perfect and only makes sense on the same build and machine; `state.jsonl` is the oracle
|
||||
for where the re-run should have ended. A server recording replays only `--in-place`, into the
|
||||
same world.
|
||||
|
||||
## Tests
|
||||
|
||||
- **Unit tests:**
|
||||
@@ -279,13 +322,17 @@ turn the switch off.
|
||||
network annotations;
|
||||
- the launcher: generated tools, `confirm`, the reference, the agent configs;
|
||||
- `horizon_core::no_ai`;
|
||||
- `options_ui`: the AI Agent page, and that No AI hides it.
|
||||
- `options_ui`: the AI Agent page, and that No AI hides it;
|
||||
- `input_trace`: origins, the ring, the intrusion rule, `input-log` and the recording writer;
|
||||
- `repro`: the format's round trip, the folder slug and the world copy.
|
||||
- **`tools/agent_control_e2e.py`** drives a real game, offscreen on macOS and on a virtual screen
|
||||
on Linux, through the CLI and MCP:
|
||||
- `survival`, `ui`, `options`, `events`, `consume`, `pick`, `suggestions`;
|
||||
- `manage`: worlds, servers, effects, books, LAN, the options pages and `confirm`;
|
||||
- `no-ai`: every surface refuses;
|
||||
- `setting`: Agent Control on opens the endpoint, the AI Agent page, Copy, and off closes it.
|
||||
- `setting`: Agent Control on opens the endpoint, the AI Agent page, Copy, and off closes it;
|
||||
- `repro`: origins in `state`/`input-log`, record-start/stop, the bundle's entries, and a
|
||||
replay ending near the recorded state.
|
||||
- **`tools/launcher_mcp_e2e.py`** drives `horizon-launcher mcp`: the generated tools, `play` and
|
||||
the `game_*` tools, `confirm`, `agent-config` and `--test`, and No AI.
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launch
|
||||
| Command | MCP tool | What it does | Arguments |
|
||||
| --- | --- | --- | --- |
|
||||
| `events [--since SEQ]` | `events` | What happened since SEQ, oldest first: screens, chat, deaths, respawns, items gained or lost, dimensions, worlds | `--since` (integer): only events after this seq |
|
||||
| `input-log [--since SEQ] [--limit N] [--origin user\|agent\|game]` | `input_log` | Recent input and who caused it: a person, an agent or the game | `--since` (integer): only events after this seq<br>`--limit` (integer): newest N events<br>`--origin` (string): user, agent or game |
|
||||
| `state` | `state` | Player, world, screen and crosshair target in one reply | none |
|
||||
| `looking-at` | `looking_at` | The block or entity under the crosshair | none |
|
||||
| `entities [RADIUS] [TYPE]` | `entities` | Mobs, dropped items and experience orbs near the player, nearest first | `radius` (number): search radius in blocks (default 32)<br>`type` (string): entity type filter |
|
||||
@@ -140,3 +141,9 @@ Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launch
|
||||
| `stats [reset]` | `stats` | Frame-time summary since the last reset | `reset` (boolean): start counting again |
|
||||
| `gpu-passes [on\|off]` | CLI only | Time Horizon's own render passes on the GPU, shown in F3 and in stats and bench | `mode` (on, off): on or off; without it, only reports |
|
||||
| `fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]` | CLI only | Fly the camera along a line, then reply with the frame summary | none |
|
||||
| `record-start [NAME] [--no-world]` | `record_start` | Record input, state and the world for a bug repro | `name` (string): folder name after the timestamp (default session)<br>`--no-world` (boolean): do not copy the world (a server session) |
|
||||
| `record-stop [--export]` | `record_stop` | Stop the recording (and write the repro zip) | `--export` (boolean): write the repro bundle too |
|
||||
| `record-status` | `record_status` | The running recording and replay, if any | none |
|
||||
| `record-list` | `record_list` | The recordings and repro bundles on disk | none |
|
||||
| `record-replay NAME [--stop] [--in-place] [--timeout S]` | `record_replay` | Replay a recording, best effort | `name` (string): recording from record-list<br>`--stop` (boolean): stop the running replay<br>`--in-place` (boolean): feed the open world instead of opening the copy<br>`--timeout` (number): give up after this many seconds (default 300) |
|
||||
| `repro-export [NAME] [PATH] [--no-world]` | `repro_export` | Zip a recording with the logs, crash report and status | `name` (string): recording from record-list (default the newest)<br>`path` (string): where to write the zip<br>`--no-world` (boolean): leave the world out |
|
||||
@@ -20,6 +20,10 @@ type WindowId = i32;
|
||||
struct ActiveEventLoop;
|
||||
struct Window;
|
||||
impl Window { fn id(&self) -> i32 { 7 } }
|
||||
mod input_trace {
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum InputOrigin { User, Agent, Game }
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct KeyEvent { physical_key: i32, logical_key: &'static str, location: i32,
|
||||
text: &'static str, pressed: bool, repeat: bool }
|
||||
@@ -28,7 +32,8 @@ fn glfw_modifiers(bits: u32) -> u32 { bits }
|
||||
struct Game { window: Option<Window>, modifiers: u32, early: bool,
|
||||
calls: Vec<&'static str>, observed: Vec<(KeyEvent, u32)> }
|
||||
impl Game {
|
||||
fn handle_window_event(&mut self, _: &ActiveEventLoop, _: WindowId, mut event: WindowEvent) {
|
||||
fn handle_window_event(&mut self, _: &ActiveEventLoop, _: WindowId, mut event: WindowEvent,
|
||||
_: input_trace::InputOrigin) {
|
||||
self.calls.push("handle"); self.modifiers = 0;
|
||||
if let WindowEvent::KeyboardInput { event, .. } = &mut event { event.physical_key = 99; event.text = ""; }
|
||||
if self.early { return; }
|
||||
|
||||
@@ -57,6 +57,7 @@ POSITIONAL = {
|
||||
'click_button': ['label'], 'type': ['text'], 'wait': ['ticks'], 'wait_ready': ['seconds'],
|
||||
'command': ['command'], 'look_at_block': ['x', 'y', 'z'], 'click': ['id'], 'set': ['id', 'value'],
|
||||
'give': ['item', 'count'], 'options_get': ['keys'], 'options_set': ['key', 'value'],
|
||||
'record_start': ['name'], 'record_replay': ['name'], 'repro_export': ['name', 'path'],
|
||||
'wait_for': ['condition', 'value'], 'open': ['world', 'seed', 'mode'], 'select': ['slot'],
|
||||
'summon': ['kind', 'x', 'y', 'z'], 'interact_entity': ['id'], 'effect': ['id', 'seconds', 'amplifier'],
|
||||
'xp': ['levels'], 'options': ['page'], 'lan': ['action'], 'world_backup': ['folder'],
|
||||
@@ -447,6 +448,93 @@ def events_scenario(d, out):
|
||||
return {'events': len(events)}
|
||||
|
||||
|
||||
def repro_scenario(d, out):
|
||||
# Input provenance and the repro recording: who caused what, the recording on disk,
|
||||
# the bundle a bug report carries, and a replay ending where the recording did.
|
||||
import zipfile
|
||||
|
||||
d('click', id='singleplayer')
|
||||
if 'game' not in widgets(d):
|
||||
d('click', id='create-new-world')
|
||||
d('type', field='name', replace=True, text='Repro')
|
||||
d('click', id='create-new-world')
|
||||
d('wait_for', condition='loaded', timeout=400)
|
||||
|
||||
# Everything the control socket does is the agent's input.
|
||||
d('give', item='stone', count=1)
|
||||
# Stand still on the ground first: the world copy's player data and the
|
||||
# recording start from the same place, so the replay can follow.
|
||||
for _ in range(200):
|
||||
state = d('state')
|
||||
player = state['player']
|
||||
# Standing still: gravity keeps a small downward velocity on the ground.
|
||||
if (player['on_ground'] and abs(player['velocity'][0]) < 0.01
|
||||
and abs(player['velocity'][2]) < 0.01):
|
||||
break
|
||||
d('wait', ticks=2)
|
||||
else:
|
||||
raise AssertionError(f'the player never settled: {player}')
|
||||
provenance = state['input']
|
||||
assert provenance['last_origin'] == 'agent', provenance
|
||||
assert provenance['counts']['agent'] >= 1, provenance
|
||||
|
||||
name = 'e2e-repro'
|
||||
started = d('record_start', name=name)
|
||||
record_dir = Path(started['dir'])
|
||||
assert started['ok'] and record_dir.is_dir(), started
|
||||
|
||||
# Walk a moment, so the recording has key events and a state trail.
|
||||
d('key', key='w', action='down')
|
||||
d('wait', ticks=25)
|
||||
d('key', key='w', action='up')
|
||||
d('wait', ticks=5)
|
||||
entries = d('input_log', origin='agent', limit=20)['events']
|
||||
assert entries and all(e['origin'] == 'agent' for e in entries), entries
|
||||
assert any(e['kind'] == 'command' for e in entries), entries
|
||||
status = d('record_status')['recording']
|
||||
assert status and status['inputs'] > 0, status
|
||||
stopped = d('record_stop')
|
||||
assert stopped['inputs'] > 0 and stopped['state'] > 0, stopped
|
||||
|
||||
# On disk: one JSON line per input and the state keyframes.
|
||||
inputs = [json.loads(line) for line in (record_dir / 'inputs.jsonl').read_text().splitlines() if line.strip()]
|
||||
assert inputs and inputs[0]['origin'] == 'agent' and 'tick' in inputs[0], inputs[:2]
|
||||
states = [json.loads(line) for line in (record_dir / 'state.jsonl').read_text().splitlines() if line.strip()]
|
||||
keyframes = [s for s in states if s['kind'] == 'state']
|
||||
assert keyframes, states[:2]
|
||||
assert (record_dir / 'events.jsonl').is_file() and (record_dir / 'session.json').is_file()
|
||||
assert (record_dir / 'world' / 'level.dat').is_file(), 'the world copy is missing'
|
||||
|
||||
# The bundle: the recording, the logs and a status in one zip.
|
||||
bundle = d('repro_export', name=name)
|
||||
zip_path = Path(bundle['path'])
|
||||
assert zip_path.is_file() and zip_path.stat().st_size > 0, bundle
|
||||
with zipfile.ZipFile(zip_path) as archive:
|
||||
names = archive.namelist()
|
||||
prefix = record_dir.name
|
||||
for wanted in ('session.json', 'inputs.jsonl', 'state.jsonl', 'events.jsonl', 'README.txt'):
|
||||
assert f'{prefix}/{wanted}' in names, (wanted, names)
|
||||
assert 'status.json' in names and 'logs/latest.log' in names, names
|
||||
assert any(name.endswith('/world/level.dat') for name in names), names
|
||||
|
||||
# The replay: the recording's world opens and its input runs again.
|
||||
replayed = d('record_replay', name=name, timeout=120)
|
||||
assert replayed['ok'] and replayed['events'] > 0, replayed
|
||||
for _ in range(240):
|
||||
state = d('state')
|
||||
if state['input'].get('replay') is None:
|
||||
break
|
||||
time.sleep(0.25)
|
||||
else:
|
||||
raise AssertionError('the replay did not finish')
|
||||
ended = state['player']['position']
|
||||
want = keyframes[-1]['state']['player']['position']
|
||||
distance = sum((a - b) ** 2 for a, b in zip(ended, want)) ** 0.5
|
||||
assert distance < 3.0, f'the replay ended at {ended}, the recording at {want} ({distance:.2f})'
|
||||
return {'inputs': len(inputs), 'keyframes': len(keyframes), 'bundle': zip_path.name,
|
||||
'replay_distance': round(distance, 2)}
|
||||
|
||||
|
||||
def consume_scenario(d, out):
|
||||
# Consumables finish as vanilla's Consumable.onConsume: always-edible food at full hunger with its
|
||||
# effects, milk and honey (no food or drink sounds) with their effects and use remainders, and
|
||||
@@ -793,7 +881,8 @@ def pause_save_scenario(d, out):
|
||||
|
||||
SCENARIOS = {'survival': scenario, 'ui': ui_scenario, 'options': options_scenario, 'events': events_scenario,
|
||||
'consume': consume_scenario, 'use-screen': use_screen_scenario, 'pick': pick_scenario,
|
||||
'suggestions': suggestions_scenario, 'manage': manage_scenario, 'pause-save': pause_save_scenario}
|
||||
'suggestions': suggestions_scenario, 'manage': manage_scenario, 'pause-save': pause_save_scenario,
|
||||
'repro': repro_scenario}
|
||||
|
||||
|
||||
REFUSAL = "AI features are off: turn off No AI in the launcher's settings"
|
||||
|
||||
Reference in new issue
Block a user