Restore T5 character animation, killfeed colors and killcam audio continuity

This commit is contained in:
vtroc7
2026-10-11 00:10:03 +03:00
parent a2f189df10
commit b8ded62c23
15 changed files with 269 additions and 39 deletions
+23 -3
View File
@@ -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,
));
}
}
+77 -3
View File
@@ -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<Arc<CompiledAnimTreeDefinition>, AtrCompileError> {
let mut names: HashSet<String> = 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<String>,
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<String>,
) -> Result<Arc<CompiledAnimTreeDefinition>, 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::<f32>();
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<ParsedAnim>, include_parent: bool, loop_sync
}
}
fn parse_properties(parser: &mut AtrParser<'_>) -> Result<u16, AtrCompileError> {
fn parse_properties(parser: &mut AtrParser<'_>, ignored: bool) -> Result<u16, AtrCompileError> {
let mut flags = 0u16;
loop {
let token = parser.parse(false);
@@ -254,6 +322,12 @@ fn parse_properties(parser: &mut AtrParser<'_>) -> Result<u16, AtrCompileError>
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")),
}
}
+88 -6
View File
@@ -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<Arc<ParsedPlayerAnimScript>, 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<ParsedAnimItem, PlayerAnimParseError> {
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::<f32>();
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<ParsedAnimCondition>, 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<ParsedAnimCondition> {
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 {
+6
View File
@@ -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(())
}
+1
View File
@@ -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,
@@ -95,6 +95,7 @@ pub(super) struct CommonProducts {
pub(super) fx_models: asset_game::FxModelCatalog,
pub(super) impact_fx: Option<asset_game::OwnedFxImpactTable>,
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<asset_game::OwnedFxImpactTable>,
pub(super) iw5_materials: MaterialCatalog,
@@ -468,6 +469,7 @@ async fn prepare_common(key: CommonKey) -> Arc<CommonSet> {
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<CommonSet> {
fx_models: common_fx_models,
impact_fx: common_impact,
t5_xanims,
t5_player_anim_sources,
t5_fx,
t5_impact_fx,
iw5_materials,
@@ -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<asset_game::OwnedFxImpactTable>,
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,
@@ -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());
-12
View File
@@ -303,18 +303,6 @@ impl AudioRuntime {
}
pub(crate) fn set_event_context(&self, context: Option<crate::event::EventContext>) {
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);
}
+1 -1
View File
@@ -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};
+4
View File
@@ -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";
+41 -11
View File
@@ -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::<f32>);
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",
));
}
+11 -1
View File
@@ -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 {
+2 -1
View File
@@ -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;
+2 -1
View File
@@ -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