Add playable customiser test build with transactional retail character swaps

This commit is contained in:
chazmm committed 2026-09-08 15:51:15 +01:00
1 parent 9ca5ee076f
commit a2ead83471
14 files changed
+1035 -36

No files matched your search

+1
View File
@@ -101,6 +101,7 @@ pub(crate) fn build(
crate::grind_world::GrindGeometryPlugin,
camera::CameraPlugin,
crate::graphics_menu::GraphicsMenuPlugin,
crate::customiser::CustomiserPlugin,
crate::render_capacity::RenderCapacityPlugin,
verification::VerificationPlugin,
crate::performance::PerformancePlugin,
+9
View File
@@ -52,6 +52,7 @@ fn spawn(mut commands: Commands, config: Res<Config>, retail: Res<crate::retail_
fn present(mut runtime: ResMut<CameraRuntime>, windows: Query<&Window>,
history: Res<crate::presentation::Presentation>, time: Res<Time<Fixed>>,
replay: Res<crate::replay::Replay>,
customiser: Option<Res<crate::customiser::Customiser>>,
mut cameras: Query<(&mut Camera, &mut Transform, &mut Projection), With<GameplayCamera>>) {
if let Ok(window) = windows.single() {
runtime.set_aspect_ratio(window.width() / window.height());
@@ -65,6 +66,14 @@ fn present(mut runtime: ResMut<CameraRuntime>, windows: Query<&Window>,
if let Projection::Perspective(p) = &mut *projection {
p.fov = previous.fov + (current.fov - previous.fov) * alpha;
}
if customiser.as_ref().is_some_and(|c| c.open) {
let center = current.root.translation + Vec3::Y * 0.95;
let offset = current.root.rotation * Vec3::new(0.0, 0.25, 3.2);
let eye = center + offset;
let right = offset.normalize().cross(Vec3::Y).normalize();
*transform = Transform::from_translation(eye).looking_at(center + right * 0.85, Vec3::Y);
if let Projection::Perspective(p) = &mut *projection { p.fov = 50_f32.to_radians(); }
}
camera.is_active = true;
}
}
+601
View File
@@ -0,0 +1,601 @@
//! Draft/commit character editing over the existing retail GLB and animation rig.
use bevy::{asset::LoadState, gltf::Gltf, prelude::*};
use serde::Deserialize;
use serde_json::{Value, json};
use std::{
io::Write,
path::PathBuf,
process::{Command, Stdio},
sync::{Mutex, mpsc},
};
#[derive(Clone, Default, Deserialize)]
struct Entry {
label: String,
#[serde(default)]
children: Vec<Entry>,
patch: Option<Value>,
scalar: Option<String>,
note: Option<String>,
minimum: Option<f64>,
maximum: Option<f64>,
initial: Option<f64>,
step: Option<f64>,
}
#[derive(Clone, Deserialize)]
struct Worker {
python: PathBuf,
working_directory: PathBuf,
}
#[derive(Resource, Default)]
pub(crate) struct Navigation {
pub pressed: u16,
previous: u16,
}
#[derive(Resource)]
pub(crate) struct Customiser {
pub open: bool,
just_opened: bool,
index: Entry,
path: Vec<usize>,
selected: usize,
draft: Value,
committed: Value,
scene: String,
committed_scene: String,
config: PathBuf,
settings: PathBuf,
worker: Option<Worker>,
receiver: Option<Mutex<mpsc::Receiver<Result<Value, String>>>>,
loading: Option<(Handle<Gltf>, String, Value)>,
status: String,
redraw: bool,
}
impl Customiser {
pub(crate) fn begin(&mut self) {
self.open = true;
self.just_opened = true;
self.path.clear();
self.selected = 0;
self.status = "Choose a category. Changes are previews until you Save.".into();
self.redraw = true;
}
fn page(&self) -> &Entry {
let mut page = &self.index;
for &i in &self.path {
page = &page.children[i];
}
page
}
fn busy(&self) -> bool {
self.receiver.is_some() || self.loading.is_some()
}
fn request(&mut self, profile: Value) {
if self.busy() {
self.status = "Please wait for the current preview.".into();
return;
}
let Some(worker) = self.worker.clone() else {
self.status = "Private customisation worker is not configured.".into();
return;
};
let config = self.config.clone();
let (tx, rx) = mpsc::channel();
self.receiver = Some(Mutex::new(rx));
self.status = "Preparing retail meshes and textures…".into();
self.redraw = true;
std::thread::spawn(move || {
let run = || -> Result<Value, String> {
let mut command = Command::new(&worker.python);
command
.current_dir(&worker.working_directory)
.args([
"-m",
"tools.asset_pipeline.customisation_worker",
"--config",
])
.arg(config)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
command.creation_flags(0x08000000);
}
let mut child = command
.spawn()
.map_err(|e| format!("Cannot start asset worker: {e}"))?;
child
.stdin
.take()
.ok_or("Worker stdin missing")?
.write_all(&serde_json::to_vec(&profile).map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?;
let output = child.wait_with_output().map_err(|e| e.to_string())?;
let response: Value = serde_json::from_slice(&output.stdout).map_err(|e| {
format!(
"Worker response: {e}; {}",
String::from_utf8_lossy(&output.stderr)
.chars()
.take(400)
.collect::<String>()
)
})?;
if let Some(e) = response.get("error").and_then(Value::as_str) {
return Err(e.into());
}
if !output.status.success() {
return Err("Asset worker failed".into());
}
Ok(response)
};
let _ = tx.send(run());
});
}
}
#[derive(Component)]
struct Root;
#[derive(Component)]
struct Row(usize);
pub(crate) struct CustomiserPlugin;
impl Plugin for CustomiserPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<Navigation>()
.add_systems(PreUpdate, navigation)
.add_systems(PostStartup, setup)
.add_systems(
Update,
(interact, complete, draw)
.chain()
.after(crate::graphics_menu::interact)
.before(crate::app::FrameSet::Animation),
);
}
}
fn navigation(mut nav: ResMut<Navigation>) {
let current = (0..4)
.find_map(|i| crate::input::platform::poll(i).ok())
.map_or(0, |p| p.state.buttons);
nav.pressed = current & !nav.previous;
nav.previous = current;
}
fn setup(
mut commands: Commands,
config: Res<crate::config::Config>,
manifest: Res<crate::assets::AssetManifest>,
) {
let root = config.asset_root.join("private/customisation");
let read = |name: &str| {
std::fs::read(root.join(name))
.ok()
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
};
let index = read("menu.json")
.and_then(|v| serde_json::from_value(v).ok())
.unwrap_or(Entry {
label: "Character customiser".into(),
note: Some("Prepare owned customisation assets for this installation.".into()),
..default()
});
let defaults = read("default.json")
.unwrap_or(json!({"selections":{},"morphs":{},"truck":0.7,"wheel":0.7,"posture":0}));
let settings = config
.asset_root
.parent()
.unwrap_or(&config.asset_root)
.join("settings/character.json");
let saved = std::fs::read(&settings)
.ok()
.and_then(|b| serde_json::from_slice::<Value>(&b).ok());
let worker = read("worker.json").and_then(|v| serde_json::from_value(v).ok());
let mut state = Customiser {
open: false,
just_opened: false,
index,
path: vec![],
selected: 0,
draft: defaults.clone(),
committed: defaults,
scene: manifest.0.character_scene.clone(),
committed_scene: manifest.0.character_scene.clone(),
config: root.join("worker.json"),
settings,
worker,
receiver: None,
loading: None,
status: String::new(),
redraw: true,
};
if let Some(profile) = saved {
state.committed = profile.clone();
state.request(profile);
}
commands.insert_resource(state);
commands.spawn((
Root,
GlobalZIndex(20),
Node {
display: Display::None,
position_type: PositionType::Absolute,
width: percent(42),
height: percent(100),
padding: UiRect::all(px(20)),
flex_direction: FlexDirection::Column,
row_gap: px(6),
..default()
},
BackgroundColor(Color::srgba(0.025, 0.04, 0.06, 0.98)),
));
}
fn merge(target: &mut Value, patch: &Value) {
if let (Some(a), Some(b)) = (target.as_object_mut(), patch.as_object()) {
for (k, v) in b {
merge(a.entry(k.clone()).or_insert(Value::Null), v);
}
} else {
*target = patch.clone();
}
}
fn scalar(profile: &Value, key: &str, initial: f64) -> f64 {
let pointer = format!("/{}", key.replace('.', "/"));
profile
.pointer(&pointer)
.and_then(Value::as_f64)
.unwrap_or(initial)
}
fn interact(
keys: Res<ButtonInput<KeyCode>>,
nav: Res<Navigation>,
mut state: ResMut<Customiser>,
mut menu: ResMut<crate::graphics_menu::Menu>,
server: Res<AssetServer>,
buttons: Query<(&Interaction, &Row), Changed<Interaction>>,
) {
if !state.open {
return;
}
if state.just_opened {
state.just_opened = false;
return;
}
let mut action = None;
let count = state.page().children.len();
if count > 0 {
if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 {
state.selected = (state.selected + count - 1) % count;
state.redraw = true;
}
if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 {
state.selected = (state.selected + 1) % count;
state.redraw = true;
}
if keys.just_pressed(KeyCode::Enter)
|| keys.just_pressed(KeyCode::ArrowRight)
|| nav.pressed & (0x1000 | 8) != 0
{
action = Some((state.selected, 1));
}
if keys.just_pressed(KeyCode::ArrowLeft) || nav.pressed & 4 != 0 {
action = Some((state.selected, -1));
}
}
let mut save = keys.just_pressed(KeyCode::F5) || nav.pressed & 0x4000 != 0;
let mut back = keys.just_pressed(KeyCode::Escape)
|| keys.just_pressed(KeyCode::Backspace)
|| nav.pressed & 0x2000 != 0;
let mut reset = false;
for (interaction, row) in &buttons {
if *interaction == Interaction::Pressed {
match row.0 {
usize::MAX => save = true,
x if x == usize::MAX - 1 => back = true,
x if x == usize::MAX - 2 => reset = true,
i => {
state.selected = i;
action = Some((i, 1));
}
}
}
}
if save {
if state.busy() {
state.status = "Wait for the preview before saving.".into();
} else {
let result = (|| -> Result<(), String> {
std::fs::create_dir_all(state.settings.parent().unwrap())
.map_err(|e| e.to_string())?;
let temp = state.settings.with_extension("pending.json");
std::fs::write(
&temp,
serde_json::to_vec_pretty(&state.draft).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
// Same directory replacement preserves the previous committed profile on write failure.
std::fs::rename(&temp, &state.settings).map_err(|e| e.to_string())
})();
match result {
Ok(()) => {
state.committed = state.draft.clone();
state.committed_scene = state.scene.clone();
state.status = "Saved. Back returns to skating with this character.".into();
}
Err(e) => state.status = format!("Could not save: {e}"),
}
}
state.redraw = true;
}
if reset && !state.busy() {
let profile = std::fs::read(state.config.with_file_name("default.json"))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok());
if let Some(profile) = profile {
state.request(profile);
}
}
if back {
if !state.path.is_empty() {
state.selected = state.path.pop().unwrap();
} else if state.busy() {
state.status = "Wait for the preview before leaving.".into();
} else {
if state.draft != state.committed {
let scene = state.committed_scene.clone();
let profile = state.committed.clone();
state.loading = Some((server.load(scene.clone()), scene, profile));
}
state.open = false;
menu.open = false;
}
state.redraw = true;
return;
}
if let Some((i, direction)) = action {
let Some(entry) = state.page().children.get(i).cloned() else {
return;
};
if !entry.children.is_empty() {
state.path.push(i);
state.selected = 0;
} else if let Some(note) = entry.note {
state.status = note;
} else if !state.busy() {
let mut profile = state.draft.clone();
if let Some(key) = entry.scalar {
let current = scalar(&profile, &key, entry.initial.unwrap_or(0.));
let value = (current + direction as f64 * entry.step.unwrap_or(0.1))
.clamp(entry.minimum.unwrap_or(0.), entry.maximum.unwrap_or(1.));
if let Some((parent, child)) = key.split_once('.') {
profile[parent][child] = json!(value);
} else {
profile[&key] = json!(value);
}
// Equipment controls are consumed directly by existing native physics.
if key == "truck" || key == "wheel" {
state.draft = profile;
} else {
state.request(profile);
}
} else if let Some(patch) = entry.patch {
merge(&mut profile, &patch);
if patch.get("posture").is_some() {
state.draft = profile;
state.status="Posture is selected. The native motion tree applies it when skating resumes.".into();
} else {
state.request(profile);
}
}
} else {
state.status = "Please wait for the current preview.".into();
}
state.redraw = true;
}
}
fn complete(
mut state: ResMut<Customiser>,
server: Res<AssetServer>,
mut commands: Commands,
root: Query<Entity, With<crate::world::PlayerRoot>>,
scenes: Query<(Entity, &ChildOf), With<SceneRoot>>,
mut animation: ResMut<crate::animation::AnimationStatus>,
mut physics: ResMut<crate::physics::GamePhysics>,
mut skater: ResMut<crate::physics::SkaterRuntime>,
) {
let response = state
.receiver
.as_ref()
.and_then(|rx| rx.lock().ok()?.try_recv().ok());
if let Some(response) = response {
state.receiver = None;
state.redraw = true;
match response {
Ok(value) => {
if let (Some(scene), Some(profile)) = (
value.get("scene").and_then(Value::as_str),
value.get("profile"),
) {
state.loading =
Some((server.load(scene.to_owned()), scene.into(), profile.clone()));
state.status = "Loading the assembled character…".into();
} else {
state.status = "Asset worker returned an incomplete character.".into();
}
}
Err(e) => state.status = format!("Preview unchanged: {e}"),
}
}
if let Some((handle, _, _)) = &state.loading {
if let Some(LoadState::Failed(error)) = server.get_load_state(handle.id()) {
state.status = format!("Preview unchanged: {error}");
state.loading = None;
state.redraw = true;
} else if server.is_loaded_with_dependencies(handle.id()) {
if let Ok(root) = root.single() {
let (_, scene, profile) = state.loading.take().unwrap();
for (entity, parent) in &scenes {
if parent.parent() == root {
commands.entity(entity).despawn();
}
}
commands.entity(root).with_children(|p| {
p.spawn(SceneRoot(
server.load(GltfAssetLabel::Scene(0).from_asset(scene.clone())),
));
});
*animation = default();
if profile == state.committed {
state.committed_scene = scene.clone();
}
state.scene = scene;
state.draft = profile;
state.status = "Preview ready. Save to keep, or Back at the top to discard.".into();
state.redraw = true;
}
}
}
physics.set_equipment_preferences(
scalar(&state.draft, "truck", 0.7).clamp(0., 1.) as f32,
scalar(&state.draft, "wheel", 0.7).clamp(0., 1.) as f32,
);
skater
.animation
.motion
.animation
.posture
.set_profile(scalar(&state.draft, "posture", 0.).clamp(0., 3.) as u32);
}
fn draw(
mut commands: Commands,
mut state: ResMut<Customiser>,
mut root: Single<(Entity, &mut Node), With<Root>>,
) {
root.1.display = if state.open {
Display::Flex
} else {
Display::None
};
if !state.open || !state.redraw {
return;
}
state.redraw = false;
commands.entity(root.0).despawn_children();
let page = state.page();
let start = state.selected / 10 * 10;
commands.entity(root.0).with_children(|p| {
p.spawn((
Text::new(&page.label),
TextFont {
font_size: 25.,
..default()
},
TextColor(Color::WHITE),
));
p.spawn((
Text::new("RETAIL CHARACTER • TEST BUILD"),
TextFont {
font_size: 13.,
..default()
},
TextColor(Color::srgb(0.4, 0.85, 0.8)),
));
for (i, entry) in page.children.iter().enumerate().skip(start).take(10) {
let value = entry
.scalar
.as_ref()
.map(|key| {
format!(
" {:.3}",
scalar(&state.draft, key, entry.initial.unwrap_or(0.))
)
})
.unwrap_or_default();
let suffix = if !entry.children.is_empty() {
" >"
} else {
""
};
p.spawn((
Button,
Row(i),
Node {
width: percent(100),
min_height: px(32),
padding: UiRect::all(px(7)),
..default()
},
BackgroundColor(if state.selected == i {
Color::srgb(0.08, 0.32, 0.33)
} else {
Color::srgb(0.06, 0.10, 0.14)
}),
))
.with_children(|r| {
r.spawn((
Text::new(format!("{}{value}{suffix}", entry.label)),
TextFont {
font_size: 16.,
..default()
},
TextColor(Color::WHITE),
));
});
}
if page.children.len() > 10 {
p.spawn((
Text::new(format!(
"{}–{} / {} • Up/Down to browse",
start + 1,
(start + 10).min(page.children.len()),
page.children.len()
)),
TextFont {
font_size: 13.,
..default()
},
));
}
for (id, title) in [
(usize::MAX, "Save [F5 / X]"),
(usize::MAX - 1, "Back [Esc / B]"),
(usize::MAX - 2, "Restore original character"),
] {
p.spawn((
Button,
Row(id),
Node {
padding: UiRect::all(px(7)),
..default()
},
BackgroundColor(Color::srgb(0.10, 0.17, 0.21)),
))
.with_children(|r| {
r.spawn((
Text::new(title),
TextFont {
font_size: 16.,
..default()
},
TextColor(Color::WHITE),
));
});
}
p.spawn((
Text::new(&state.status),
TextFont {
font_size: 14.,
..default()
},
TextColor(Color::srgb(0.8, 0.85, 0.9)),
));
p.spawn((
Text::new(
"Enter / A: choose • Left/Right: adjust\nBack at the top discards unsaved changes.",
),
TextFont {
font_size: 13.,
..default()
},
TextColor(Color::srgb(0.55, 0.65, 0.7)),
));
});
}
+19 -13
View File
@@ -75,7 +75,7 @@ impl GraphicsSettings {
}
#[derive(Resource)]
pub(crate) struct Menu {
open: bool,
pub(crate) open: bool,
selected: usize,
settings: GraphicsSettings,
path: PathBuf,
@@ -95,7 +95,7 @@ struct FramePacer(Instant);
#[derive(Component)]
struct MenuRoot;
#[derive(Component)]
struct MenuRow(usize);
pub(crate) struct MenuRow(usize);
#[derive(Component)]
struct MenuLabel(usize);
#[derive(Component)]
@@ -198,7 +198,7 @@ fn setup(
BackgroundColor(Color::srgb(0.035,0.055,0.08)))).with_children(|panel| {
panel.spawn((Text::new("PAUSED"),TextFont {font_size:32.,..default()},TextColor(Color::WHITE)));
panel.spawn((Text::new("GAMEPLAY & GRAPHICS"),TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.4,0.85,0.85))));
for i in 0..10 {
for i in 0..11 {
panel.spawn((Button, MenuRow(i), Node {width:percent(100),min_height:px(36),padding:UiRect::all(px(8)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(5)),..default()},
BackgroundColor(Color::srgb(0.08,0.11,0.15)))).with_children(|row| {
row.spawn((MenuLabel(i),Text::new(""),TextFont {font_size:18.,..default()},TextColor(Color::WHITE)));
@@ -235,8 +235,10 @@ fn cycle<T: PartialEq + Copy>(values: &[T], value: T, direction: i32) -> T {
let index = values.iter().position(|x| *x == value).unwrap_or(0) as i32;
values[(index + direction).rem_euclid(values.len() as i32) as usize]
}
fn interact(
pub(crate) fn interact(
config: Res<crate::config::Config>,
mut customiser: ResMut<crate::customiser::Customiser>,
nav: Res<crate::customiser::Navigation>,
mut physics: ResMut<crate::physics::GamePhysics>,
keys: Res<ButtonInput<KeyCode>>,
mut menu: ResMut<Menu>,
@@ -244,21 +246,22 @@ fn interact(
buttons: Query<(&Interaction, &MenuRow), Changed<Interaction>>,
mut exit: MessageWriter<AppExit>,
) {
if keys.just_pressed(KeyCode::Escape) {
if customiser.open { return; }
if keys.just_pressed(KeyCode::Escape) || nav.pressed & 0x10 != 0 {
menu.open = !menu.open;
}
let mut action = None;
if menu.open {
if keys.just_pressed(KeyCode::ArrowUp) {
menu.selected = (menu.selected + 9) % 10;
if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 {
menu.selected = (menu.selected + 10) % 11;
}
if keys.just_pressed(KeyCode::ArrowDown) {
menu.selected = (menu.selected + 1) % 10;
if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 {
menu.selected = (menu.selected + 1) % 11;
}
if keys.just_pressed(KeyCode::ArrowLeft) {
if keys.just_pressed(KeyCode::ArrowLeft) || nav.pressed & 4 != 0 {
action = Some((menu.selected, -1));
}
if keys.just_pressed(KeyCode::ArrowRight) || keys.just_pressed(KeyCode::Enter) {
if keys.just_pressed(KeyCode::ArrowRight) || keys.just_pressed(KeyCode::Enter) || nav.pressed & (8 | 0x1000) != 0 {
action = Some((menu.selected, 1));
}
for (interaction, row) in &buttons {
@@ -308,6 +311,7 @@ fn interact(
9 => {
exit.write(AppExit::Success);
}
10 => customiser.begin(),
_ => {}
}
if row < 5 {
@@ -380,13 +384,14 @@ fn apply(
}
fn labels(
menu: Res<Menu>,
customiser: Res<crate::customiser::Customiser>,
window: Single<&Window, With<PrimaryWindow>>,
mut root: Single<&mut Node, With<MenuRoot>>,
mut labels: Query<(&MenuLabel, &mut Text), Without<StatusLabel>>,
mut status: Single<&mut Text, With<StatusLabel>>,
mut buttons: Query<(&MenuRow, &Interaction, &mut BackgroundColor)>,
) {
root.display = if menu.open {
root.display = if menu.open && !customiser.open {
Display::Flex
} else {
Display::None
@@ -424,7 +429,8 @@ fn labels(
6 => format!("Map {}", menu.maps[menu.selected_map].label),
7 => "Load map (restarts session)".into(),
8 => "Resume".into(),
_ => "Quit game".into(),
9 => "Quit game".into(),
_ => "Character customiser".into(),
};
}
***status = menu.status.clone();
+1 -1
View File
@@ -7,7 +7,7 @@ pub(crate) mod gesture_catalog;
mod gesture_mapping_data;
pub(crate) mod gesture_mapping;
pub(crate) mod gesture_input;
mod platform;
pub(crate) mod platform;
pub(crate) use controllers::{ControllerInput, ControllerStatus};
use skate_core::input::tick::TickInput;
+2 -2
View File
@@ -2,7 +2,7 @@
//! converter without Bevy/gilrs deadzones or normalized-axis reconstruction.
use skate_core::input::xbox::XboxState;
pub(super) struct DevicePacket {
pub(crate) struct DevicePacket {
pub number: u32,
pub state: XboxState,
pub subtype: u8,
@@ -95,7 +95,7 @@ mod windows {
}
}
pub(super) fn poll(index: usize) -> Result<DevicePacket, DeviceError> {
pub(crate) fn poll(index: usize) -> Result<DevicePacket, DeviceError> {
assert!(index < 4);
#[cfg(windows)]
return windows::poll(index as u32);
+1
View File
@@ -15,6 +15,7 @@ mod skater_animation;
mod verification;
mod performance;
mod graphics_menu;
mod customiser;
mod render_capacity;
mod retail_render;
mod presentation;
+6
View File
@@ -169,6 +169,12 @@ mod exchange_tests {
}
impl GamePhysics {
pub(crate) fn set_equipment_preferences(&mut self, truck: f32, wheel: f32) {
if truck.is_finite() && wheel.is_finite() {
self.animation_profile.truck_tightness = truck.clamp(0.0, 1.0);
self.animation_profile.wheel_hardness = wheel.clamp(0.0, 1.0);
}
}
pub(crate) fn set_difficulty(&mut self, difficulty: crate::difficulty::Difficulty) {
// Actor publication carries this selector into the next physical packet.
// Keep the board, active trick, equipment preferences and controller history.