diff --git a/crates/asset_anim/src/animtree.rs b/crates/asset_anim/src/animtree.rs index f4eb1be..0e41025 100644 --- a/crates/asset_anim/src/animtree.rs +++ b/crates/asset_anim/src/animtree.rs @@ -106,10 +106,30 @@ impl PlayerAnimSources { self.multiplayer_atr.as_deref(), self.playeranim_script.as_deref(), )); - if let Some(Ok(tree)) = self.compiled.as_ref() { - if let Some(script) = self.playeranim_script.as_deref() { + if let Some(Ok(tree)) = self.compiled.as_ref() + && let Some(script) = self.playeranim_script.as_deref() + { + self.parsed_script = Some(crate::playeranim_parse::parse_player_anim_script( + script, + tree, + self.family == Some(asset_core::AssetNamespace::T5), + )); + } + if self.family == Some(asset_core::AssetNamespace::T5) + && let (Some(Ok(tree)), Some(Ok(script)), Some(atr)) = ( + self.compiled.as_ref(), + self.parsed_script.as_ref(), + self.multiplayer_atr.as_deref(), + ) + { + self.compiled = Some(crate::atr_compile::compile_selected_multiplayer( + atr, tree, script, + )); + if let Some(Ok(tree)) = self.compiled.as_ref() + && let Some(script) = self.playeranim_script.as_deref() + { self.parsed_script = Some(crate::playeranim_parse::parse_player_anim_script( - script, tree, + script, tree, true, )); } } diff --git a/crates/asset_anim/src/atr_compile.rs b/crates/asset_anim/src/atr_compile.rs index eddfe03..3676446 100644 --- a/crates/asset_anim/src/atr_compile.rs +++ b/crates/asset_anim/src/atr_compile.rs @@ -11,6 +11,8 @@ use crate::animtree::{ PLAYERANIM_SCRIPT_PATH, }; +const UNSUPPORTED_BRANCH: u16 = 1 << 15; + const BG_FIND_ANIM_NAMES: &[&str] = &["torso", "legs", "turning"]; pub(crate) fn compile_multiplayer( @@ -28,6 +30,54 @@ pub(crate) fn compile_multiplayer( names.insert((*name).to_owned()); } harvest_script_anim_names(script, &mut names); + compile_named_tree(atr, names) +} + +pub(crate) fn compile_selected_multiplayer( + atr: &[u8], + tree: &CompiledAnimTreeDefinition, + script: &crate::ParsedPlayerAnimScript, +) -> Result, AtrCompileError> { + let mut names: HashSet = BG_FIND_ANIM_NAMES + .iter() + .map(|name| (*name).into()) + .collect(); + for (_, _, items) in &script.slots { + add_selected_names(&mut names, tree, items); + } + for (_, items) in &script.events { + add_selected_names(&mut names, tree, items); + } + let tree = compile_named_tree(atr, names)?; + if tree.node_count() > usize::from(anim_iw4::PLAYER_ANIM_INDEX_MASK) + 1 { + return Err(AtrCompileError::BadToken { + offset: 0, + message: "character animation tree exceeds packed index capacity".into(), + }); + } + Ok(tree) +} + +fn add_selected_names( + names: &mut HashSet, + tree: &CompiledAnimTreeDefinition, + items: &[crate::ParsedAnimItem], +) { + for command in items + .iter() + .filter(|item| !item.skip) + .flat_map(|item| &item.commands) + { + if let Some(node) = tree.node(command.anim_index) { + names.insert(node.name.clone()); + } + } +} + +fn compile_named_tree( + atr: &[u8], + names: HashSet, +) -> Result, AtrCompileError> { let script_names = names.len(); let mut ignored = 0usize; let children = parse_atr(atr, &names, &mut ignored)?; @@ -114,7 +164,22 @@ fn parse_internal( return Err(AtrCompileError::DuplicateAnimation { name }); } let ignore = !complete && !names.contains(&name); - let on_line = parser.parse(false); + let mut on_line = parser.parse(false); + if on_line == "[" { + let key = parser.parse(false); + let equals = parser.parse(false); + let value = parser.parse(false).parse::(); + let close = parser.parse(false); + if !key.eq_ignore_ascii_case("turn") + || equals != "=" + || value.is_err() + || value.is_ok_and(|value| !value.is_finite()) + || close != "]" + { + return Err(parser.bad_token("invalid animation turn annotation")); + } + on_line = parser.parse(false); + } if on_line.is_empty() { current = Some(WorkAnim { name, @@ -130,7 +195,7 @@ fn parse_internal( if on_line != ":" { return Err(parser.bad_token("bad token")); } - let flags = parse_properties(parser)?; + let flags = parse_properties(parser, ignore)?; let brace = parser.parse(true); if brace != "{" { return Err( @@ -169,6 +234,9 @@ fn parse_internal( if nested_eof { return Err(parser.bad_token("unexpected end of file")); } + if !nested.is_empty() && work.flags & UNSUPPORTED_BRANCH != 0 { + return Err(parser.bad_token("unsupported property on retained animation branch")); + } if nested.is_empty() { *ignored += 1; } else { @@ -239,7 +307,7 @@ fn finish_parent(children: &mut Vec, include_parent: bool, loop_sync } } -fn parse_properties(parser: &mut AtrParser<'_>) -> Result { +fn parse_properties(parser: &mut AtrParser<'_>, ignored: bool) -> Result { let mut flags = 0u16; loop { let token = parser.parse(false); @@ -254,6 +322,12 @@ fn parse_properties(parser: &mut AtrParser<'_>) -> Result Some(1) => flags |= ANIMFLAG_NONLOOPSYNC, Some(2) => flags |= ANIMFLAG_COMPLETE, Some(3) => flags |= ANIMFLAG_ADDITIVE, + _ if ignored + && (token.eq_ignore_ascii_case("client") + || token.eq_ignore_ascii_case("separate")) => + { + flags |= UNSUPPORTED_BRANCH + } _ => return Err(parser.bad_token("unknown anim property")), } } diff --git a/crates/asset_anim/src/playeranim_parse.rs b/crates/asset_anim/src/playeranim_parse.rs index f6ce00a..ebd1256 100644 --- a/crates/asset_anim/src/playeranim_parse.rs +++ b/crates/asset_anim/src/playeranim_parse.rs @@ -133,6 +133,7 @@ impl core::fmt::Display for PlayerAnimParseError { pub(crate) fn parse_player_anim_script( script: &[u8], tree: &CompiledAnimTreeDefinition, + t5_syntax: bool, ) -> Result, PlayerAnimParseError> { let mut parser = AtrParser::new(script); let mut parsed = ParsedPlayerAnimScript { @@ -167,7 +168,7 @@ pub(crate) fn parse_player_anim_script( } if mode == 0 { if token.eq_ignore_ascii_case("set") { - parse_define(&mut parser, &mut aliases)?; + parse_define(&mut parser, &mut aliases, t5_syntax)?; } continue; } @@ -207,7 +208,7 @@ pub(crate) fn parse_player_anim_script( } 1 => { parser.unget(); - let item = parse_item(&mut parser, tree, &mut parsed, &aliases)?; + let item = parse_item(&mut parser, tree, &mut parsed, &aliases, t5_syntax)?; if let Some(items) = current_event_items.as_mut() { if items.len() >= 128 { return Err(bad(&parser, "exceeded maximum items per script (128)")); @@ -246,7 +247,7 @@ pub(crate) fn parse_player_anim_script( } 2 => { parser.unget(); - let item = parse_item(&mut parser, tree, &mut parsed, &aliases)?; + let item = parse_item(&mut parser, tree, &mut parsed, &aliases, t5_syntax)?; if let Some(items) = current_items.as_mut() { if items.len() >= 128 { return Err(bad(&parser, "exceeded maximum items per script (128)")); @@ -293,8 +294,9 @@ fn parse_item( tree: &CompiledAnimTreeDefinition, parsed: &mut ParsedPlayerAnimScript, aliases: &HashMap<(u8, String), u64>, + t5_syntax: bool, ) -> Result { - let (skip, conditions, raw) = parse_conditions(parser, aliases)?; + let (skip, conditions, raw) = parse_conditions(parser, aliases, t5_syntax)?; let brace = parser.parse(true); if brace != "{" { parser.unget(); @@ -336,7 +338,20 @@ fn parse_item( ); } else if extra.eq_ignore_ascii_case("sound") { let _ = parser.parse(false); - } else if extra.eq_ignore_ascii_case("turretanim") { + } else if t5_syntax + && (extra.eq_ignore_ascii_case("blendouttime") + || extra.eq_ignore_ascii_case("animrate")) + { + let value = parser.parse(false).parse::(); + if value.is_err() || value.is_ok_and(|value| !value.is_finite()) { + return Err(bad(parser, "expected animation modifier value")); + } + } else if extra.eq_ignore_ascii_case("turretanim") + || (t5_syntax + && (extra.eq_ignore_ascii_case("grenadeanim") + || extra.eq_ignore_ascii_case("weapontimescale") + || extra.eq_ignore_ascii_case("allowmovementinterrupt"))) + { continue; } else { parser.unget(); @@ -355,7 +370,8 @@ fn parse_item( blend_ms, }); } - None => parsed.unresolved_anims += 1, + None if !skip => parsed.unresolved_anims += 1, + None => {} } } Ok(ParsedAnimItem { @@ -369,9 +385,14 @@ fn parse_item( fn parse_define( parser: &mut AtrParser<'_>, aliases: &mut HashMap<(u8, String), u64>, + t5_syntax: bool, ) -> Result<(), PlayerAnimParseError> { let cond = parser.parse(false); let Some(index) = index_ci(&cond, ANIM_COND_NAMES) else { + if t5_syntax { + while !parser.parse(false).is_empty() {} + return Ok(()); + } return Err(bad(parser, "unknown define condition")); }; let alias = ascii_lower(&parser.parse(false)); @@ -449,6 +470,7 @@ fn resolve_cond_value( fn parse_conditions( parser: &mut AtrParser<'_>, aliases: &HashMap<(u8, String), u64>, + t5_syntax: bool, ) -> Result<(bool, Vec, String), PlayerAnimParseError> { let mut skip = false; let mut saw = false; @@ -478,6 +500,33 @@ fn parse_conditions( saw = true; continue; } + if t5_syntax + && (token.eq_ignore_ascii_case("stance") || token.eq_ignore_ascii_case("direction")) + { + if let Some(cond) = current.take() { + conditions.push(cond); + } + let value = parser.parse(false); + raw.push(value.clone()); + saw = true; + if let Some(condition) = t5_pose_condition(&token, &value) { + conditions.push(condition); + if token.eq_ignore_ascii_case("direction") + && (value.eq_ignore_ascii_case("forward") + || value.eq_ignore_ascii_case("backward")) + { + conditions.push(ParsedAnimCondition { + index: anim_iw4::ANIM_COND_STRAFING, + bitflags: false, + bits: 0, + value: 0, + }); + } + } else { + skip = true; + } + continue; + } if let Some(cond) = current.as_mut() && let Some(value) = resolve_cond_value(cond.index, &token, aliases) { @@ -538,6 +587,11 @@ fn parse_conditions( skip = true; continue; } + if t5_syntax { + skip = true; + saw = true; + continue; + } return Err(bad(parser, "unknown condition token")); } if let Some(cond) = current.take() { @@ -549,6 +603,34 @@ fn parse_conditions( Ok((skip, conditions, raw.join(" "))) } +fn t5_pose_condition(name: &str, value: &str) -> Option { + let mask = |indices: &[u8]| { + indices + .iter() + .fold(0u64, |bits, index| bits | (1u64 << index)) + }; + let crouch = mask(&[2, 6, 7, 12, 13, 16, 17]); + let prone = mask(&[3, 8, 9]); + let backward = mask(&[5, 7, 9, 11, 13]); + let (index, bitflags, bits, value) = + match (ascii_lower(name).as_str(), ascii_lower(value).as_str()) { + ("stance", "crouch") => (anim_iw4::ANIM_COND_MOVETYPE, true, crouch, 0), + ("stance", "prone") => (anim_iw4::ANIM_COND_MOVETYPE, true, prone, 0), + ("stance", "stand") => (anim_iw4::ANIM_COND_MOVETYPE, true, !(crouch | prone), 0), + ("direction", "backward") => (anim_iw4::ANIM_COND_MOVETYPE, true, backward, 0), + ("direction", "forward") => (anim_iw4::ANIM_COND_MOVETYPE, true, !backward, 0), + ("direction", "left") => (anim_iw4::ANIM_COND_STRAFING, false, 0, 1), + ("direction", "right") => (anim_iw4::ANIM_COND_STRAFING, false, 0, 2), + _ => return None, + }; + Some(ParsedAnimCondition { + index, + bitflags, + bits, + value, + }) +} + fn skip_unknown_block(parser: &mut AtrParser<'_>) { let mut depth = 0i32; loop { diff --git a/crates/assets/src/lane/sink.rs b/crates/assets/src/lane/sink.rs index 3825494..f2749cc 100644 --- a/crates/assets/src/lane/sink.rs +++ b/crates/assets/src/lane/sink.rs @@ -946,6 +946,12 @@ impl fastfile_t5::AssetLinkSink for CommonWalkSink { { self.teamsets.insert(key, icons); } + self.player_anim_sources.capture( + asset_core::AssetNamespace::T5, + name, + data, + zlib_compressed, + ); self.scripts.capture(name, data, zlib_compressed); Ok(()) } diff --git a/crates/assets/src/lane/t5.rs b/crates/assets/src/lane/t5.rs index c66613b..06a9049 100644 --- a/crates/assets/src/lane/t5.rs +++ b/crates/assets/src/lane/t5.rs @@ -679,6 +679,7 @@ impl ZoneLane for T5Lane { projectile_meshes: sink.projectile_meshes, world_weapons: sink.world_weapons, xanims: sink.xanims, + player_anim_sources: sink.player_anim_sources, fx: sink.fx, impact_fx: sink.impact_fx.take_table(), fx_models: sink.fx_models, diff --git a/crates/assets/src/session_load/common_cache.rs b/crates/assets/src/session_load/common_cache.rs index fea767f..54ad656 100644 --- a/crates/assets/src/session_load/common_cache.rs +++ b/crates/assets/src/session_load/common_cache.rs @@ -95,6 +95,7 @@ pub(super) struct CommonProducts { pub(super) fx_models: asset_game::FxModelCatalog, pub(super) impact_fx: Option, pub(super) t5_xanims: XAnimBuild, + pub(super) t5_player_anim_sources: asset_anim::PlayerAnimSources, pub(super) t5_fx: FxCatalog, pub(super) t5_impact_fx: Option, pub(super) iw5_materials: MaterialCatalog, @@ -468,6 +469,7 @@ async fn prepare_common(key: CommonKey) -> Arc { world_guns: t5_world_guns, mut material_seed, xanims: t5_xanims, + player_anim_sources: t5_player_anim_sources, fx: t5_fx, impact_fx: t5_impact_fx, projectiles: t5_projectiles, @@ -892,6 +894,7 @@ async fn prepare_common(key: CommonKey) -> Arc { fx_models: common_fx_models, impact_fx: common_impact, t5_xanims, + t5_player_anim_sources, t5_fx, t5_impact_fx, iw5_materials, diff --git a/crates/assets/src/session_load/common_walks.rs b/crates/assets/src/session_load/common_walks.rs index bacdd88..63f5acb 100644 --- a/crates/assets/src/session_load/common_walks.rs +++ b/crates/assets/src/session_load/common_walks.rs @@ -740,6 +740,7 @@ pub(super) struct T5WeaponCommon { pub(super) world_guns: WorldWeaponBuild, pub(super) material_seed: MaterialCatalog, pub(super) xanims: XAnimBuild, + pub(super) player_anim_sources: asset_anim::PlayerAnimSources, pub(super) fx: FxCatalog, pub(super) impact_fx: Option, pub(super) projectiles: asset_model::ProjectileMeshBuild, @@ -761,6 +762,7 @@ impl T5WeaponCommon { world_guns: WorldWeaponBuild::default(), material_seed, xanims: XAnimBuild::default(), + player_anim_sources: asset_anim::PlayerAnimSources::default(), fx: FxCatalog::default(), impact_fx: None, projectiles: asset_model::ProjectileMeshBuild::default(), @@ -827,6 +829,7 @@ pub(super) fn walk_t5_weapon_common( world_guns: census.world_weapons, material_seed: census.material_population, xanims: census.xanims, + player_anim_sources: census.player_anim_sources, fx: census.fx, impact_fx: census.impact_fx, projectiles: census.projectile_meshes, diff --git a/crates/assets/src/session_load/match_walk.rs b/crates/assets/src/session_load/match_walk.rs index ba60f74..7394f0b 100644 --- a/crates/assets/src/session_load/match_walk.rs +++ b/crates/assets/src/session_load/match_walk.rs @@ -90,6 +90,7 @@ pub(super) async fn walk_prepared_match( fx_models: common_fx_models, impact_fx: common_impact, t5_xanims, + t5_player_anim_sources, t5_fx, t5_impact_fx, iw5_materials, @@ -184,6 +185,12 @@ pub(super) async fn walk_prepared_match( gaps, } = loaded; world.source_namespace = map_namespace; + if map_family == asset_core::FamilyId::T5 { + player_anim_sources = t5_player_anim_sources; + player_anim_sources.compile(); + report.push(player_anim_sources.compile_report_line()); + report.push(player_anim_sources.parse_report_line()); + } report.append(&mut common_report); report.extend(image_trees.report_lines()); diff --git a/crates/audio/src/runtime.rs b/crates/audio/src/runtime.rs index c8804e2..d996862 100644 --- a/crates/audio/src/runtime.rs +++ b/crates/audio/src/runtime.rs @@ -303,18 +303,6 @@ impl AudioRuntime { } pub(crate) fn set_event_context(&self, context: Option) { - let previous = self.event_context.get(); - if previous - .zip(context) - .is_some_and(|(old, new)| old.world != new.world || old.timeline != new.timeline) - { - self.cue_cancellation.cancel( - None, - None, - None, - self.shared.match_epoch.load(Ordering::Acquire), - ); - } self.event_context.set(context); } diff --git a/crates/gamemode_iw4/src/lib.rs b/crates/gamemode_iw4/src/lib.rs index 02e9b28..0e0cf62 100644 --- a/crates/gamemode_iw4/src/lib.rs +++ b/crates/gamemode_iw4/src/lib.rs @@ -58,4 +58,4 @@ pub use score::Score; pub use stuck_in_client::{G_PLAYER_COLLISION_EJECT_SPEED_DEFAULT, StuckClient, stuck_in_client}; pub use suicide::is_really_alive; -pub use teams::{TEAM_COLOR_ENEMY_TEAM, TEAM_COLOR_MY_TEAM}; +pub use teams::{TEAM_COLOR_ALLIES, TEAM_COLOR_AXIS, TEAM_COLOR_ENEMY_TEAM, TEAM_COLOR_MY_TEAM}; diff --git a/crates/gamemode_iw4/src/teams.rs b/crates/gamemode_iw4/src/teams.rs index ebacb7e..c5ee01a 100644 --- a/crates/gamemode_iw4/src/teams.rs +++ b/crates/gamemode_iw4/src/teams.rs @@ -1,3 +1,7 @@ +pub const TEAM_COLOR_AXIS: &str = ".65 .57 .41"; + +pub const TEAM_COLOR_ALLIES: &str = ".6 .64 .69"; + pub const TEAM_COLOR_MY_TEAM: &str = ".6 .8 .6"; pub const TEAM_COLOR_ENEMY_TEAM: &str = "1 .45 .5"; diff --git a/crates/hud/src/killfeed.rs b/crates/hud/src/killfeed.rs index 2af1eda..06dd7c5 100644 --- a/crates/hud/src/killfeed.rs +++ b/crates/hud/src/killfeed.rs @@ -174,20 +174,45 @@ fn snapshot_client_team(presented: &net::PresentedSnapshot, client: i32) -> i32 .map_or(0, |meta| meta.client_state_team) } -fn obituary_name_color(local_team: i32, team: i32) -> [f32; 4] { +fn obituary_name_color(local_team: i32, team: i32, dvars: sim::ScriptDvars<'_>) -> [f32; 4] { if !matches!(local_team, 1 | 2) || !matches!(team, 1 | 2) { return [1.0, 1.0, 1.0, 1.0]; } - let rgb = if team == local_team { - gamemode_iw4::TEAM_COLOR_MY_TEAM + let (name, default) = if dvars + .int("useRelativeTeamColors") + .is_some_and(|value| value != 0) + { + if team == local_team { + ("g_TeamColor_MyTeam", gamemode_iw4::TEAM_COLOR_MY_TEAM) + } else { + ("g_TeamColor_EnemyTeam", gamemode_iw4::TEAM_COLOR_ENEMY_TEAM) + } + } else if team == 1 { + ("g_TeamColor_Axis", gamemode_iw4::TEAM_COLOR_AXIS) } else { - gamemode_iw4::TEAM_COLOR_ENEMY_TEAM + ("g_TeamColor_Allies", gamemode_iw4::TEAM_COLOR_ALLIES) }; - let mut color = [1.0; 4]; - for (slot, value) in color.iter_mut().zip(rgb.split_whitespace()) { - *slot = value.parse().unwrap_or(1.0); - } - color + let parse = |rgb: &str| { + let mut values = rgb.split_whitespace().map(str::parse::); + let rgb = [ + values.next()?.ok()?, + values.next()?.ok()?, + values.next()?.ok()?, + ]; + (values.next().is_none() && rgb.iter().all(|value| value.is_finite())).then(|| { + [ + rgb[0].clamp(0.0, 1.0), + rgb[1].clamp(0.0, 1.0), + rgb[2].clamp(0.0, 1.0), + 1.0, + ] + }) + }; + dvars + .string(name) + .and_then(parse) + .or_else(|| parse(default)) + .unwrap_or([1.0; 4]) } fn snapshot_client_name(presented: &net::PresentedSnapshot, client: i32) -> String { @@ -405,6 +430,11 @@ pub(crate) fn update_killfeed( } let local_team = snapshot_client_team(&presented, local.0.0 as i32); + let Some(snapshot) = presented.snapshot() else { + hide(&mut pass); + return; + }; + let dvars = snapshot.meta.script_dvars(local.0); let Some(item) = catalog .as_ref() .and_then(|c| c.get("hud_fullscreen")) @@ -547,7 +577,7 @@ pub(crate) fn update_killfeed( applied.h, font_material.clone(), attacker.clone(), - obituary_name_color(local_team, *attacker_team), + obituary_name_color(local_team, *attacker_team, dvars), "killfeed_obituary", )); x_virtual += attacker_w + icon_spacing; @@ -611,7 +641,7 @@ pub(crate) fn update_killfeed( applied.h, font_material.clone(), victim.clone(), - obituary_name_color(local_team, *victim_team), + obituary_name_color(local_team, *victim_team, dvars), "killfeed_obituary", )); } diff --git a/crates/sim/src/script/host/objectives.rs b/crates/sim/src/script/host/objectives.rs index da0d0fc..49f40dc 100644 --- a/crates/sim/src/script/host/objectives.rs +++ b/crates/sim/src/script/host/objectives.rs @@ -7,7 +7,17 @@ use bevy_ecs::prelude::World; use gamemode_iw4::Team; const MAX_OBJECTIVES: i32 = 32; -const ENGINE_SERVER_INFO: &[&str] = &["ui_bomb_timer", "mapname", "g_gametype", "g_hardcore"]; +const ENGINE_SERVER_INFO: &[&str] = &[ + "ui_bomb_timer", + "mapname", + "g_gametype", + "g_hardcore", + "userelativeteamcolors", + "g_teamcolor_axis", + "g_teamcolor_allies", + "g_teamcolor_myteam", + "g_teamcolor_enemyteam", +]; #[derive(Clone, Debug, Default)] pub(crate) struct ScriptObjective { diff --git a/docs/AUDIO.md b/docs/AUDIO.md index 8688a37..e5ae8c1 100644 --- a/docs/AUDIO.md +++ b/docs/AUDIO.md @@ -17,7 +17,8 @@ retain its revision; foreign owners and media/source mismatches refuse before pl T5 layers activate at resolution; others wait for primary preparation. Layers share deadlines, cancellation and lifetime, with independent pitch/failure. Unknown looping uses named one-shot compatibility. [CUES.md](CUES.md) details binding and decode contracts. -Killcam worlds retain the live sound registry for round-result commands. +Killcam worlds retain the live sound registry for round-result commands. Replay +timeline changes retire event cues; music and local announcements keep playing. Weapon publication compiles thermal scopes, cue namespaces, melee precedence, knife substitution and breath aliases. Prediction identifies fire occurrences; diff --git a/docs/KILLCAM.md b/docs/KILLCAM.md index 02b42c7..7735b05 100644 --- a/docs/KILLCAM.md +++ b/docs/KILLCAM.md @@ -17,7 +17,8 @@ projectiles, script movers, combat events and script effects. Sound event identities use archived ticks during replay, including entity-ring sounds and viewmodel animation markers. Repeated snapshots do not replay the same event. Entering, rewinding or leaving killcam changes the event timeline -and cancels cues from the previous timeline. +and cancels event cues from the previous timeline. Music, local announcements +and other cues without replay event identities continue across these transitions. FX transitions reset active effects, tracers, marks and projectile bolt state. Map and script effects are reconstructed from the presented world. Script FX