Files
wds/src/session.rs
T
irrlicht 9db3929750 Collider via collide-Property + Quickhull, OBJ-Pfad entfernt
col_*-Namenspräfix raus: Rollen steuern nur noch Custom Properties
(collide=1 sichtbar+Collider, "proxy" unsichtbar, signal wie gehabt).
Collider sind die konvexe Hülle der Vertices — konkave Meshes erlaubt.
glb ist der einzige Modell-Pfad; suzanne.obj → suzanne.glb.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 20:37:18 +02:00

590 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::engine::collision::CollisionWorld;
use crate::engine::game::{Action, Game, ModeTarget};
use crate::engine::ink::StoryState;
use crate::engine::map::{self, Map};
use crate::engine::player::{self, Player};
use crate::engine::trigger::Triggers;
use crate::engine::{kv, model, signals, story_ctrl};
/// Maus-Empfindlichkeit der Sicht (Radiant/Pixel). Geteilt zwischen der
/// First-Person-Sim hier und der Debug-Flycam im Fenster-Frontend, damit sich
/// beide gleich anfühlen (das Frontend referenziert diese Konstante).
pub const LOOK_SENS: f32 = 0.0025;
/// Fester Simulationsschritt (s) = 60 Hz. Die Physik läuft ausschließlich in
/// diesem Takt — unabhängig von der Render-Framerate — damit Step-up und
/// Ground-Snap sich exakt so verhalten wie in den (bei 1/60 geschriebenen)
/// Player-Tests, statt bei 144 Hz oder Rucklern durch andere dt-Werte zu driften.
const FIXED_DT: f32 = 1.0 / 60.0;
/// Deckel für den Sim-Akkumulator (s): bei einem schweren Ruckler holen wir
/// höchstens so viel Zeit nach, sonst spiralt die Sim bei einem Hänger davon
/// („spiral of death"). ~0.25 s = maximal 15 Nachhol-Schritte pro Frame.
const MAX_ACCUM: f32 = 0.25;
/// Reichweite (units) des Point-and-Click-Strahls auf `signal`-Klick-Ziele.
pub const INTERACT_REACH: f32 = 3.0;
pub enum Mode {
FirstPerson,
Free,
Menu,
Dialog(Dialog),
}
pub struct Dialog {
pub text: String,
pub choices: Vec<String>,
}
pub struct Session {
pub game: Game,
pub mode: Mode,
pub player: Player,
pub collision: CollisionWorld,
/// Klick-Ziele und Betretens-Zonen aus den Blender-Modellen
/// (`signal`-Property, siehe engine::trigger).
triggers: Triggers,
signals_path: String,
/// Aufgelaufene, noch nicht simulierte Zeit (s) für den Fixed-Timestep.
sim_accum: f32,
/// Über Frames gepufferte Sprung-Flanke: eine Sprungtaste, die in einen
/// Frame ohne Sim-Schritt fällt (hohe FPS), ginge sonst verloren. Wird beim
/// nächsten Sub-Schritt konsumiert.
pending_jump: bool,
}
/// Kontinuierliche Eingabe für einen Simulationsschritt — vom Frontend pro
/// Frame gefüllt. Gegenstück zu [`ExecResult`]/`exec` (diskret). `look_*`
/// sind rohe Maus-Pixel, `fwd`/`right` Tastenachsen in [-1, 1].
#[derive(Default)]
pub struct FrameInput {
pub fwd: f32,
pub right: f32,
pub jump: bool,
pub look_dx: f32,
pub look_dy: f32,
}
/// Ergebnis einer Eingabezeile: Ausgabezeilen plus, ob das Frontend
/// beenden soll (`quit`/`exit`).
pub struct ExecResult {
pub output: Vec<String>,
pub quit: bool,
}
impl ExecResult {
fn lines(output: Vec<String>) -> Self { Self { output, quit: false } }
fn empty() -> Self { Self { output: Vec::new(), quit: false } }
}
impl Session {
pub fn new(signals_path: String) -> Self {
let game = Game::new(signals::load_signals(&signals_path));
Self {
game,
mode: Mode::Menu,
// Default-Welt, bis `load_world` eine Map einspielt (die CLI
// braucht keine — sie tickt nie).
player: Player::new([0.0, 1.0, 0.0]),
collision: CollisionWorld::empty(),
triggers: Triggers::new(),
signals_path,
sim_accum: 0.0,
pending_jump: false,
}
}
/// Welt-Geometrie aus der Map laden: Collision-Brushes bauen und den
/// Spieler an `info_player_start` setzen. Einmal vom Fenster-Frontend beim
/// Start gerufen.
pub fn load_world(&mut self, map: &Map) {
self.collision = CollisionWorld::build(map);
self.triggers = Triggers::new();
self.player = Player::new(player_spawn(map));
}
/// Sim-Seite eines Blender-Modells in die bestehende Welt einfügen —
/// nach `load_world` rufen, einmal pro Modell: Collider (`collide`-
/// Property → konvexe Hülle) und Trigger (`signal`-Property →
/// Klick-Ziele/Zonen). Die Sichtgeometrie desselben Modells ist Sache
/// des Renderers (render::props).
pub fn load_props(&mut self, model: &model::Model) {
self.collision.add_model(model);
self.triggers.add_model(model);
}
/// Einen Simulationsschritt treiben — das kontinuierliche Gegenstück zu
/// `exec`. Nur im Spielmodus bewegt sich der Spieler; Menü/Dialog/Flycam
/// pausieren die Sim (die Flycam ist reine Frontend-Sicht). Rückgabe:
/// Ausgabezeilen gefeuerter Betretens-Zonen (meist leer) — das Frontend
/// zeigt sie wie `exec`-Ausgaben an.
pub fn tick(&mut self, input: &FrameInput, dt: f32) -> Vec<String> {
if !matches!(self.mode, Mode::FirstPerson) { return Vec::new(); }
// Blick ist ein direkter Maus-Delta (nicht zeitintegriert) → genau
// einmal pro Frame anwenden, sonst skalierte ihn die Zahl der
// Sub-Schritte und die Empfindlichkeit schwankte mit der Framerate.
self.player.look(input.look_dx, input.look_dy, LOOK_SENS);
// Sprung-Flanke puffern, bis ein Sub-Schritt sie verbraucht.
self.pending_jump |= input.jump;
// Physik in festen FIXED_DT-Schritten treiben; Rest-Zeit über Frames
// tragen. Der Deckel verhindert die Spirale bei einem einzelnen Hänger.
self.sim_accum = (self.sim_accum + dt).min(MAX_ACCUM);
while self.sim_accum >= FIXED_DT {
let jump = std::mem::take(&mut self.pending_jump);
self.player.step(&self.collision, input.fwd, input.right, jump, FIXED_DT);
self.sim_accum -= FIXED_DT;
}
// Betretens-Zonen nach der Bewegung prüfen (Flanke: einmal je
// Eintritt). Erst alle Events einsammeln, dann feuern — `fire` kann
// den Modus wechseln (z.B. start_ink → Dialog), die Sim dieses Frames
// ist da schon abgeschlossen.
let center = [
self.player.pos[0],
self.player.pos[1] + player::HALF_EXTENTS[1],
self.player.pos[2],
];
let events = self.triggers.enter_events(center, player::HALF_EXTENTS);
let mut out = Vec::new();
for (name, signal) in events {
out.extend(self.fire(&signal, Some(name)));
}
out
}
/// Klick-Ziel unterm Fadenkreuz: nächstes `signal`-Klick-Volumen entlang
/// des Blickstrahls (`origin` + `dir·reach`), sofern keine andere
/// Weltgeometrie davor liegt. Liefert den Objektnamen für `use <name>`.
pub fn pick(&self, origin: [f32; 3], dir: [f32; 3], reach: f32) -> Option<&str> {
let end = [
origin[0] + dir[0] * reach,
origin[1] + dir[1] * reach,
origin[2] + dir[2] * reach,
];
let (name, frac) = self.triggers.pick(origin, end)?;
// Das Klick-Volumen ist selbst Teil der Kollisionswelt → bei freier
// Sicht treffen beide Traces dieselbe Fläche (Toleranz für FP/SKIN).
let world = self.collision.trace(origin, end, [0.0; 3]).map_or(1.0, |h| h.frac);
(frac <= world + 1e-3).then_some(name)
}
/// Reserviertes `[init]`-Signal feuern (KV-Defaults, bevor etwas läuft).
/// Eigene Methode statt im Konstruktor, damit das Frontend die Ausgabe
/// darstellen kann — und damit `init` bereits einen Dialog öffnen darf.
pub fn start(&mut self) -> Vec<String> {
self.fire("init", None)
}
/// Eine Eingabezeile verarbeiten. Im Dialog treibt sie den Dialog,
/// sonst ist sie ein Engine-Befehl. Dieselbe Eingabequelle, je nach
/// `mode` unterschiedlich gedeutet — so steuert Terminal *und* (später)
/// Klick denselben Dialog ohne Sonderpfad.
pub fn exec(&mut self, line: &str) -> ExecResult {
match self.mode {
Mode::Dialog(_) => self.dialog_input(line),
Mode::FirstPerson | Mode::Free | Mode::Menu => self.command(line),
}
}
fn command(&mut self, line: &str) -> ExecResult {
let line = line.trim();
match line {
"" => ExecResult::empty(),
"quit" | "exit" => ExecResult { output: Vec::new(), quit: true },
"menu" => {
// Menü öffnen bzw. ins Spiel zurück. „Fortsetzen" landet im
// Spielmodus (Play), nicht in der Debug-Flycam; aus Play oder
// Free öffnet es das Menü. (Dialog wird anders geroutet.)
let t = match self.mode {
Mode::Menu => ModeTarget::Play,
_ => ModeTarget::Menu,
};
self.set_mode(t);
ExecResult::empty()
}
"noclip" => {
// Debug: zwischen First-Person (Play) und freier Flycam (Free)
// umschalten.
let t = match self.mode {
Mode::FirstPerson => ModeTarget::Free,
_ => ModeTarget::Play,
};
self.set_mode(t);
ExecResult::empty()
}
"help" => ExecResult::lines(help()),
"kv" => ExecResult::lines(self.dump_kv()),
"reload" => {
self.game.signals = signals::load_signals(&self.signals_path);
ExecResult::lines(vec![
format!("{} Signale geladen.", self.game.signals.len()),
])
}
_ => {
if let Some(m) = line.strip_prefix("mode ") {
// Modus explizit setzen — über dieselbe Action wie das
// `mode`-Verb aus signals.toml/Ink-Tags, damit CLI und
// Signals genau einen Wechsel-Pfad teilen.
match ModeTarget::parse(m) {
Some(t) => {
self.game.actions.push(Action::SetMode(t));
ExecResult::lines(self.drain_actions())
}
None => ExecResult::lines(vec![
"Modus: play | free | menu".into(),
]),
}
} else if let Some(sig) = line.strip_prefix("signal ") {
ExecResult::lines(self.fire(sig.trim(), None))
} else if let Some(name) = line.strip_prefix("use ") {
// Objekt-Interaktion (LMB-Klick und Konsole laufen beide
// hier durch): Klick-Ziele mit `signal`-Property feuern
// dieses Signal; sonst den vom Blender-Suffix befreiten
// Objektnamen. $self ist immer der volle Name.
let name = name.trim();
let key = self.triggers.signal_for(name)
.unwrap_or_else(|| signals::signal_key(name))
.to_string();
ExecResult::lines(self.fire(&key, Some(name.to_string())))
} else {
ExecResult::lines(vec![
format!("unbekannter Befehl: {line:?} — `help` für Befehle"),
])
}
}
}
}
fn dialog_input(&mut self, line: &str) -> ExecResult {
let Mode::Dialog(d) = &self.mode else { return ExecResult::empty(); };
let sel = if d.choices.is_empty() {
None // reiner Text → Leerzeile/Enter blättert weiter
} else {
match line.trim().parse::<usize>() {
Ok(n) if (1..=d.choices.len()).contains(&n) => Some(n - 1),
_ => return ExecResult::lines(vec![format!(" (1..{})", d.choices.len())]),
}
};
let mut out = self.step(sel);
// Ein start_ink-Tag kann während step() eine neue Story in den Slot
// geladen haben, während die alte auf End lief (mode → Play). Falls
// das passiert ist, sofort den ersten Schritt der neuen Story ziehen
// statt den Spieler im Play-Modus hängen zu lassen.
if self.game.story.is_some() && matches!(self.mode, Mode::FirstPerson) {
out.extend(self.step(None));
}
ExecResult::lines(out)
}
/// Signal dispatchen, deferred Actions einsammeln und — falls dadurch
/// eine Story startete — den ersten Dialogschritt ziehen.
fn fire(&mut self, signal: &str, instance: Option<String>) -> Vec<String> {
{
let mut ctx = self.game.action_ctx(instance);
signals::dispatch(signal, &mut ctx);
}
let mut out = self.drain_actions();
if self.game.story.is_some() {
out.extend(self.step(None));
}
out
}
/// Einen Story-Schritt treiben und `mode` daran anpassen. `sel` ist die
/// Choice-Auswahl bzw. `None` für ersten Schritt / Text-Weiterblättern.
/// Tags gehen wie gehabt zurück in den Dispatcher (siehe story_ctrl).
fn step(&mut self, sel: Option<usize>) -> Vec<String> {
let state = {
let mut ctx = self.game.action_ctx(None);
let (state, tags) = story_ctrl::advance(sel, &mut ctx);
for t in &tags { signals::dispatch(t, &mut ctx); }
state
};
let mut out = self.drain_actions();
match state {
None | Some(StoryState::End) => {
self.mode = Mode::FirstPerson;
}
Some(StoryState::Text(text)) => {
out.push(text.clone());
self.mode = Mode::Dialog(Dialog { text, choices: Vec::new() });
}
Some(StoryState::Choice { prompt, options }) => {
if !prompt.is_empty() { out.push(prompt.clone()); }
for (i, o) in options.iter().enumerate() {
out.push(format!(" {}) {o}", i + 1));
}
self.mode = Mode::Dialog(Dialog { text: prompt, choices: options });
}
}
out
}
fn drain_actions(&mut self) -> Vec<String> {
// Erst aus der Queue ziehen (gibt den `&mut self.game`-Borrow frei),
// dann anwenden — `SetMode` braucht `&mut self.mode`.
let actions: Vec<Action> = self.game.actions.drain(..).collect();
let mut out = Vec::with_capacity(actions.len());
for a in actions {
match a {
Action::HideObject(n) => out.push(format!("[action] hide_object {n}")),
Action::PlaySound(n) => out.push(format!("[action] play_sound {n}")),
Action::SetMode(t) => {
self.set_mode(t);
out.push(format!("[action] mode {}", t.label()));
}
}
}
out
}
/// Frontend-neutrales [`ModeTarget`] auf den konkreten [`Mode`] mappen.
/// Einziger Schreibpfad für die nicht-Dialog-Modi — `menu`/`noclip`/`mode`
/// und die `SetMode`-Action laufen alle hier durch. (Der Dialog-Modus
/// entsteht ausschließlich in `step` aus dem Story-Ablauf.)
fn set_mode(&mut self, t: ModeTarget) {
self.mode = match t {
ModeTarget::Play => Mode::FirstPerson,
ModeTarget::Free => Mode::Free,
ModeTarget::Menu => Mode::Menu,
};
}
fn dump_kv(&self) -> Vec<String> {
let mut keys: Vec<&String> = self.game.kv.keys().collect();
keys.sort();
keys.into_iter()
.map(|k| format!(" {k} = {}", kv::format_value(&self.game.kv[k])))
.collect()
}
}
/// Spawn-Fußpunkt (Engine-Koords) aus `info_player_start`; sonst ein Default.
fn player_spawn(map: &Map) -> [f32; 3] {
for e in &map.entities {
if e.classname() == Some("info_player_start")
&& let Some(origin) = e.props.get("origin") {
let v: Vec<f32> = origin.split_whitespace().filter_map(|s| s.parse().ok()).collect();
if let [x, y, z] = v[..] {
return map::to_engine([x, y, z]);
}
}
}
[0.0, 1.0, 0.0]
}
fn help() -> Vec<String> {
vec![
" signal <s> Signal feuern oder Action direkt ausführen".into(),
" (z.B. `signal Cube`, `signal set has_key true`)".into(),
" use <instance> Objekt-Interaktion simulieren — `use Mushroom.005`".into(),
" feuert Signal `Mushroom` mit $self = Mushroom.005".into(),
" kv KV-Store anzeigen".into(),
" menu Menü öffnen / ins Spiel zurück".into(),
" noclip zwischen First-Person und Debug-Flycam wechseln".into(),
" mode <m> Modus direkt setzen: play | free | menu".into(),
" reload signals.toml neu laden".into(),
" quit beenden".into(),
]
}
#[cfg(test)]
mod tests {
use super::*;
// Fehlender Pfad → leere Signal-Table (load_signals schluckt den Fehler),
// also keine Asset-Abhängigkeit für diese Tests. Der Konstruktor startet
// im Menü (Titelbildschirm); Tests des Befehlspfads setzen vorab `Free`.
fn empty_session() -> Session { Session::new("/nonexistent/signals.toml".into()) }
#[test]
fn starts_in_menu() {
assert!(matches!(empty_session().mode, Mode::Menu));
}
#[test]
fn quit_sets_flag() {
let mut s = empty_session();
assert!(s.exec("quit").quit);
}
#[test]
fn builtin_command_mutates_kv_and_stays_free() {
let mut s = empty_session();
s.mode = Mode::Free;
// Unbekanntes Signal fällt auf den Builtin-Pfad durch → `set`.
let r = s.exec("signal set has_key true");
assert!(!r.quit);
assert!(s.game.kv["has_key"].coerce_to_bool().unwrap());
assert!(matches!(s.mode, Mode::Free));
}
#[test]
fn menu_command_opens_and_resumes_to_play() {
let mut s = empty_session();
s.mode = Mode::Free;
s.exec("menu");
assert!(matches!(s.mode, Mode::Menu));
s.exec("menu"); // „Fortsetzen" → ins Spiel
assert!(matches!(s.mode, Mode::FirstPerson));
s.exec("menu"); // aus dem Spiel wieder ins Menü
assert!(matches!(s.mode, Mode::Menu));
// Im Menü laufen Engine-Befehle weiter (gleiche Route wie Play/Free).
s.exec("signal set in_menu true");
assert!(s.game.kv["in_menu"].coerce_to_bool().unwrap());
}
#[test]
fn mode_command_sets_mode_directly() {
let mut s = empty_session();
s.exec("mode play");
assert!(matches!(s.mode, Mode::FirstPerson));
s.exec("mode free");
assert!(matches!(s.mode, Mode::Free));
s.exec("mode menu");
assert!(matches!(s.mode, Mode::Menu));
// Müll-Argument lässt den Modus stehen und meldet die Nutzung.
let r = s.exec("mode dialog");
assert!(matches!(s.mode, Mode::Menu));
assert_eq!(r.output.len(), 1);
}
#[test]
fn signal_can_switch_mode() {
let mut s = empty_session();
s.mode = Mode::Free;
// `mode`-Verb direkt als Action (Ink-Tag-/signals.toml-Pfad).
s.exec("signal mode play");
assert!(matches!(s.mode, Mode::FirstPerson));
}
#[test]
fn noclip_toggles_play_and_free() {
let mut s = empty_session();
s.mode = Mode::FirstPerson;
s.exec("noclip");
assert!(matches!(s.mode, Mode::Free));
s.exec("noclip");
assert!(matches!(s.mode, Mode::FirstPerson));
}
#[test]
fn fixed_timestep_is_framerate_independent() {
// Gleiche Gesamtzeit, einmal als grober und einmal als viele feine
// Frames → dieselbe Zahl fester Sim-Schritte, also (bis auf FP-Rauschen)
// dasselbe Ergebnis. Die leere Kollisionswelt lässt den Spieler frei
// fallen — genügt, um die Integration zu vergleichen.
let mut coarse = empty_session();
coarse.mode = Mode::FirstPerson;
let mut fine = empty_session();
fine.mode = Mode::FirstPerson;
let input = FrameInput::default();
coarse.tick(&input, 0.1);
for _ in 0..60 { fine.tick(&input, 0.1 / 60.0); }
assert!((coarse.player.pos[1] - fine.player.pos[1]).abs() < 1e-3,
"framerate-unabhängig: {} vs {}", coarse.player.pos[1], fine.player.pos[1]);
assert!((coarse.player.vel[1] - fine.player.vel[1]).abs() < 1e-3,
"gleiche Fallgeschwindigkeit: {} vs {}", coarse.player.vel[1], fine.player.vel[1]);
assert!(coarse.player.pos[1] < 1.0, "sollte gefallen sein: {}", coarse.player.pos[1]);
}
#[test]
fn jump_press_survives_a_substepless_frame() {
// Sprungtaste in einem Frame kürzer als FIXED_DT: es läuft noch kein
// Sim-Schritt, die Flanke darf aber nicht verfallen, sondern muss beim
// nächsten Schritt zünden.
let mut s = empty_session();
s.mode = Mode::FirstPerson;
s.player.grounded = true;
let jump = FrameInput { jump: true, ..Default::default() };
s.tick(&jump, FIXED_DT * 0.5);
assert!(s.player.vel[1] <= 0.0, "noch kein Schritt → noch kein Absprung");
s.tick(&FrameInput::default(), FIXED_DT * 0.6);
assert!(s.player.vel[1] > 0.0,
"gepufferter Sprung sollte abheben: {}", s.player.vel[1]);
}
#[test]
fn tick_only_runs_in_first_person() {
// Menü/Free/Dialog pausieren die Sim: kein Schritt, keine Bewegung.
let mut s = empty_session();
s.mode = Mode::Menu;
let before = s.player.pos;
s.tick(&FrameInput::default(), 0.1);
assert_eq!(s.player.pos, before, "Menü pausiert die Sim");
}
/// Modell mit einer Betretens-Zone vor dem Spieler und einem Klick-Ziel,
/// deren Signale als Builtin-Fall-Through direkt KV-Flags setzen.
fn trigger_model() -> crate::engine::model::Model {
crate::engine::model::Model {
objects: vec![
crate::engine::model::test_cube(
"Zone.001", [-0.5, 0.0, -3.0], None, Some("set zone_hit true")),
crate::engine::model::test_cube(
"Kiste", [5.0, 0.0, 5.0], Some("1"), Some("set kiste_geklickt true")),
],
empties: Vec::new(),
materials: vec![String::new()],
}
}
#[test]
fn walking_into_zone_fires_signal_once() {
let mut s = empty_session();
s.load_props(&trigger_model());
s.mode = Mode::FirstPerson;
s.player.grounded = true;
// Vorwärts (Z, yaw=0) in die Zone laufen; irgendwann feuert die
// Flanke genau einmal (danach: drin = still).
for _ in 0..120 { s.tick(&FrameInput { fwd: 1.0, ..Default::default() }, FIXED_DT); }
assert!(s.game.kv["zone_hit"].coerce_to_bool().unwrap(), "Zone sollte gefeuert haben");
// Weiterlaufen in der Zone darf nicht erneut feuern.
s.game.kv.remove("zone_hit");
for _ in 0..5 { s.tick(&FrameInput { fwd: 1.0, ..Default::default() }, FIXED_DT); }
assert!(!s.game.kv.contains_key("zone_hit"), "Flanke: nur einmal je Eintritt");
}
#[test]
fn use_resolves_signal_property_before_name() {
let mut s = empty_session();
s.load_props(&trigger_model());
s.mode = Mode::FirstPerson;
// Klick-Ziel: feuert den Property-Wert, nicht signal_key("Kiste").
s.exec("use Kiste");
assert!(s.game.kv["kiste_geklickt"].coerce_to_bool().unwrap());
// Ohne Property wie gehabt: Name (suffix-gestrippt) als Signal — hier
// unbekannt und ohne Table ein stilles No-Op, aber kein Fehler.
let r = s.exec("use Irgendwas.003");
assert!(!r.quit);
}
#[test]
fn pick_hits_clickable_within_reach() {
let mut s = empty_session();
s.load_props(&trigger_model());
// Kiste bei [5..6, 0..1, 5..6]: Strahl von schräg oben davor.
let hit = s.pick([5.5, 0.5, 3.0], [0.0, 0.0, 1.0], INTERACT_REACH);
assert_eq!(hit, Some("Kiste"));
// Außer Reichweite → nichts.
assert_eq!(s.pick([5.5, 0.5, 0.0], [0.0, 0.0, 1.0], INTERACT_REACH), None);
}
#[test]
fn unknown_command_reports_and_stays_free() {
let mut s = empty_session();
s.mode = Mode::Free;
let r = s.exec("frobnicate");
assert_eq!(r.output.len(), 1);
assert!(matches!(s.mode, Mode::Free));
}
}