urt-rs / crates /urt-gameplay /src /client_processing.rs
nmarshall's picture
gameplay: compose pre-movement client policies
99a6f51
Raw History Blame Contribute Delete
22 kB
//! Recovered pre-movement client policies with explicit captured callback effects.
use crate::native_policy::Error;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ProcessingEffect {
Intermission {
slot: i32,
},
Spectator {
slot: i32,
command_time: u32,
},
Team {
slot: i32,
text: Vec<u8>,
force: u32,
},
Send {
slot: i32,
text: Vec<u8>,
},
Drop {
slot: i32,
text: Vec<u8>,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ClientRoute {
Continue,
Intermission,
Spectator,
Return,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RoutingOutcome {
pub route: ClientRoute,
pub effects: Vec<ProcessingEffect>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RoutingInput {
pub intermission: u32,
pub team: u32,
pub follow: u32,
pub spectator: u32,
pub slot: i32,
pub command_time: u32,
}
/// Original154210 routing, stopping before command rewriting or at early return.
pub fn route_client(input: RoutingInput) -> Result<RoutingOutcome, Error> {
if !(0..64).contains(&input.slot) {
return Err(Error::InvalidIndex);
}
let (route, effects) = if input.intermission != 0 {
(
ClientRoute::Intermission,
vec![ProcessingEffect::Intermission { slot: input.slot }],
)
} else if input.team == 3 || input.follow != 0 {
if input.spectator == 3 {
(ClientRoute::Return, vec![])
} else {
(
ClientRoute::Spectator,
vec![ProcessingEffect::Spectator {
slot: input.slot,
command_time: input.command_time,
}],
)
}
} else {
(ClientRoute::Continue, vec![])
};
Ok(RoutingOutcome { route, effects })
}
#[cfg(test)]
mod routing_tests {
use super::*;
fn input() -> RoutingInput {
RoutingInput {
intermission: 0,
team: 0,
follow: 0,
spectator: 0,
slot: 2,
command_time: 12345,
}
}
#[test]
fn intermission_precedes_spectator_state() {
let p = route_client(RoutingInput {
intermission: 1,
team: 3,
follow: 1,
spectator: 3,
..input()
})
.unwrap();
assert_eq!(p.route, ClientRoute::Intermission);
assert_eq!(p.effects, [ProcessingEffect::Intermission { slot: 2 }]);
}
#[test]
fn spectator_word_only_suppresses_with_outer_gate() {
assert_eq!(
route_client(RoutingInput {
spectator: 3,
..input()
})
.unwrap()
.route,
ClientRoute::Continue
);
let p = route_client(RoutingInput {
team: 3,
spectator: 3,
..input()
})
.unwrap();
assert_eq!(p.route, ClientRoute::Return);
assert!(p.effects.is_empty());
assert_eq!(
route_client(RoutingInput {
follow: 1,
..input()
})
.unwrap()
.route,
ClientRoute::Spectator
);
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CommandState {
pub angles: [u32; 3],
pub saved: [u32; 3],
pub buttons: u32,
pub move_word: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CommandControl {
pub pause: u32,
pub mode: u32,
pub flags: u32,
pub block: u32,
}
/// Original154256: lock restores saved angles; otherwise refresh the saved copy.
/// Byte stores clear movement while preserving the adjacent low command byte.
pub fn rewrite_client_command(state: &mut CommandState, control: CommandControl) {
let locked = control.pause & 1 != 0 || control.mode == 10 && control.flags & 16 != 0;
if locked {
state.angles = state.saved;
} else {
state.saved = state.angles;
}
if locked || control.block != 0 {
state.buttons = 0;
state.move_word &= 255;
}
}
#[cfg(test)]
mod command_tests {
use super::*;
fn state() -> CommandState {
CommandState {
angles: [1, 2, 3],
saved: [4, 5, 6],
buttons: 7,
move_word: 0x807fff42,
}
}
#[test]
fn pause_bit_selection_and_angle_direction_are_preserved() {
let mut s = state();
rewrite_client_command(
&mut s,
CommandControl {
pause: 2,
mode: 0,
flags: 16,
block: 0,
},
);
assert_eq!(s.saved, [1, 2, 3]);
assert_eq!(s.move_word, 0x807fff42);
let mut s = state();
rewrite_client_command(
&mut s,
CommandControl {
pause: 1,
mode: 0,
flags: 0,
block: 0,
},
);
assert_eq!(s.angles, [4, 5, 6]);
assert_eq!(s.saved, [4, 5, 6]);
assert_eq!(s.move_word, 0x42);
assert_eq!(s.buttons, 0);
}
#[test]
fn server_override_masks_bytes_after_saving_angles() {
let mut s = state();
s.move_word = 0x010203ff;
rewrite_client_command(
&mut s,
CommandControl {
pause: 0,
mode: 0,
flags: 0,
block: 1,
},
);
assert_eq!(s.saved, [1, 2, 3]);
assert_eq!(s.angles, [1, 2, 3]);
assert_eq!(s.move_word, 255);
assert_eq!(s.buttons, 0);
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct InactivityState {
pub deadline: u32,
pub warned: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct InactivityInput {
pub entity_flags: u32,
pub idle_seconds: u32,
pub pause: u32,
pub pause_anchor: u32,
pub tick: u32,
pub moves: [u8; 3],
pub buttons: u32,
pub health: u32,
pub exempt: u32,
pub mode: u32,
pub msec: u32,
pub slot: i32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InactivityOutcome {
pub accepted: bool,
pub effects: Vec<ProcessingEffect>,
}
const WARN_DROP: &[u8] = b"cp \"Ten seconds until inactivity drop!\n\"";
const WARN_SPEC: &[u8] = b"cp \"Ten seconds until forced spectator mode!\n\"";
const IDLE_DROP: &[u8] = b"Dropped due to inactivity";
const IDLE_MOVED: &[u8] = b"cp \"You were moved to spectator due to inactivity!\n\"";
/// Original150382 with native team/send/drop effects captured. Arithmetic wraps
/// as words before signed comparisons; dead-client time extension precedes exemption.
pub fn client_inactivity(
state: &mut InactivityState,
input: InactivityInput,
) -> Result<InactivityOutcome, Error> {
if !(0..64).contains(&input.slot) {
return Err(Error::InvalidIndex);
}
let mut accepted = true;
let mut effects = vec![];
if input.entity_flags & 8 != 0 {
} else if input.idle_seconds == 0 {
state.deadline = input.tick.wrapping_add(60000);
state.warned = 0;
} else if input.pause != 0 {
let limit = input
.pause_anchor
.wrapping_add(input.idle_seconds.wrapping_mul(1000));
if (state.deadline as i32) < limit as i32 {
state.deadline = limit;
}
} else if input.moves.iter().any(|v| *v != 0) || input.buttons & 1 != 0 {
let limit = input
.tick
.wrapping_add(input.idle_seconds.wrapping_mul(1000));
if (state.deadline as i32) < limit as i32 {
state.deadline = limit;
}
state.warned = 0;
} else if input.health as i32 <= 0 {
state.deadline = state.deadline.wrapping_add(input.msec);
} else if input.exempt != 0 {
} else if input.tick as i32 > state.deadline as i32 {
accepted = false;
if input.mode == 1 {
effects.push(ProcessingEffect::Team {
slot: input.slot,
text: b"s".to_vec(),
force: 0,
});
effects.push(ProcessingEffect::Send {
slot: input.slot,
text: IDLE_MOVED.to_vec(),
});
} else {
effects.push(ProcessingEffect::Drop {
slot: input.slot,
text: IDLE_DROP.to_vec(),
});
}
} else if input.tick as i32 > state.deadline.wrapping_sub(10000) as i32 && state.warned == 0 {
state.warned = 1;
effects.push(ProcessingEffect::Send {
slot: input.slot,
text: if input.mode == 1 {
WARN_SPEC.to_vec()
} else {
WARN_DROP.to_vec()
},
});
}
Ok(InactivityOutcome { accepted, effects })
}
#[cfg(test)]
mod inactivity_tests {
use super::*;
fn input() -> InactivityInput {
InactivityInput {
entity_flags: 0,
idle_seconds: 60,
pause: 0,
pause_anchor: 3000,
tick: 10000,
moves: [0; 3],
buttons: 0,
health: 1,
exempt: 0,
mode: 1,
msec: 100,
slot: 2,
}
}
#[test]
fn warning_boundary_repeat_suppression_and_timeout_order() {
let mut s = InactivityState {
deadline: 20000,
warned: 0,
};
assert!(
client_inactivity(&mut s, input())
.unwrap()
.effects
.is_empty()
);
let i = InactivityInput {
tick: 10001,
..input()
};
assert_eq!(client_inactivity(&mut s, i).unwrap().effects.len(), 1);
assert_eq!(s.warned, 1);
assert!(client_inactivity(&mut s, i).unwrap().effects.is_empty());
let out = client_inactivity(&mut s, InactivityInput { tick: 20001, ..i }).unwrap();
assert!(!out.accepted);
assert!(matches!(
&out.effects[..],
[ProcessingEffect::Team { .. }, ProcessingEffect::Send { .. }]
));
}
#[test]
fn pause_and_activity_preserve_later_deadlines_and_dead_extension_wraps() {
let mut s = InactivityState {
deadline: 0,
warned: 1,
};
client_inactivity(
&mut s,
InactivityInput {
pause: 2,
..input()
},
)
.unwrap();
assert_eq!(
s,
InactivityState {
deadline: 63000,
warned: 1
}
);
s.deadline = 90000;
client_inactivity(
&mut s,
InactivityInput {
moves: [0, 128, 0],
..input()
},
)
.unwrap();
assert_eq!(s.deadline, 90000);
assert_eq!(s.warned, 0);
s.deadline = 20;
client_inactivity(
&mut s,
InactivityInput {
health: 0,
exempt: 1,
msec: u32::MAX,
..input()
},
)
.unwrap();
assert_eq!(s.deadline, 19);
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MovementState {
pub pm_type: u32,
pub gravity: u32,
pub speed: u32,
pub controller: bool,
pub packed: [u32; 2],
pub event_sequence: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MovementInput {
pub pause: u32,
pub noclip: u32,
pub health: u32,
pub gravity: u32,
pub factor: u32,
pub packs: [u32; 4],
}
fn trunc_word(value: f32) -> Result<u32, Error> {
if !value.is_finite() {
return Err(Error::NonFinite);
}
if !(-2147483648.0..2147483648.0).contains(&value) {
return Err(Error::InvalidInput);
}
Ok((value as i32) as u32)
}
/// Original154405 finite movement setup through154544. A controller is consumed
/// only on unpaused paths; raw packed words are not narrowed to bytes.
pub fn setup_client_movement(
state: &mut MovementState,
input: MovementInput,
) -> Result<u32, Error> {
let mut next = *state;
next.pm_type = if input.noclip != 0 {
1
} else if input.health as i32 <= 0 {
3
} else {
0
};
if input.pause & 1 != 0 {
next.gravity = 0;
next.speed = 0;
} else {
let base = f32::from_bits(input.gravity);
let value = if next.controller {
base * f32::from_bits(input.factor)
} else {
base
};
next.gravity = trunc_word(value)?;
next.speed = 220;
next.controller = false;
}
next.packed = [
input.packs[0] | input.packs[1].wrapping_shl(8),
input.packs[2] | input.packs[3].wrapping_shl(8),
];
let old = next.event_sequence;
*state = next;
Ok(old)
}
#[cfg(test)]
mod movement_tests {
use super::*;
fn state() -> MovementState {
MovementState {
pm_type: 99,
gravity: 111,
speed: 222,
controller: true,
packed: [333, 444],
event_sequence: 0xdeadbeef,
}
}
fn input() -> MovementInput {
MovementInput {
pause: 0,
noclip: 0,
health: 1,
gravity: 800.75f32.to_bits(),
factor: (-0.5f32).to_bits(),
packs: [0x1ff, 0x102, 0x80000000, 0xff0001],
}
}
#[test]
fn truncation_controller_consumption_and_unmasked_packing() {
let mut s = state();
assert_eq!(setup_client_movement(&mut s, input()).unwrap(), 0xdeadbeef);
assert_eq!(s.gravity, (-400i32) as u32);
assert_eq!(s.speed, 220);
assert!(!s.controller);
assert_eq!(s.packed, [0x103ff, 0xff000100]);
}
#[test]
fn paused_path_ignores_float_inputs_and_unsupported_unpaused_input_is_atomic() {
let mut s = state();
let i = MovementInput {
pause: 1,
noclip: 1,
health: 0,
gravity: f32::NAN.to_bits(),
factor: f32::NAN.to_bits(),
..input()
};
setup_client_movement(&mut s, i).unwrap();
assert_eq!([s.pm_type, s.gravity, s.speed], [1, 0, 0]);
assert!(s.controller);
let old = s;
assert!(setup_client_movement(&mut s, MovementInput { pause: 0, ..i }).is_err());
assert_eq!(s, old);
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ClientProcessingState {
pub timing: crate::client_timer::TimerPrepareState,
pub command: CommandState,
pub inactivity: InactivityState,
pub movement: MovementState,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ClientProcessingInput {
pub slot: i32,
pub tick: u32,
pub intermission: u32,
pub team: u32,
pub follow: u32,
pub pause: u32,
pub game_mode: u32,
pub client_flags: u32,
pub block: u32,
pub entity_flags: u32,
pub idle_seconds: u32,
pub pause_anchor: u32,
pub health: u32,
pub exempt: u32,
pub idle_mode: u32,
pub noclip: u32,
pub gravity: u32,
pub factor: u32,
pub packs: [u32; 4],
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ClientProcessingOutcome {
pub proceed: bool,
pub msec: Option<u32>,
pub idle_result: Option<bool>,
pub idle_msec: Option<u32>,
pub event_sequence: Option<u32>,
pub effects: Vec<ProcessingEffect>,
}
fn process_inner(
state: &mut ClientProcessingState,
input: ClientProcessingInput,
) -> Result<ClientProcessingOutcome, Error> {
let prepared = crate::client_timer::prepare_client_timing(&mut state.timing, input.tick);
let mut out = ClientProcessingOutcome {
proceed: false,
msec: prepared.msec,
idle_result: None,
idle_msec: None,
event_sequence: None,
effects: vec![],
};
if !prepared.proceed {
return Ok(out);
}
let routed = route_client(RoutingInput {
intermission: input.intermission,
team: input.team,
follow: input.follow,
spectator: state.timing.exception,
slot: input.slot,
command_time: state.timing.command_time,
})?;
out.effects.extend(routed.effects);
if routed.route != ClientRoute::Continue {
return Ok(out);
}
rewrite_client_command(
&mut state.command,
CommandControl {
pause: input.pause,
mode: input.game_mode,
flags: input.client_flags,
block: input.block,
},
);
let bytes = state.command.move_word.to_le_bytes();
let msec = prepared
.msec
.expect("proceeding source preparation has a delta");
let idle = client_inactivity(
&mut state.inactivity,
InactivityInput {
entity_flags: input.entity_flags,
idle_seconds: input.idle_seconds,
pause: input.pause,
pause_anchor: input.pause_anchor,
tick: input.tick,
moves: [bytes[1], bytes[2], bytes[3]],
buttons: state.command.buttons,
health: input.health,
exempt: input.exempt,
mode: input.idle_mode,
msec,
slot: input.slot,
},
)?;
out.idle_result = Some(idle.accepted);
out.idle_msec = Some(msec);
out.effects.extend(idle.effects);
if !idle.accepted {
return Ok(out);
}
out.event_sequence = Some(setup_client_movement(
&mut state.movement,
MovementInput {
pause: input.pause,
noclip: input.noclip,
health: input.health,
gravity: input.gravity,
factor: input.factor,
packs: input.packs,
},
)?);
out.proceed = true;
Ok(out)
}
/// Original154089 through154544 with captured callback effects. Ordinary early
/// exits commit preceding writes; unsupported typed domains roll back together.
pub fn client_pre_movement(
state: &mut ClientProcessingState,
input: ClientProcessingInput,
) -> Result<ClientProcessingOutcome, Error> {
if !(0..64).contains(&input.slot) {
return Err(Error::InvalidIndex);
}
let mut next = *state;
let out = process_inner(&mut next, input)?;
*state = next;
Ok(out)
}
#[cfg(test)]
mod pipeline_tests {
use super::*;
fn state() -> ClientProcessingState {
ClientProcessingState {
timing: crate::client_timer::TimerPrepareState {
connection: 2,
exception: 0,
command_time: 10050,
current_time: 10000,
quantize: 1,
copied_tick: 17,
copied_command: 18,
},
command: CommandState {
angles: [11, 22, 33],
saved: [44, 55, 66],
buttons: 0,
move_word: 0x12a,
},
inactivity: InactivityState {
deadline: 9999,
warned: 0,
},
movement: MovementState {
pm_type: 99,
gravity: 111,
speed: 222,
controller: false,
packed: [333, 444],
event_sequence: 9,
},
}
}
fn input() -> ClientProcessingInput {
ClientProcessingInput {
slot: 2,
tick: 10000,
intermission: 0,
team: 0,
follow: 0,
pause: 0,
game_mode: 0,
client_flags: 0,
block: 0,
entity_flags: 0,
idle_seconds: 60,
pause_anchor: 3000,
health: 1,
exempt: 0,
idle_mode: 0,
noclip: 0,
gravity: 800.75f32.to_bits(),
factor: 0.5f32.to_bits(),
packs: [0x1ff, 0x102, 0x80000000, 0xff0001],
}
}
#[test]
fn blocked_movement_times_out_after_preserving_earlier_writes() {
let mut s = state();
let p = client_pre_movement(
&mut s,
ClientProcessingInput {
block: 1,
..input()
},
)
.unwrap();
assert!(!p.proceed);
assert_eq!(p.idle_result, Some(false));
assert_eq!(p.idle_msec, Some(50));
assert!(matches!(&p.effects[..], [ProcessingEffect::Drop { .. }]));
assert_eq!(s.command.saved, [11, 22, 33]);
assert_eq!(s.command.move_word, 42);
assert_eq!(s.timing.command_time, 10056);
assert_eq!(s.movement.pm_type, 99);
let mut s = state();
assert!(client_pre_movement(&mut s, input()).unwrap().proceed);
assert_eq!(s.inactivity.deadline, 70000);
}
#[test]
fn routed_exit_commits_timestamps_but_unsupported_late_input_is_atomic() {
let mut s = state();
let command = s.command;
let p = client_pre_movement(
&mut s,
ClientProcessingInput {
intermission: 1,
..input()
},
)
.unwrap();
assert!(!p.proceed);
assert_eq!(s.command, command);
assert_eq!(s.timing.copied_command, 10050);
assert!(matches!(
&p.effects[..],
[ProcessingEffect::Intermission { .. }]
));
let mut s = state();
s.command.move_word = 42;
s.inactivity.deadline = 19999;
let old = s;
assert!(
client_pre_movement(
&mut s,
ClientProcessingInput {
gravity: f32::NAN.to_bits(),
..input()
}
)
.is_err()
);
assert_eq!(s, old);
}
}