Compare commits

..
Author SHA1 Message Date
EIREXE 819c680a79 Fix GameCube poll thread shutdown and backoff
CI / build-and-test (pull_request) Successful in 1m36s
CI / production-build (pull_request) Successful in 9m24s
2026-09-21 16:17:04 +02:00
EIREXE f7bb8cc345 Remove UI multi cam setup (#54)
CI / build-and-test (push) Successful in 1m19s
CI / production-build (push) Successful in 9m38s
Reviewed-on: #54
Co-authored-by: Álex Román Núñez <eirexe@eirteam.moe>
2026-09-20 17:34:11 +00:00
EIREXE 85236c4e0f Optimize rollback hot paths (#53)
CI / build-and-test (push) Successful in 1m16s
CI / production-build (push) Successful in 9m36s
## Summary

Fixes #20.

- Reuse fighter animation scratch and output buffers across rollback frames.
- Select air-collision hits in one pass without candidate allocations or sorting.
- Index contiguous replay input frames directly and binary-search checksums.
- Add regression tests for buffer reuse and replay lookup/validation.

Reviewed-on: #53
Co-authored-by: Álex Román Núñez <eirexe@eirteam.moe>
2026-09-20 12:41:13 +00:00
4 changed files with 90 additions and 26 deletions
+74 -10
View File
@@ -1,6 +1,8 @@
use std::panic::{self, AssertUnwindSafe}; use std::panic::{self, AssertUnwindSafe};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use bevy::prelude::*; use bevy::prelude::*;
use gc_adapter_neo::{Controller, GcAdapter}; use gc_adapter_neo::{Controller, GcAdapter};
@@ -55,10 +57,11 @@ impl GcPortState {
/// Shared, `Send + Sync` handle to the latest snapshot from the poll /// Shared, `Send + Sync` handle to the latest snapshot from the poll
/// thread. Safe to store as an ordinary [`Resource`] (no `NonSend` needed) /// thread. Safe to store as an ordinary [`Resource`] (no `NonSend` needed)
/// because the `GcAdapter` itself never leaves its thread. /// because the `GcAdapter` itself never leaves its thread.
#[derive(Resource, Clone)] #[derive(Resource)]
pub struct GcAdapterSnapshot { pub struct GcAdapterSnapshot {
ports: Arc<Mutex<[GcPortState; 4]>>, ports: Arc<Mutex<[GcPortState; 4]>>,
shutdown: Arc<AtomicBool>, shutdown: Arc<AtomicBool>,
poll_thread: Option<JoinHandle<()>>,
} }
impl GcAdapterSnapshot { impl GcAdapterSnapshot {
@@ -69,12 +72,36 @@ impl GcAdapterSnapshot {
impl Drop for GcAdapterSnapshot { impl Drop for GcAdapterSnapshot {
fn drop(&mut self) { fn drop(&mut self) {
// Only actually signals shutdown once the last clone (this resource self.shutdown.store(true, Ordering::Release);
// is never cloned in practice, but be defensive) is dropped.
if Arc::strong_count(&self.shutdown) <= 1 { if let Some(poll_thread) = self.poll_thread.take() {
self.shutdown.store(true, Ordering::Relaxed); if let Err(payload) = poll_thread.join() {
error!(
"GameCube Controller Adapter poll thread panicked during shutdown: {payload:?}"
);
} }
} }
}
}
const INITIAL_ERROR_BACKOFF: Duration = Duration::from_millis(1);
const MAX_ERROR_BACKOFF: Duration = Duration::from_millis(100);
fn next_error_backoff(backoff: Duration) -> Duration {
backoff.saturating_mul(2).min(MAX_ERROR_BACKOFF)
}
/// Wait for a retry while still allowing the owner to shut the poll thread down promptly.
fn wait_for_retry(shutdown: &AtomicBool, delay: Duration) -> bool {
let deadline = Instant::now() + delay;
while !shutdown.load(Ordering::Acquire) {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return true;
}
std::thread::sleep(remaining.min(Duration::from_millis(10)));
}
false
} }
/// Connects to the first GameCube adapter found on the USB bus and starts /// Connects to the first GameCube adapter found on the USB bus and starts
@@ -94,7 +121,7 @@ pub fn spawn_poll_thread() -> Result<GcAdapterSnapshot, gc_adapter_neo::UsbError
let thread_ports = ports.clone(); let thread_ports = ports.clone();
let thread_shutdown = shutdown.clone(); let thread_shutdown = shutdown.clone();
std::thread::Builder::new() let poll_thread = std::thread::Builder::new()
.name("gc-adapter-poll".into()) .name("gc-adapter-poll".into())
.spawn(move || { .spawn(move || {
// Flush whatever the adapter had buffered before we started // Flush whatever the adapter had buffered before we started
@@ -107,17 +134,27 @@ pub fn spawn_poll_thread() -> Result<GcAdapterSnapshot, gc_adapter_neo::UsbError
let mut rate_window_start = std::time::Instant::now(); let mut rate_window_start = std::time::Instant::now();
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
let mut rate_window_count: u32 = 0; let mut rate_window_count: u32 = 0;
let mut error_backoff = INITIAL_ERROR_BACKOFF;
while !thread_shutdown.load(Ordering::Relaxed) { while !thread_shutdown.load(Ordering::Acquire) {
let result = panic::catch_unwind(AssertUnwindSafe(|| adapter.read_controllers())); let result = panic::catch_unwind(AssertUnwindSafe(|| adapter.read_controllers()));
let controllers = match result { let controllers = match result {
Ok(Ok(controllers)) => controllers, Ok(Ok(controllers)) => {
error_backoff = INITIAL_ERROR_BACKOFF;
controllers
}
// Ignore this, for some reason we get a bunch of these when we start reading // Ignore this, for some reason we get a bunch of these when we start reading
Ok(Err( Ok(Err(
gc_adapter_neo::AdapterError::Parse(_) gc_adapter_neo::AdapterError::Parse(_)
| gc_adapter_neo::AdapterError::ShortRead(_), | gc_adapter_neo::AdapterError::ShortRead(_),
)) => continue, )) => {
if !wait_for_retry(&thread_shutdown, error_backoff) {
break;
}
error_backoff = next_error_backoff(error_backoff);
continue;
}
Ok(Err(gc_adapter_neo::AdapterError::Usb(err))) => { Ok(Err(gc_adapter_neo::AdapterError::Usb(err))) => {
error!( error!(
"GameCube Controller Adapter USB read failed ({err:?}); stopping \ "GameCube Controller Adapter USB read failed ({err:?}); stopping \
@@ -160,5 +197,32 @@ pub fn spawn_poll_thread() -> Result<GcAdapterSnapshot, gc_adapter_neo::UsbError
}) })
.expect("failed to spawn gc-adapter poll thread"); .expect("failed to spawn gc-adapter poll thread");
Ok(GcAdapterSnapshot { ports, shutdown }) Ok(GcAdapterSnapshot {
ports,
shutdown,
poll_thread: Some(poll_thread),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_backoff_is_capped() {
let mut backoff = INITIAL_ERROR_BACKOFF;
for _ in 0..16 {
backoff = next_error_backoff(backoff);
}
assert_eq!(backoff, MAX_ERROR_BACKOFF);
assert_eq!(next_error_backoff(MAX_ERROR_BACKOFF), MAX_ERROR_BACKOFF);
}
#[test]
fn retry_wait_stops_when_shutdown_is_requested() {
let shutdown = AtomicBool::new(true);
assert!(!wait_for_retry(&shutdown, Duration::from_secs(1)));
}
} }
+1 -1
View File
@@ -241,7 +241,7 @@ pub fn copy_cursor_transform_to_token(
character_boxes: Query<(&CharacterBox, &ComputedNode, &UiGlobalTransform)>, character_boxes: Query<(&CharacterBox, &ComputedNode, &UiGlobalTransform)>,
match_config: Res<MenuMatchConfig>, match_config: Res<MenuMatchConfig>,
cursors: Query<&UiGlobalTransform, With<FGMenuCursor>>, cursors: Query<&UiGlobalTransform, With<FGMenuCursor>>,
cam: Single<&Camera, With<IsDefaultUiCamera>>, cam: Single<&Camera>,
ui_scale: Res<UiScale>, ui_scale: Res<UiScale>,
) -> Result { ) -> Result {
let camera = cam.into_inner(); let camera = cam.into_inner();
+1 -1
View File
@@ -181,7 +181,7 @@ pub fn cursor_input(
} }
pub fn update_cursor_transform( pub fn update_cursor_transform(
camera: Single<&Camera, With<IsDefaultUiCamera>>, camera: Single<&Camera>,
query: Query<(&mut Node, &PointerLocation), With<FGMenuCursor>>, query: Query<(&mut Node, &PointerLocation), With<FGMenuCursor>>,
ui_scale: Res<UiScale>, ui_scale: Res<UiScale>,
) { ) {
+14 -14
View File
@@ -25,6 +25,8 @@ pub mod style;
#[derive(Component, Clone, Default)] #[derive(Component, Clone, Default)]
pub struct MenuMarker {} pub struct MenuMarker {}
#[derive(Component, Clone, Default)]
pub struct MenuCameraMarker {}
pub struct MenuPlugin; pub struct MenuPlugin;
@@ -38,6 +40,7 @@ pub fn wrap_menu(menu: impl Scene) -> impl Scene {
bsn! { bsn! {
#MenuRoot #MenuRoot
MenuMarker MenuMarker
Transform
Node { Node {
width: percent(100), width: percent(100),
height: percent(100), height: percent(100),
@@ -46,6 +49,11 @@ pub fn wrap_menu(menu: impl Scene) -> impl Scene {
flex_direction: FlexDirection::Column, flex_direction: FlexDirection::Column,
} }
Children [ Children [
(
#MenuCamera
Camera
Camera2d
),
( (
#MenuContainer #MenuContainer
Node { Node {
@@ -65,15 +73,18 @@ pub fn wrap_menu(menu: impl Scene) -> impl Scene {
pub fn despawn_menu( pub fn despawn_menu(
existing_menu: Option<Single<Entity, With<MenuMarker>>>, existing_menu: Option<Single<Entity, With<MenuMarker>>>,
existing_menu_camera: Option<Single<Entity, With<MenuCameraMarker>>>,
mut commands: Commands, mut commands: Commands,
) { ) {
if let Some(entity) = existing_menu { if let Some(entity) = existing_menu {
commands commands
.entity(entity.into_inner()) .entity(entity.into_inner())
.remove::<Camera2d>()
.remove::<Camera>()
.despawn(); .despawn();
} }
if let Some(menu_cam) = existing_menu_camera {
commands.entity(*menu_cam).despawn();
}
} }
pub fn spawn_menu( pub fn spawn_menu(
@@ -84,8 +95,6 @@ pub fn spawn_menu(
if let Some(entity) = existing_menu { if let Some(entity) = existing_menu {
commands commands
.entity(entity.into_inner()) .entity(entity.into_inner())
.remove::<Camera2d>()
.remove::<Camera>()
.despawn(); .despawn();
} }
commands.queue_spawn_scene(wrap_menu(menu)); commands.queue_spawn_scene(wrap_menu(menu));
@@ -113,16 +122,7 @@ impl Plugin for MenuPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_systems( app.add_systems(
OnEnter(AppState::MainMenu), OnEnter(AppState::MainMenu),
(build_menu::<MainMenu>, |mut commands: Commands| { build_menu::<MainMenu>,
commands.spawn((Camera2d, IsDefaultUiCamera));
commands.spawn((
Camera2d,
Camera {
order: 1,
..default()
},
));
}),
) )
.add_message::<character_select::PlayerSlotAssigned>() .add_message::<character_select::PlayerSlotAssigned>()
.init_resource::<navigation::UINavigationActionState>() .init_resource::<navigation::UINavigationActionState>()