Make underbarrel attachments work across families, bound transport and qualify match publication

- Underbarrel attachments work across IW4, IW5, T5 and T6, including
  per-family impact FX, T5 flamethrower streams and T6 launcher fuses.
- Viewmodel notetrack sounds play in third person.
- Transport operations and queues are bounded; Quinn congestion control
  returns to its default.
- Match publication, presentation context and prepared content semantics
  are qualified and validated; cosmetic and entity occurrence lifecycles
  are preserved.
- GPU skinning batches within supported device budgets.
- Immutable UI projections and correlated class replies are reconciled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vtroc7
2026-10-16 16:27:43 +03:00
parent 3bb6a07456
commit acc57a5c9d
145 changed files with 5285 additions and 1056 deletions
Generated
+2 -3
View File
@@ -4317,9 +4317,6 @@ dependencies = [
[[package]]
name = "master_protocol"
version = "0.1.0"
dependencies = [
"quinn-proto",
]
[[package]]
name = "matchers"
@@ -5679,6 +5676,7 @@ dependencies = [
"hud_iw4",
"lighting_iw4",
"marks_iw4",
"master_protocol",
"math_iw4",
"naga",
"net",
@@ -5747,6 +5745,7 @@ dependencies = [
"render_backend",
"render_frame",
"render_material",
"render_scene",
]
[[package]]
+1
View File
@@ -123,6 +123,7 @@ pub struct FpvAssemblyKey {
#[derive(Clone, Debug)]
pub struct FpvSideAssemblies {
pub ammunition: asset_model::AmmunitionVisualMode,
pub bare: Arc<FpvAssembly>,
pub rocket: Option<Arc<FpvAssembly>>,
pub melee: Option<Arc<FpvAssembly>>,
+6 -10
View File
@@ -10,9 +10,9 @@ pub struct OwnedFxImpactEntry {
pub flesh: [String; FX_IMPACT_FLESH_COUNT],
}
#[derive(Clone, Debug, Default)]
#[derive(Clone, Debug)]
pub struct OwnedFxImpactTable {
pub t5: bool,
pub namespace: crate::AssetNamespace,
pub name: String,
pub entries: Vec<OwnedFxImpactEntry>,
pub capture_gaps: usize,
@@ -28,11 +28,7 @@ impl OwnedFxImpactTable {
}
pub fn namespace(&self) -> crate::AssetNamespace {
if self.t5 {
crate::AssetNamespace::T5
} else {
crate::AssetNamespace::Iw4
}
self.namespace
}
pub fn effect_name(
@@ -105,7 +101,7 @@ impl ImpactFxCatalog {
}
self.capture_gaps += gaps;
self.table = Some(OwnedFxImpactTable {
t5: false,
namespace: crate::AssetNamespace::Iw4,
name,
entries,
capture_gaps: gaps,
@@ -164,7 +160,7 @@ impl ImpactFxCatalog {
}
self.capture_gaps += gaps;
self.table = Some(OwnedFxImpactTable {
t5: true,
namespace: crate::AssetNamespace::T5,
name: s.cstr(name)?.to_owned(),
entries: rows,
capture_gaps: gaps,
@@ -175,7 +171,7 @@ impl ImpactFxCatalog {
impl OwnedFxImpactTable {
pub fn impact_row(&self, impact_type: i32, exit: bool) -> Option<usize> {
if !self.t5 {
if self.namespace == crate::AssetNamespace::Iw4 {
return fx_iw4::impact_table_row(impact_type, exit);
}
let exit = usize::from(exit);
+169 -27
View File
@@ -60,6 +60,12 @@ pub enum MenuEvent {
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Deserialize, serde::Serialize)]
pub struct ExpressionId {
pub source: u64,
pub statement: (u8, u32),
}
#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct MenuHandlers {
@@ -113,6 +119,7 @@ pub struct MenuSlider {
#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct MenuItem {
pub authored_id: String,
pub name: String,
pub handlers: ItemHandlers,
@@ -149,6 +156,8 @@ pub struct MenuItem {
pub vis_ptr: u8,
pub mat_ptr: u8,
pub vis_exp: String,
pub visibility_expression: Option<ExpressionId>,
pub expression_ids: std::collections::BTreeMap<String, ExpressionId>,
pub text_exp: String,
pub material_exp: String,
pub disabled_exp: String,
@@ -197,17 +206,6 @@ pub struct MenuDef {
}
impl MenuDef {
pub fn inherit_shared_vis(&mut self) {
let mut prev = String::new();
for item in &mut self.items {
if item.vis_exp.is_empty() && item.vis_ptr != 0 && !prev.is_empty() {
item.vis_exp = prev.clone();
} else if !item.vis_exp.is_empty() {
prev = item.vis_exp.clone();
}
}
}
pub fn static_dvar_name(&self, index: i32) -> Option<&str> {
self.expr_dvar_rows
.get_or_init(|| expr_dvar_rows(&self.expr_dvars))
@@ -345,8 +343,12 @@ impl CapturedStringTable {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ImageProductRevision(pub u64);
#[derive(Clone, Debug, Default, Resource)]
pub struct MenuCatalog {
image_revision: std::sync::OnceLock<ImageProductRevision>,
pub menus: BTreeMap<String, MenuDef>,
pub fonts: BTreeMap<String, FontDef>,
@@ -354,14 +356,14 @@ pub struct MenuCatalog {
pub rawfiles: BTreeMap<String, String>,
pub zone_images: BTreeMap<String, ZoneUiImage>,
pub(crate) zone_images: BTreeMap<String, ZoneUiImage>,
pub material_state_bits: BTreeMap<String, asset_iw4::ColorPassAgreement<[u32; 2]>>,
pub(crate) material_state_bits: BTreeMap<String, asset_iw4::ColorPassAgreement<[u32; 2]>>,
pub material_2d_plans: BTreeMap<String, HudMaterialPlan>,
pub(crate) material_2d_plans: BTreeMap<String, HudMaterialPlan>,
pub material_images: BTreeMap<String, String>,
pub material_srgb_reads: BTreeMap<String, bool>,
pub(crate) material_images: BTreeMap<String, String>,
pub(crate) material_srgb_reads: BTreeMap<String, bool>,
pub lists: Vec<(String, i32)>,
pub walked: usize,
@@ -390,16 +392,64 @@ pub struct HudMaterialTextureBinding {
}
impl MenuCatalog {
/// Runs after every variant is resolved: a variant that overrides an item's
/// visibility must still pass it on to the items that share it.
pub fn inherit_shared_vis(&mut self) {
for def in self.menus.values_mut() {
def.inherit_shared_vis();
}
}
pub fn load_definitions(&mut self, source: &str) -> Result<(), String> {
let definitions = crate::menu_source::load(source, self)?;
let mut definitions = crate::menu_source::load(source, self)?;
use std::hash::{Hash, Hasher};
let mut hash = std::collections::hash_map::DefaultHasher::new();
source.hash(&mut hash);
let source = hash.finish();
let mut next = 0_u32;
for definition in &mut definitions {
let mut ids = std::collections::BTreeSet::new();
for item in &definition.items {
if !item.authored_id.is_empty()
&& !ids.insert(item.authored_id.to_ascii_lowercase())
{
return Err(format!(
"Duplicate authored item identity in {}",
definition.name
));
}
}
for item in &mut definition.items {
item.expression_ids.clear();
for (field, dump) in [
("Visible", &item.vis_exp),
("Disabled", &item.disabled_exp),
("Text", &item.text_exp),
("Material", &item.material_exp),
] {
if !dump.is_empty() {
item.expression_ids.insert(
field.to_owned(),
ExpressionId {
source,
statement: (u8::MAX, next),
},
);
next = next
.checked_add(1)
.ok_or("authored expression identity exhausted")?;
}
}
for (key, dump) in &item.float_exp {
if !dump.is_empty() {
item.expression_ids.insert(
format!("Float({key})"),
ExpressionId {
source,
statement: (u8::MAX, next),
},
);
next = next
.checked_add(1)
.ok_or("authored expression identity exhausted")?;
}
}
item.visibility_expression = item.expression_ids.get("Visible").copied();
}
}
self.menus
.extend(definitions.into_iter().map(|def| (def.name.clone(), def)));
Ok(())
@@ -475,6 +525,34 @@ impl MenuCatalog {
.get(&AssetRef::bare_name(name).to_ascii_lowercase())
}
pub fn image_revision(&self) -> Option<ImageProductRevision> {
self.image_revision.get().copied()
}
pub fn publish_image_products(&self) -> ImageProductRevision {
*self
.image_revision
.get_or_init(|| ImageProductRevision(self.image_identity()))
}
pub fn zone_images(&self) -> &BTreeMap<String, ZoneUiImage> {
&self.zone_images
}
pub fn material_images(&self) -> &BTreeMap<String, String> {
&self.material_images
}
pub fn material_srgb_reads(&self) -> &BTreeMap<String, bool> {
&self.material_srgb_reads
}
pub fn material_2d_plans(&self) -> &BTreeMap<String, HudMaterialPlan> {
&self.material_2d_plans
}
pub fn material_state_bits(
&self,
) -> &BTreeMap<String, asset_iw4::ColorPassAgreement<[u32; 2]>> {
&self.material_state_bits
}
pub fn image_identity(&self) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
@@ -483,7 +561,7 @@ impl MenuCatalog {
image.material.hash(&mut hasher);
image.image.hash(&mut hasher);
(image.width, image.height).hash(&mut hasher);
Arc::as_ptr(&image.rgba).hash(&mut hasher);
image.rgba.as_slice().hash(&mut hasher);
}
self.material_images.hash(&mut hasher);
self.material_srgb_reads.hash(&mut hasher);
@@ -529,6 +607,14 @@ impl MenuCatalog {
}
pub fn absorb(&mut self, other: MenuCatalog) {
let images_changed = !other.zone_images.is_empty()
|| !other.material_images.is_empty()
|| !other.material_srgb_reads.is_empty()
|| !other.material_2d_plans.is_empty()
|| !other.material_state_bits.is_empty();
if images_changed {
self.image_revision = std::sync::OnceLock::new();
}
self.walked += other.walked;
self.font_headers += other.font_headers;
self.lists.extend(other.lists);
@@ -553,6 +639,9 @@ impl MenuCatalog {
for (name, image) in other.zone_images {
self.zone_images.insert(name, image);
}
if images_changed {
self.publish_image_products();
}
}
}
@@ -660,7 +749,11 @@ fn load_menu_catalog_with_iwd(path: &Path, games: Option<&Path>) -> Result<MenuC
let header = image.header().map_err(|e| e.to_string())?;
let mut memory = ZoneMemory::for_header(&header);
let mut stream = memory.stream(&image.bytes).map_err(|e| e.to_string())?;
use std::hash::{Hash, Hasher};
let mut source_hash = std::collections::hash_map::DefaultHasher::new();
image.bytes.hash(&mut source_hash);
let mut sink = MenuSink {
expression_source: source_hash.finish(),
catalog: MenuCatalog::default(),
russian_fonts: path
.parent()
@@ -680,7 +773,9 @@ fn load_menu_catalog_with_iwd(path: &Path, games: Option<&Path>) -> Result<MenuC
Some((index, ty, slot)) => format!("asset #{index} {} at {slot:?}: {error:?}", ty.name()),
None => format!("asset table: {error:?}"),
})?;
Ok(sink.finish_and_take(games))
let catalog = sink.finish_and_take(games);
catalog.publish_image_products();
Ok(catalog)
}
#[derive(Clone, Copy)]
@@ -713,6 +808,7 @@ enum RecordedEvent {
}
struct MenuSink {
expression_source: u64,
russian_fonts: bool,
catalog: MenuCatalog,
names: HashMap<(u8, u32), String>,
@@ -1377,6 +1473,52 @@ impl AssetLinkSink for MenuSink {
Ok(())
}
fn capture_item_expression_reference(
&mut self,
menu: &str,
_item: &str,
field: fastfile_iw4::ItemExpressionField,
source: Ptr,
) -> fastfile_iw4::Result<()> {
if let Some(item) = self
.catalog
.menus
.get_mut(menu)
.and_then(|menu| menu.items.last_mut())
{
item.expression_ids.insert(
format!("{field:?}"),
ExpressionId {
source: self.expression_source,
statement: ptr_key(source),
},
);
}
Ok(())
}
fn capture_item_visible_reference(
&mut self,
menu: &str,
item: &str,
source: Ptr,
dump: &str,
) -> fastfile_iw4::Result<()> {
self.capture_item_visible_exp(menu, item, dump)?;
if let Some(item) = self
.catalog
.menus
.get_mut(menu)
.and_then(|menu| menu.items.last_mut())
{
item.visibility_expression = Some(ExpressionId {
source: self.expression_source,
statement: ptr_key(source),
});
}
Ok(())
}
fn capture_item_visible_exp(
&mut self,
menu: &str,
+19
View File
@@ -31,7 +31,9 @@ mod capture_t6;
pub use capture_t5::t5_inline_note_alias;
use capture_t6::*;
mod capture_t5;
mod flame_stream;
use capture_t5::*;
pub use flame_stream::{FlameStreamView, FlameTube, WeaponFlameStream};
mod capture_iw5;
use capture_iw5::*;
mod binding;
@@ -326,6 +328,7 @@ pub(crate) struct WeaponBodyFacts {
pub min_player_damage: i32,
pub max_damage_range: f32,
pub min_damage_range: f32,
pub flame_damage_interval_ms: i32,
pub kick: WeaponKickFacts,
@@ -625,6 +628,8 @@ struct CatalogWeapon {
pub combat_fx: WeaponCombatFx,
pub flame: Option<Box<WeaponFlameStream>>,
pub(crate) combat_slots: CombatFxSlots,
pub facts: WeaponBodyFacts,
}
@@ -836,6 +841,9 @@ pub struct WeaponSoundAliases {
pub fire_player_akimbo: Option<String>,
pub fire_start: Option<String>,
pub fire_start_player: Option<String>,
pub fire_loop: Option<String>,
pub fire_loop_player: Option<String>,
@@ -923,6 +931,12 @@ weapon_sound_slots! {
ProjIgnition => proj_ignition_sound,
FireLast => fire_last,
FireLastPlayer => fire_last_player,
FireStart => fire_start,
FireStartPlayer => fire_start_player,
FireLoop => fire_loop,
FireLoopPlayer => fire_loop_player,
FireStop => fire_stop,
FireStopPlayer => fire_stop_player,
}
impl WeaponSoundAliases {
@@ -967,6 +981,8 @@ impl WeaponSoundAliases {
self.projectile.as_deref(),
self.proj_ignition_sound.as_deref(),
self.fire_player_akimbo.as_deref(),
self.fire_start.as_deref(),
self.fire_start_player.as_deref(),
self.fire_loop.as_deref(),
self.fire_loop_player.as_deref(),
self.fire_stop.as_deref(),
@@ -1428,6 +1444,8 @@ struct WeaponRow {
combat_fx: WeaponCombatFx,
flame: Option<Box<WeaponFlameStream>>,
reticle: WeaponReticleAssets,
hud_material_edges: WeaponHudMaterialEdges,
@@ -1533,6 +1551,7 @@ impl Default for WeaponRow {
iw5_notetrack_overrides: Vec::new(),
sounds: WeaponSoundAliases::default(),
combat_fx: WeaponCombatFx::empty(crate::AssetNamespace::Iw4),
flame: None,
reticle: WeaponReticleAssets::default(),
hud_material_edges: WeaponHudMaterialEdges::default(),
overlay_material: None,
@@ -279,6 +279,8 @@ impl WeaponCatalog {
geometry.notetrack_rumble_values,
),
fire_player_akimbo: None,
fire_start: None,
fire_start_player: None,
fire_loop: None,
fire_loop_player: None,
fire_stop: None,
@@ -304,6 +306,7 @@ impl WeaponCatalog {
explosion: geometry.explosion_slot,
tracer: geometry.tracer_slot,
},
flame: None,
combat_fx: WeaponCombatFx {
last_shot_eject_pair_authored: geometry.view_last_shot_eject_slot.is_some()
&& geometry.world_last_shot_eject_slot.is_some(),
@@ -464,6 +467,7 @@ impl WeaponCatalog {
min_player_damage: geometry.min_player_damage,
max_damage_range: geometry.max_damage_range,
min_damage_range: geometry.min_damage_range,
flame_damage_interval_ms: 0,
kick: WeaponKickFacts::from_capture(geometry.kick),
sway: WeaponSwayFacts::from_capture(geometry.sway),
dual_wield_view_model_offset: geometry.dual_wield_view_model_offset,
@@ -269,6 +269,7 @@ impl WeaponCatalog {
sz_xanims_left: [const { None }; WEAPON_ANIM_SLOTS],
hide_tags: read_hide_tags_iw5(stream, strings, geometry.hide_tags),
sounds: leftover_iw5_sounds(stream, strings, &geometry),
flame: None,
combat_fx: read_iw5_combat_fx(stream, &geometry, fx_name_at_slot),
combat_slots: CombatFxSlots::default(),
facts: capture_iw5_body_facts(stream, &geometry),
@@ -839,6 +840,8 @@ pub(super) fn leftover_iw5_sounds(
};
let mut sounds = WeaponSoundAliases {
notetrack_convention: NotetrackConvention::SoundMap,
fire_start: None,
fire_start_player: None,
pickup: leftover_iw5_snd_alias(stream, body, sz::WEAPON_DEF_SND_PICKUP_OFF, 128),
pickup_player: leftover_iw5_snd_alias(
stream,
@@ -207,6 +207,8 @@ pub(super) fn merge_sound_aliases(dst: &mut WeaponSoundAliases, src: &WeaponSoun
(&mut dst.projectile, &src.projectile),
(&mut dst.proj_ignition_sound, &src.proj_ignition_sound),
(&mut dst.fire_player_akimbo, &src.fire_player_akimbo),
(&mut dst.fire_start, &src.fire_start),
(&mut dst.fire_start_player, &src.fire_start_player),
(&mut dst.fire_loop, &src.fire_loop),
(&mut dst.fire_loop_player, &src.fire_loop_player),
(&mut dst.fire_stop, &src.fire_stop),
@@ -680,6 +682,9 @@ pub(super) fn merge_body_facts(dst: &mut WeaponBodyFacts, src: WeaponBodyFacts)
if dst.min_damage_range == 0.0 {
dst.min_damage_range = src.min_damage_range;
}
if dst.flame_damage_interval_ms == 0 {
dst.flame_damage_interval_ms = src.flame_damage_interval_ms;
}
if !dst.inherits_perks && src.inherits_perks {
dst.inherits_perks = true;
}
@@ -185,6 +185,7 @@ impl WeaponCatalog {
hide_tags: read_hide_tags_t5(stream, strings, geometry.hide_tags),
sounds: leftover_t5_sounds(stream, strings, &geometry),
combat_fx: WeaponCombatFx::empty(crate::AssetNamespace::T5),
flame: flame_stream::capture_t5_flame_stream(stream, &geometry),
combat_slots: CombatFxSlots::default(),
facts: capture_t5_body_facts(stream, &geometry),
});
@@ -385,6 +386,46 @@ pub(super) fn leftover_t5_cstr(
.map(str::to_owned)
}
fn leftover_t5_flame_table(
stream: &fastfile_t5::ZoneStream<'_>,
body: fastfile_t5::Ptr,
off: usize,
) -> Option<fastfile_t5::Ptr> {
match stream.ptr_at(body, off).ok()? {
fastfile_t5::ZonePtr::Offset(q) => Some(stream.resolve_alias(q)),
_ => None,
}
}
fn apply_t5_flame(
stream: &fastfile_t5::ZoneStream<'_>,
body: fastfile_t5::Ptr,
facts: &mut WeaponBodyFacts,
) {
use fastfile_t5::size as sz;
let fuel_tank = u8_at_t5(stream, body, sz::WEAPON_DEF_FUEL_TANK_WEAPON_OFF) != 0;
let tank_life_ms = i32_at_t5(stream, body, sz::WEAPON_DEF_TANK_LIFE_TIME_OFF);
if fuel_tank && tank_life_ms > 0 && facts.fire_time_ms > 0 {
let ticks = (tank_life_ms / facts.fire_time_ms).max(1);
facts.clip_size = facts.clip_size.saturating_mul(ticks);
if !facts.ammo_count_clip_relative {
facts.start_ammo = facts.start_ammo.saturating_mul(ticks);
facts.max_ammo = facts.max_ammo.saturating_mul(ticks);
}
}
facts.flame_damage_interval_ms =
f32_at_t5(stream, body, sz::WEAPON_DEF_DAMAGE_INTERVAL_OFF) as i32;
let reach = leftover_t5_flame_table(stream, body, sz::WEAPON_DEF_FLAME_TABLE_FIRST_PERSON_OFF)
.map(|table| {
f32_at_t5(stream, table, sz::FLAME_TABLE_STREAM_FUEL_LENGTH_OFF)
+ f32_at_t5(stream, table, sz::FLAME_TABLE_STREAM_FLAME_LENGTH_OFF)
})
.unwrap_or(0.0);
facts.min_damage = facts.damage;
facts.max_damage_range = reach;
facts.min_damage_range = reach;
}
pub(super) fn leftover_t5_asset_slot(
stream: &fastfile_t5::ZoneStream<'_>,
body: Option<fastfile_t5::Ptr>,
@@ -595,11 +636,40 @@ pub(super) fn leftover_t5_sounds(
let Some(body) = geometry.weap_def else {
return WeaponSoundAliases::default();
};
let gas = geometry.weap_type == weapon_iw4::WEAPTYPE_GAS;
let flame_sound = |table_off, sound_off| {
let table = gas.then(|| leftover_t5_flame_table(stream, body, table_off))??;
leftover_t5_cstr(stream, table, sound_off)
};
WeaponSoundAliases {
projectile: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_PROJECTILE_SOUND_OFF),
proj_explosion: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_PROJ_EXPLOSION_SOUND_OFF),
proj_ignition_sound: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_PROJ_IGNITION_SOUND_OFF),
notetrack_convention: NotetrackConvention::InlinePrefix,
fire_start: flame_sound(
sz::WEAPON_DEF_FLAME_TABLE_THIRD_PERSON_OFF,
sz::FLAME_TABLE_IGNITE_SOUND_OFF,
),
fire_start_player: flame_sound(
sz::WEAPON_DEF_FLAME_TABLE_FIRST_PERSON_OFF,
sz::FLAME_TABLE_IGNITE_SOUND_OFF,
),
fire_loop: flame_sound(
sz::WEAPON_DEF_FLAME_TABLE_THIRD_PERSON_OFF,
sz::FLAME_TABLE_ON_LOOP_SOUND_OFF,
),
fire_loop_player: flame_sound(
sz::WEAPON_DEF_FLAME_TABLE_FIRST_PERSON_OFF,
sz::FLAME_TABLE_ON_LOOP_SOUND_OFF,
),
fire_stop: flame_sound(
sz::WEAPON_DEF_FLAME_TABLE_THIRD_PERSON_OFF,
sz::FLAME_TABLE_COOLDOWN_SOUND_OFF,
),
fire_stop_player: flame_sound(
sz::WEAPON_DEF_FLAME_TABLE_FIRST_PERSON_OFF,
sz::FLAME_TABLE_COOLDOWN_SOUND_OFF,
),
fire: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_SND_FIRE_OFF),
fire_player: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_SND_FIRE_PLAYER_OFF),
empty_fire: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_SND_EMPTY_FIRE_OFF),
@@ -622,6 +692,8 @@ pub(super) fn leftover_t5_sounds(
),
reload_end: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_SND_RELOAD_END_OFF),
reload_end_player: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_SND_RELOAD_END_PLAYER_OFF),
alt_switch: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_SND_ALT_SWITCH_OFF),
alt_switch_player: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_SND_ALT_SWITCH_PLAYER_OFF),
raise_player: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_SND_RAISE_PLAYER_OFF),
putaway_player: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_SND_PUTAWAY_PLAYER_OFF),
melee_swipe: leftover_t5_cstr(stream, body, sz::WEAPON_DEF_SND_MELEE_SWIPE_OFF),
@@ -853,6 +925,9 @@ pub(super) fn capture_t5_body_facts(
};
facts.inherits_perks = leftover_t5_inherits_host_perks();
facts.kick = leftover_t5_kick(stream, geometry);
if facts.weap_type == weapon_iw4::WEAPTYPE_GAS {
apply_t5_flame(stream, body, &mut facts);
}
facts
}
@@ -117,7 +117,14 @@ impl WeaponCatalog {
sz_xanims_left: t6_sz_xanims_left(weapon),
hide_tags: Vec::new(),
sounds: capture_t6_sounds(weapon),
flame: None,
combat_fx: WeaponCombatFx {
view_flash_hint: weapon
.def_asset_name(fastfile_t6::weapon::def::VIEW_FLASH_EFFECT)
.map(str::to_owned),
world_flash_hint: weapon
.def_asset_name(fastfile_t6::weapon::def::WORLD_FLASH_EFFECT)
.map(str::to_owned),
explosion_hint: weapon
.def_asset_name(fastfile_t6::weapon::def::PROJ_EXPLOSION_EFFECT)
.map(str::to_owned),
@@ -424,6 +431,8 @@ pub(super) fn capture_t6_sounds(w: fastfile_t6::weapon::WeaponView<'_>) -> Weapo
melee_miss: name(d::MELEE_MISS_SOUND),
pullback: name(d::PULLBACK_SOUND),
pullback_player: name(d::PULLBACK_SOUND_PLAYER),
alt_switch: name(d::ALT_SWITCH_SOUND),
alt_switch_player: name(d::ALT_SWITCH_SOUND_PLAYER),
raise: name(d::RAISE_SOUND),
raise_player: name(d::RAISE_SOUND_PLAYER),
first_raise: name(d::FIRST_RAISE_SOUND),
@@ -79,6 +79,7 @@ fn captured_input(f: WeaponBodyFacts, melee_charge_anim: bool) -> CapturedCombat
stunned_start_time_ms: f.stunned_start_time_ms,
stunned_end_time_ms: f.stunned_end_time_ms,
damage: f.damage,
flame_damage_interval_ms: f.flame_damage_interval_ms,
min_damage: f.min_damage,
max_damage_range: f.max_damage_range,
min_damage_range: f.min_damage_range,
@@ -0,0 +1,122 @@
use super::capture_t5::f32_at_t5;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct FlameTube {
pub size: [f32; 2],
pub length: f32,
pub segments: f32,
pub anim_loop_s: f32,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct FlameStreamView {
pub chunk_gravity: [f32; 2],
pub chunk_max_size: f32,
pub chunk_size: [f32; 2],
pub chunk_size_rand: [f32; 2],
pub chunk_dist_scalar: f32,
pub chunk_speed: f32,
pub chunk_decel: f32,
pub chunk_life_s: f32,
pub chunk_rot_rand: f32,
pub fire_interval_s: [f32; 2],
pub fire_first_life_frac: f32,
pub fire_min_life_frac: f32,
pub fire_min_life_age_s: [f32; 2],
pub fuel: FlameTube,
pub flame: FlameTube,
pub fire_life_s: f32,
pub fire_life_rand_s: f32,
pub fire_speed_scale: f32,
pub fire_speed_scale_rand: f32,
pub fire_vel_add_z: f32,
pub fire_vel_add_z_rand: f32,
pub fire_vel_add_sideways: f32,
pub fire_gravity: [f32; 2],
pub fire_max_rot_vel: f32,
pub fire_friction: f32,
pub fire_end_size_add: f32,
pub fire_start_size_scale: f32,
pub fire_end_size_scale: f32,
/// `FlameTable` material order: fire, smoke, heat, drips, fuel, fuel2,
/// flame, flame2.
pub materials: [Option<String>; fastfile_t5::FLAME_TABLE_MATERIAL_COUNT],
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct WeaponFlameStream {
pub views: [FlameStreamView; 2],
}
impl WeaponFlameStream {
pub fn view(&self, player_view: bool) -> &FlameStreamView {
&self.views[usize::from(!player_view)]
}
}
pub(super) fn capture_t5_flame_stream(
stream: &fastfile_t5::ZoneStream<'_>,
geometry: &fastfile_t5::WeaponGeometry,
) -> Option<Box<WeaponFlameStream>> {
use fastfile_t5::size as sz;
let body = geometry.weap_def?;
let mut views: [FlameStreamView; 2] = Default::default();
for (view, (off, names)) in views.iter_mut().zip(
[
sz::WEAPON_DEF_FLAME_TABLE_FIRST_PERSON_OFF,
sz::WEAPON_DEF_FLAME_TABLE_THIRD_PERSON_OFF,
]
.into_iter()
.zip(&geometry.flame_material_names),
) {
let table = match stream.ptr_at(body, off).ok()? {
fastfile_t5::ZonePtr::Offset(q) => stream.resolve_alias(q),
_ => return None,
};
let f = |i: usize| f32_at_t5(stream, table, i * 4);
let tube = |i: usize| FlameTube {
size: [f(i), f(i + 1)],
length: f(i + 2),
segments: f(i + 3),
anim_loop_s: f(i + 4),
};
let name = |slot: usize| {
names[slot]
.and_then(|ptr| stream.cstr(ptr).ok())
.filter(|name| !name.is_empty())
.map(str::to_owned)
};
*view = FlameStreamView {
chunk_gravity: [f(0), f(1)],
chunk_max_size: f(2),
chunk_size: [f(3), f(4)],
chunk_size_rand: [f(5), f(6)],
chunk_dist_scalar: f(7),
chunk_speed: f(10),
chunk_decel: f(11),
chunk_life_s: f(13),
chunk_rot_rand: f(28),
fire_interval_s: [f(18), f(19)],
fire_first_life_frac: f(20),
fire_min_life_frac: f(22),
fire_min_life_age_s: [f(23), f(24)],
fuel: tube(40),
flame: tube(45),
fire_life_s: f(58),
fire_life_rand_s: f(59),
fire_speed_scale: f(60),
fire_speed_scale_rand: f(61),
fire_vel_add_z: f(62),
fire_vel_add_z_rand: f(63),
fire_vel_add_sideways: f(64),
fire_gravity: [f(65), f(66)],
fire_max_rot_vel: f(67),
fire_friction: f(68),
fire_end_size_add: f(69),
fire_start_size_scale: f(70),
fire_end_size_scale: f(71),
materials: std::array::from_fn(name),
};
}
Some(Box::new(WeaponFlameStream { views }))
}
@@ -188,7 +188,7 @@ impl WeaponBuild {
if parent == 0 {
row.fpv_assemblies = [None, None];
}
let Some(Ok(mounts)) = &row.fpv_mount_plan else {
let Some(Ok(mounts)) = &mut row.fpv_mount_plan else {
continue;
};
let clips: std::collections::BTreeSet<usize> = row
@@ -199,11 +199,19 @@ impl WeaponBuild {
.filter_map(|edge| edge.bound_index())
.collect();
let hide_mode = row.preparation.hide_mode();
let embedded_ammo = mounts.rocket.is_none()
let declares_embedded = row.rocket_model.is_none()
&& row.projectile_model.is_some()
&& hide_tags
.iter()
.any(|tag| tag.eq_ignore_ascii_case("tag_clip"));
if declares_embedded && let Err(error) = mounts.prepare_embedded_ammunition(fpv) {
row.fpv_assemblies = std::array::from_fn(|_| Some(Err(error.to_string())));
continue;
}
let embedded_ammo = matches!(
mounts.ammunition,
asset_model::AmmunitionVisualMode::Embedded { .. }
);
let knife_model = row.knife_xmodel.as_deref().map(|name| {
row.preparation
.bind_fpv(WeaponComponent::ViewModel, Some(name), fpv)
@@ -320,6 +328,7 @@ impl WeaponBuild {
.any(|(jammed, bare)| jammed.hide != bare.hide)
.then_some(jammed);
Some(Ok(crate::FpvSideAssemblies {
ammunition: mounts.ammunition,
bare,
rocket,
melee,
@@ -328,6 +328,9 @@ impl WeaponBuild {
entry.sounds.leftover_sound_overrides.clone();
}
merge_combat_fx(&mut existing.combat_fx, &entry.combat_fx);
if existing.flame.is_none() {
existing.flame = entry.flame;
}
merge_combat_slots(&mut existing.combat_slots, &entry.combat_slots);
merge_body_facts(&mut existing.facts, entry.facts);
}
@@ -430,6 +433,7 @@ impl WeaponBuild {
iw5_notetrack_overrides: entry.iw5_notetrack_overrides,
sounds: entry.sounds,
combat_fx: entry.combat_fx,
flame: entry.flame,
reticle: entry.reticle,
hud_material_edges: entry.hud_material_edges,
overlay_material_from_slot: entry.overlay_material_slot.is_some(),
@@ -1106,6 +1106,10 @@ impl WeaponRegistry {
self.rows.get(index as usize).map(|row| &row.combat_fx)
}
pub fn flame_stream_of(&self, index: u32) -> Option<&WeaponFlameStream> {
self.rows.get(index as usize)?.flame.as_deref()
}
pub fn tracer_type_census(&self) -> AssetEdgeCensus {
let mut census = AssetEdgeCensus::default();
for row in self.rows.iter().skip(1) {
@@ -2044,7 +2044,7 @@ impl MaterialCatalog {
sort_key: geometry.sort_key,
info_game_flags: geometry.info_game_flags,
texture_atlas: None,
texture_atlas: Some(geometry.texture_atlas),
surface_type_bits: Some(geometry.surface_type_bits),
t5_layered_surface_types: Some(geometry.layered_surface_types),
state_flags: geometry.state_flags,
@@ -137,6 +137,7 @@ pub fn compile_material_catalog(source: &crate::MaterialDefinitions) -> RuntimeM
.draw_rules(material, source.is_unlit(material).unwrap_or(false)),
sort_key: material.sort_key,
info_game_flags: material.info_game_flags,
texture_atlas: material.texture_atlas.unwrap_or([1, 1]),
state_flags: material.state_flags,
surface_type_bits: material.surface_type_bits,
unlit: source.is_unlit(material).unwrap_or(false),
+14 -1
View File
@@ -80,6 +80,7 @@ impl BodyMeshEntry {
#[derive(Clone, Debug, Default)]
pub struct BodyMeshCatalog {
identity: u64,
entries: HashMap<String, BodyMeshEntry>,
kits_cache: std::sync::OnceLock<SoldierKits>,
}
@@ -99,7 +100,15 @@ impl std::ops::Deref for BodyMeshBuild {
}
impl BodyMeshBuild {
pub fn publish(self) -> BodyMeshCatalog {
pub fn publish(mut self) -> BodyMeshCatalog {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
self.catalog.identity = NEXT
.fetch_update(
std::sync::atomic::Ordering::Relaxed,
std::sync::atomic::Ordering::Relaxed,
|next| next.checked_add(1),
)
.expect("body product revision exhausted");
self.catalog
}
@@ -193,6 +202,10 @@ impl BodyMeshBuild {
}
impl BodyMeshCatalog {
pub fn identity(&self) -> u64 {
self.identity
}
pub fn len(&self) -> usize {
self.entries.len()
}
+93
View File
@@ -561,16 +561,104 @@ pub struct FpvMount {
pub tag: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AmmunitionVisualMode {
Absent,
Embedded { bone: u16 },
Mounted,
}
#[derive(Clone, Debug)]
pub struct FpvMountPlan {
pub gun: FpvMeshIndex,
pub secondary_gun: Option<FpvMeshIndex>,
pub attachments: Vec<FpvMount>,
pub rocket: Option<FpvMount>,
pub ammunition: AmmunitionVisualMode,
pub ads_swaps: Vec<(usize, FpvMeshIndex)>,
}
impl FpvMountPlan {
pub fn prepare_embedded_ammunition(
&mut self,
catalog: &FpvMeshCatalog,
) -> Result<(), FpvMountError> {
let entry = catalog
.get_at(self.gun.order())
.ok_or_else(|| FpvMountError {
model: format!("#{}", self.gun.order()),
detail: "gun missing from FPV catalog",
})?;
let valid = entry.skel.pose.as_ref().is_some_and(|pose| {
let count = entry.skel.bone_names.len();
pose.num_bones == count
&& entry.skel.bones.len() == count
&& pose.num_root_bones > 0
&& pose.num_root_bones <= count
&& pose.parent_list.len() == count - pose.num_root_bones
&& (pose.num_root_bones..count).all(|bone| entry.skel.parent_of(bone).is_some())
&& entry.skel.vert_skin.iter().all(|skin| {
(0..4)
.filter(|index| {
if *index == 0 {
!skin.primary_is_zero()
} else {
skin.has_extra_weight(*index)
}
})
.all(|index| usize::from(skin.bones[index]) < count)
})
});
if !valid {
return Err(FpvMountError {
model: entry.skel.name.clone(),
detail: "invalid embedded ammunition hierarchy or binding",
});
}
let bone = entry
.skel
.bone_names
.iter()
.position(|name| name.eq_ignore_ascii_case("tag_clip"))
.filter(|bone| *bone <= u8::MAX as usize)
.filter(|bone| {
entry.skel.vert_skin.iter().any(|skin| {
skin.bones
.iter()
.enumerate()
.filter(|(index, _)| {
if *index == 0 {
!skin.primary_is_zero()
} else {
skin.has_extra_weight(*index)
}
})
.any(|(_, bound)| {
let mut current = Some(usize::from(*bound));
while let Some(index) = current {
if index == *bone {
return true;
}
current = entry.skel.parent_of(index);
}
false
})
})
})
.ok_or_else(|| FpvMountError {
model: entry.skel.name.clone(),
detail: "embedded ammunition has no bound geometry",
})?;
if entry.skel.pose.is_none() || self.rocket.is_some() {
return Err(FpvMountError {
model: entry.skel.name.clone(),
detail: "invalid embedded ammunition structure",
});
}
self.ammunition = AmmunitionVisualMode::Embedded { bone: bone as u16 };
Ok(())
}
pub fn attachment_models(&self, ads: bool) -> impl Iterator<Item = FpvMeshIndex> + '_ {
self.attachments.iter().enumerate().map(move |(at, mount)| {
ads.then(|| self.ads_swaps.iter().find(|(swap, _)| *swap == at))
@@ -728,6 +816,11 @@ pub fn plan_fpv_mounts(
gun,
secondary_gun: None,
attachments: selected,
ammunition: if rocket.is_some() {
AmmunitionVisualMode::Mounted
} else {
AmmunitionVisualMode::Absent
},
rocket,
ads_swaps: Vec::new(),
})
+10 -2
View File
@@ -109,12 +109,19 @@ impl WorldWeaponBuild {
pub fn seal_identity(&mut self) -> u64 {
static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
if self.catalog.identity == 0 {
self.catalog.identity = NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
self.catalog.identity = NEXT_ID
.fetch_update(
std::sync::atomic::Ordering::Relaxed,
std::sync::atomic::Ordering::Relaxed,
|next| next.checked_add(1),
)
.expect("world weapon product revision exhausted");
}
self.catalog.identity
}
pub fn publish(self) -> WorldWeaponCatalog {
pub fn publish(mut self) -> WorldWeaponCatalog {
self.seal_identity();
self.catalog
}
@@ -227,6 +234,7 @@ impl WorldWeaponBuild {
}
fn retain(&mut self, key: WorldWeaponKey, entry: WorldWeaponEntry) {
self.catalog.identity = 0;
if let Some(&pos) = self.catalog.indices.get(&key) {
self.catalog.zones[pos] = self.capture_zone;
self.catalog.entries[pos] = entry;
+293 -2
View File
@@ -28,12 +28,25 @@ pub struct ClipCmodel {
#[derive(Clone, Debug, Default)]
pub struct ClipMapMaterial {
pub name: String,
pub source_surface_flags: u32,
pub surface_flags: u32,
pub content_flags: u32,
}
pub const COLLISION_ADAPTER_VERSION: u32 = 1;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum CollisionCoverage {
#[default]
Unvalidated,
Complete,
}
#[derive(Clone, Debug, Default)]
pub struct ClipCollision {
pub adapter_version: u32,
pub source_family: Option<asset_core::AssetNamespace>,
pub coverage: CollisionCoverage,
pub brushes: Vec<ClipBrush>,
pub nodes: Vec<ClipBspNode>,
@@ -178,6 +191,244 @@ impl clipmap_iw4::BrushView for ClipBrush {
}
impl ClipCollision {
pub fn validate(
&mut self,
family: asset_core::AssetNamespace,
) -> Result<(), ClipCollisionError> {
self.coverage = CollisionCoverage::Unvalidated;
let invalid = || ClipCollisionError::InvalidSemantics("BSP node graph");
let finite = |v: &[f32]| v.iter().all(|n| n.is_finite());
let mut node_indegree = vec![0_usize; self.nodes.len()];
for node in &self.nodes {
if !finite(&node.plane) || node.plane[..3].iter().map(|n| n * n).sum::<f32>() < 0.5 {
return Err(invalid());
}
for child in node.children {
if child >= 0 {
let slot = node_indegree.get_mut(child as usize).ok_or_else(invalid)?;
*slot += 1;
} else if (-1_i64 - i64::from(child)) as usize >= self.leaves.len() {
return Err(invalid());
}
}
}
let mut roots: std::collections::VecDeque<_> = node_indegree
.iter()
.enumerate()
.filter_map(|(index, n)| (*n == 0).then_some(index))
.collect();
let mut visited = 0;
while let Some(index) = roots.pop_front() {
visited += 1;
for child in self.nodes[index]
.children
.into_iter()
.filter(|child| *child >= 0)
{
node_indegree[child as usize] -= 1;
if node_indegree[child as usize] == 0 {
roots.push_back(child as usize);
}
}
}
if visited != self.nodes.len() {
return Err(invalid());
}
let invalid = || ClipCollisionError::InvalidSemantics("brush hull");
for brush in &self.brushes {
if brush.planes.len() < 4
|| brush.planes.len() != brush.plane_surface_flags.len()
|| brush.planes.iter().any(|plane| {
!finite(plane) || plane[..3].iter().map(|n| n * n).sum::<f32>() < 0.5
})
{
return Err(invalid());
}
}
let invalid = || ClipCollisionError::InvalidSemantics("leaf brush references");
if self
.leafbrushes
.iter()
.any(|index| *index as usize >= self.brushes.len())
{
return Err(invalid());
}
let invalid = || ClipCollisionError::InvalidSemantics("leaf brush span");
for leaf in &self.leaves {
if (leaf.first_brush as usize)
.checked_add(leaf.num_brushes as usize)
.is_none_or(|end| end > self.leafbrushes.len())
{
return Err(invalid());
}
}
let invalid = || ClipCollisionError::InvalidSemantics("collision model span or bounds");
for model in &self.cmodels {
if !finite(&model.mins)
|| !finite(&model.maxs)
|| !model.radius.is_finite()
|| model.radius < 0.0
|| (0..3).any(|axis| model.mins[axis] > model.maxs[axis])
|| (model.first_brush as usize)
.checked_add(model.num_brushes as usize)
.is_none_or(|end| end > self.leafbrushes.len())
{
return Err(invalid());
}
}
let invalid = || ClipCollisionError::InvalidSemantics("mesh arrays");
let mesh = &self.mesh;
let triangles = mesh.tri_count();
if mesh.tri_indices.len() % 3 != 0 {
return Err(ClipCollisionError::InvalidSemantics("triangle triplets"));
}
if mesh.verts.iter().any(|vertex| !finite(vertex)) {
return Err(ClipCollisionError::InvalidSemantics("finite mesh vertices"));
}
if triangles != 0 && mesh.partitions.is_empty() {
return Err(ClipCollisionError::InvalidSemantics(
"mesh partitions absent",
));
}
if triangles != 0 && mesh.tri_surface_flags.len() != triangles {
return Err(ClipCollisionError::InvalidSemantics(
"triangle surface flags",
));
}
if triangles != 0 && mesh.tri_content_flags.len() != triangles {
return Err(ClipCollisionError::InvalidSemantics("triangle contents"));
}
if !self.tri_material_index.is_empty()
&& (self.tri_material_index.len() != triangles
|| self
.tri_material_index
.iter()
.enumerate()
.any(|(tri, index)| {
*index as usize >= self.materials.len()
&& mesh
.tri_content_flags
.get(tri)
.is_some_and(|flags| *flags != 0)
}))
{
return Err(invalid());
}
let invalid = || ClipCollisionError::InvalidSemantics("partition range");
let mut covered = vec![false; triangles];
for partition in &mesh.partitions {
let first = usize::try_from(partition.first_tri).map_err(|_| invalid())?;
let end = first
.checked_add(partition.tri_count as usize)
.filter(|end| *end <= triangles)
.ok_or_else(invalid)?;
if (partition.first_border as usize)
.checked_add(partition.border_count as usize)
.is_none_or(|end| end > mesh.borders.len())
{
return Err(invalid());
}
covered[first..end].fill(true);
let base = partition.first_vert_segment as usize * clipmap_iw4::VERTS_PER_SEGMENT;
if mesh.tri_indices[first * 3..end * 3]
.iter()
.any(|index| base + *index as usize >= mesh.verts.len())
{
return Err(invalid());
}
}
let invalid = || ClipCollisionError::InvalidSemantics("triangle coverage");
if covered
.iter()
.enumerate()
.any(|(tri, covered)| !covered && mesh.tri_content_flags[tri] != 0)
{
return Err(invalid());
}
let invalid = || ClipCollisionError::InvalidSemantics("mesh border");
for border in &mesh.borders {
if !finite(&border.dist_eq)
|| !finite(&[border.z_base, border.z_slope, border.start, border.length])
|| border.length < 0.0
{
return Err(invalid());
}
}
for leaf in &self.leaves {
if (leaf.first_coll_aabb_index as usize)
.checked_add(leaf.coll_aabb_count as usize)
.is_none_or(|end| end > mesh.aabb_trees.len())
{
return Err(invalid());
}
}
let invalid = || ClipCollisionError::InvalidSemantics("AABB roots");
if mesh
.aabb_roots
.iter()
.any(|index| *index as usize >= mesh.aabb_trees.len())
{
return Err(invalid());
}
let invalid = || ClipCollisionError::InvalidSemantics("AABB child or partition reference");
for node in &mesh.aabb_trees {
if !finite(&node.origin)
|| !finite(&node.half_size)
|| node.half_size.iter().any(|n| *n < 0.0)
|| (node.child_count != 0
&& usize::try_from(node.u)
.ok()
.and_then(|first| first.checked_add(node.child_count as usize))
.is_none_or(|end| end > mesh.aabb_trees.len()))
|| (node.child_count == 0
&& usize::try_from(node.u)
.ok()
.is_none_or(|index| index >= mesh.partitions.len()))
{
return Err(invalid());
}
}
let mut indegree = vec![0_usize; mesh.aabb_trees.len()];
for node in &mesh.aabb_trees {
if node.child_count == 0 {
if !self.materials.is_empty()
&& node.material_index as usize >= self.materials.len()
{
return Err(invalid());
}
} else {
for child in node.u as usize..node.u as usize + node.child_count as usize {
indegree[child] += 1;
}
}
}
let mut roots: std::collections::VecDeque<_> = indegree
.iter()
.enumerate()
.filter_map(|(index, n)| (*n == 0).then_some(index))
.collect();
let mut visited = 0;
while let Some(index) = roots.pop_front() {
visited += 1;
let node = &mesh.aabb_trees[index];
if node.child_count != 0 {
for child in node.u as usize..node.u as usize + node.child_count as usize {
indegree[child] -= 1;
if indegree[child] == 0 {
roots.push_back(child);
}
}
}
}
if visited != mesh.aabb_trees.len() {
return Err(ClipCollisionError::InvalidSemantics("AABB cycle"));
}
self.adapter_version = COLLISION_ADAPTER_VERSION;
self.source_family = Some(family);
self.coverage = CollisionCoverage::Complete;
Ok(())
}
fn map_ref(&self) -> clipmap_iw4::ClipMapRef<'_, ClipBrush> {
clipmap_iw4::ClipMapRef {
nodes: &self.nodes,
@@ -208,6 +459,7 @@ impl ClipCollision {
let mesh = self.mesh.as_ref().as_ref();
let trace = clipmap_iw4::trace_brush_and_mesh(&map, &mesh, &ext, &|_piece| true);
ClipSweepHit {
coverage: self.coverage,
fraction: trace.fraction,
normal: trace.normal,
endpos: trace.endpos,
@@ -220,6 +472,7 @@ impl ClipCollision {
#[derive(Clone, Copy, Debug)]
pub struct ClipSweepHit {
pub coverage: CollisionCoverage,
pub fraction: f32,
pub normal: [f32; 3],
pub endpos: [f32; 3],
@@ -235,11 +488,15 @@ pub enum ClipCollisionError {
Truncated,
LeafbrushIndexOverflow,
InvalidSemantics(&'static str),
}
impl core::fmt::Display for ClipCollisionError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::InvalidSemantics(reason) => {
write!(f, "collision semantics or references are invalid: {reason}")
}
Self::MissingTables => write!(f, "clipmap brush tables were not retained"),
Self::Truncated => write!(f, "clipmap brush bytes truncated in arena"),
Self::LeafbrushIndexOverflow => {
@@ -362,6 +619,7 @@ pub fn build_clip_collision(
extract_cmodels(s, g, &mut out)?;
gate_leafbrush_index_fits(out.leafbrushes.len())?;
out.validate(asset_core::AssetNamespace::Iw4)?;
Ok(out)
}
@@ -372,6 +630,9 @@ pub fn attach_static_models(
out: &mut ClipCollision,
) {
extract_static_models(s, g, catalog, out);
if out.static_models.len() != g.static_model_count {
out.coverage = CollisionCoverage::Unvalidated;
}
}
pub fn attach_iw5_static_models(
@@ -381,6 +642,9 @@ pub fn attach_iw5_static_models(
out: &mut ClipCollision,
) {
extract_iw5_static_models(s, g, catalog, out);
if out.static_models.len() != g.static_model_count {
out.coverage = CollisionCoverage::Unvalidated;
}
}
fn extract_mesh_tables(
@@ -389,6 +653,12 @@ fn extract_mesh_tables(
out: &mut ClipCollision,
) -> Result<(), ClipCollisionError> {
let mesh = std::sync::Arc::make_mut(&mut out.mesh);
if (g.vert_count != 0 && g.verts.is_none())
|| (g.tri_count != 0 && g.tri_indices.is_none())
|| (g.material_count != 0 && g.materials.is_none())
{
return Err(ClipCollisionError::MissingTables);
}
let Some(verts_ptr) = g.verts else {
return Ok(());
};
@@ -548,7 +818,9 @@ fn extract_clip_materials_iw4(
) -> Result<Vec<ClipMapMaterial>, ClipCollisionError> {
let mut out = Vec::with_capacity(g.material_count);
let Some(mats) = g.materials else {
out.resize(g.material_count, ClipMapMaterial::default());
if g.material_count != 0 {
return Err(ClipCollisionError::MissingTables);
}
return Ok(out);
};
for i in 0..g.material_count {
@@ -568,6 +840,7 @@ fn extract_clip_materials_iw4(
.map_err(|_| ClipCollisionError::Truncated)?;
out.push(ClipMapMaterial {
name,
source_surface_flags: surface_flags,
surface_flags,
content_flags,
});
@@ -1160,6 +1433,7 @@ pub fn build_iw5_clip_collision(
extract_iw5_cmodels(s, g, &mut out)?;
gate_leafbrush_index_fits(out.leafbrushes.len())?;
out.validate(asset_core::AssetNamespace::Iw5)?;
Ok(out)
}
@@ -1307,6 +1581,12 @@ fn extract_iw5_mesh_tables(
out: &mut ClipCollision,
) -> Result<(), ClipCollisionError> {
let mesh = std::sync::Arc::make_mut(&mut out.mesh);
if (g.vert_count != 0 && g.verts.is_none())
|| (g.tri_count != 0 && g.tri_indices.is_none())
|| (g.material_count != 0 && g.materials.is_none())
{
return Err(ClipCollisionError::MissingTables);
}
let Some(verts_ptr) = g.verts else {
return Ok(());
};
@@ -1461,12 +1741,15 @@ fn extract_iw5_mesh_materials(
.map_err(|_| ClipCollisionError::Truncated)?;
mats.push(ClipMapMaterial {
name,
source_surface_flags: surface_flags,
surface_flags,
content_flags,
});
}
} else {
mats.resize(g.material_count, ClipMapMaterial::default());
if g.material_count != 0 {
return Err(ClipCollisionError::MissingTables);
}
}
mats
};
@@ -1814,6 +2097,7 @@ pub fn build_t5_clip_collision(
extract_t5_mesh_tables(s, g, &mut out)?;
extract_t5_bsp_tables(s, g, &mut out)?;
out.validate(asset_core::AssetNamespace::T5)?;
Ok(out)
}
@@ -1834,6 +2118,12 @@ fn extract_t5_mesh_tables(
out: &mut ClipCollision,
) -> Result<(), ClipCollisionError> {
let mesh = std::sync::Arc::make_mut(&mut out.mesh);
if (g.vert_count != 0 && g.verts.is_none())
|| (g.tri_count != 0 && g.tri_indices.is_none())
|| (g.material_count != 0 && g.materials.is_none())
{
return Err(ClipCollisionError::MissingTables);
}
let Some(verts_ptr) = g.verts else {
return Ok(());
};
@@ -1876,6 +2166,7 @@ fn extract_t5_mesh_tables(
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
out.materials.push(ClipMapMaterial {
name: String::from_utf8_lossy(&bytes[..end]).into_owned(),
source_surface_flags: s.u32_at(p, 64).map_err(|_| ClipCollisionError::Truncated)?,
surface_flags: treyarch_surface_flags(
s.u32_at(p, 64).map_err(|_| ClipCollisionError::Truncated)?,
),
+4 -1
View File
@@ -493,6 +493,7 @@ pub fn build_clip_collision(load: &ZoneLoad) -> Result<crate::ClipCollision, Str
let b = r.bytes(p, 12)?;
out.materials.push(ClipMapMaterial {
name: r.text(Reader::ptr(b, 0)?)?.to_owned(),
source_surface_flags: Reader::word(b, 4)?,
surface_flags: crate::clip_collision::treyarch_surface_flags(Reader::word(b, 4)?),
content_flags: Reader::word(b, 8)?,
});
@@ -553,7 +554,7 @@ pub fn build_clip_collision(load: &ZoneLoad) -> Result<crate::ClipCollision, Str
for index in 0..static_count {
let row = table.at(index * 84);
let Some(model_asset) = load.asset_in(asset, row.at(4)) else {
continue;
return Err(format!("T6 collision static model {index} is unresolved"));
};
let key = std::ptr::from_ref(model_asset);
if !models.contains_key(&key) {
@@ -802,6 +803,8 @@ pub fn build_clip_collision(load: &ZoneLoad) -> Result<crate::ClipCollision, Str
}
}
out.mesh = std::sync::Arc::new(mesh);
out.validate(asset_core::AssetNamespace::T6)
.map_err(|error| error.to_string())?;
Ok(out)
}
+1
View File
@@ -660,6 +660,7 @@ impl ZoneLane for Iw4Lane {
fx_glass,
map_content,
impact_fx,
family_impact_fx: Vec::new(),
reflection_probe_images,
intermission_view,
exp_fog,
+1
View File
@@ -158,6 +158,7 @@ pub(crate) enum CommonDependencyRefusal {
pub(crate) struct CommonPreparationResult {
pub products: CommonPreparationProducts,
pub impact_fx: Option<asset_game::OwnedFxImpactTable>,
pub refusals: Vec<CommonDependencyRefusal>,
}
+1
View File
@@ -454,6 +454,7 @@ impl ZoneLane for T5Lane {
fx_glass: None,
map_content: Default::default(),
impact_fx: sink.impact_fx.take_table(),
family_impact_fx: Vec::new(),
reflection_probe_images,
intermission_view,
exp_fog,
+72
View File
@@ -48,6 +48,7 @@ struct T6Content {
pub fx: Vec<asset_game::T6FxCapture>,
pub fx_materials: BTreeMap<String, T6MaterialCapture>,
pub camouflages: BTreeMap<String, Vec<asset_game::WeaponCamouflage>>,
pub impact_fx: Option<asset_game::OwnedFxImpactTable>,
pub report: Vec<String>,
}
@@ -1351,10 +1352,52 @@ fn capture_content(
content.materials.len(),
ipaks.len()
));
content.impact_fx = capture_impact_table(&zones);
capture_effects(&zones, ipaks, &mut content);
content
}
const FX_IMPACT_ROWS: u32 = 21;
const FX_IMPACT_ENTRY: u32 = 144;
const FX_IMPACT_NONFLESH: u32 = 32;
const FX_IMPACT_FIRST_EXPLOSION_ROW: usize = 11;
fn capture_impact_table(
zones: &[&fastfile_t6::ZoneLoad],
) -> Option<asset_game::OwnedFxImpactTable> {
zones.iter().find_map(|&load| {
let asset = load
.assets
.iter()
.find(|asset| asset.ty == fastfile_t6::AssetType::ImpactFx)?;
let table = decode_ptr(header_u32(&asset.header, 4)?)?;
let name_at = |cell: u32| {
load.asset_at(table.at(cell * 4))
.and_then(|fx| header_str(load, &fx.header, 0))
.unwrap_or_default()
.to_owned()
};
let entries = (0..FX_IMPACT_ROWS)
.map(|row| {
let first = row * FX_IMPACT_ENTRY / 4;
// The 32nd nonflesh surface (riotshield) has no IW4-shaped column.
asset_game::OwnedFxImpactEntry {
nonflesh: std::array::from_fn(|surf| name_at(first + surf as u32)),
flesh: std::array::from_fn(|slot| {
name_at(first + FX_IMPACT_NONFLESH + slot as u32)
}),
}
})
.collect();
Some(asset_game::OwnedFxImpactTable {
namespace: asset_core::AssetNamespace::T6,
name: header_str(load, &asset.header, 0)?.to_owned(),
entries,
capture_gaps: 0,
})
})
}
const FX_ELEM_DEF: u32 = 292;
const FX_ELEM_TYPE: usize = 184;
const FX_ELEM_VEL_SAMPLES: usize = 188;
@@ -1381,6 +1424,34 @@ fn capture_effects(
.map(|&name| name.to_owned())
.collect();
wanted.insert("maps/mp_maps/fx_mp_exp_bomb".to_owned());
if let Some(table) = &content.impact_fx {
wanted.extend(
table.entries[FX_IMPACT_FIRST_EXPLOSION_ROW..]
.iter()
.flat_map(|entry| entry.nonflesh.iter().chain(&entry.flesh))
.filter(|name| !name.is_empty())
.cloned(),
);
}
for &load in zones {
for asset in load
.assets
.iter()
.filter(|asset| asset.ty == fastfile_t6::AssetType::Weapon)
{
let Some(weapon) = fastfile_t6::weapon::WeaponView::new(load, asset) else {
continue;
};
use fastfile_t6::weapon::def;
wanted.extend(
[def::VIEW_FLASH_EFFECT, def::WORLD_FLASH_EFFECT]
.into_iter()
.filter_map(|off| weapon.def_asset_name(off))
.filter(|name| !name.is_empty())
.map(str::to_owned),
);
}
}
for load in zones {
match destructible::effect_names(load) {
Ok(names) => wanted.extend(names),
@@ -2524,6 +2595,7 @@ impl ZoneLane for T6Lane {
path: path.to_owned(),
..Default::default()
};
effect_content.impact_fx = capture_impact_table(&image_loads);
capture_effects(&image_loads, &ipaks, &mut effect_content);
let mut fx = asset_game::FxCatalog::default();
let mut effect_refusals = Vec::new();
@@ -27,6 +27,7 @@ impl CommonFamilyCompiler for T6CommonCompiler {
} = products;
let t6_captured = self.captured_weapons;
let mut content = self.content;
let impact_fx = content.impact_fx.take();
let t6_hands = content.hands.clone();
let (added, kept) = xanims.absorb_vacant(std::mem::take(&mut content.xanims));
report.push(format!(
@@ -92,6 +93,7 @@ impl CommonFamilyCompiler for T6CommonCompiler {
fx: common_fx,
report,
},
impact_fx,
refusals,
}
}
+2 -2
View File
@@ -32,8 +32,8 @@ pub use lighting_iw4::MODEL_LIGHTING_TILE_BYTES;
pub use loading_screen::{LoadingPreviewSource, LoadingScreen};
pub use map_load_process::MapLoadProcess;
pub use match_load::{
MapLoadApproval, MatchLoadAbort, MatchLoadAccepted, MatchLoadBusy, MatchLoadDispatch,
MatchLoadRequest, PreparedMatchReady, PreparedMatchSound,
CurrentMatchLoad, MapLoadApproval, MatchLoadAbort, MatchLoadAccepted, MatchLoadBusy,
MatchLoadDispatch, MatchLoadRequest, PreparedMatchReady, PreparedMatchSound,
};
pub use plugin::AssetPlugin;
pub use prepared::{
+110 -31
View File
@@ -20,6 +20,16 @@ pub struct MatchLoadBusy(pub bool);
#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq)]
pub struct MatchLoadAbort(pub u64);
#[derive(Resource, Default)]
pub struct CurrentMatchLoad(pub Option<frame::LocalLoadKey>);
fn current_load(world: &World, key: frame::LocalLoadKey) -> bool {
world.resource::<CurrentMatchLoad>().0 == Some(key)
&& world
.get_resource::<MatchLoadAbort>()
.is_none_or(|abort| abort.0 != key.local_load_request_id)
}
#[derive(Resource)]
pub struct MatchLoadRequest {
pub request_id: u64,
@@ -87,7 +97,15 @@ fn start_match_load(
.is_some_and(|abort| abort.0 == request.request_id)
{
*waiting_for_retirement = None;
commands.remove_resource::<MatchLoadRequest>();
let load_key = request.load_key;
commands.queue(move |world: &mut World| {
if world
.get_resource::<MatchLoadRequest>()
.is_some_and(|request| request.load_key == load_key)
{
world.remove_resource::<MatchLoadRequest>();
}
});
if busy.is_none() {
inflight.0 = false;
}
@@ -132,7 +150,15 @@ fn start_match_load(
zone: request.zone.clone(),
error: error.clone(),
});
commands.remove_resource::<MatchLoadRequest>();
let load_key = request.load_key;
commands.queue(move |world: &mut World| {
if world
.get_resource::<MatchLoadRequest>()
.is_some_and(|request| request.load_key == load_key)
{
world.remove_resource::<MatchLoadRequest>();
}
});
inflight.0 = false;
return;
}
@@ -148,12 +174,12 @@ fn start_match_load(
// The process outlives the overlay and every task this spawns: it is what
// the table reads, what admission finishes, and what the bench still has
// once the screen is gone.
commands.insert_resource(MapLoadProcess::new(
request_id,
load_key,
zone.clone(),
cancel_handle.clone(),
));
let process = MapLoadProcess::new(request_id, load_key, zone.clone(), cancel_handle.clone());
commands.queue(move |world: &mut World| {
if current_load(world, load_key) {
world.insert_resource(process);
}
});
let families = request.families;
let games = identity.map(|identity| asset_transport::GamesRoot(identity.games_root.clone()));
@@ -210,6 +236,9 @@ fn start_match_load(
});
inflight.0 = true;
commands.queue(move |world: &mut World| {
if !current_load(world, load_key) {
return;
}
frame::scope::insert(
world,
MatchLoadAccepted {
@@ -222,6 +251,10 @@ fn start_match_load(
);
});
commands.queue(move |world: &mut World| {
if !current_load(world, load_key) {
cancel_handle.cancel();
return;
}
frame::scope::insert(
world,
MatchLoadTask {
@@ -234,7 +267,14 @@ fn start_match_load(
frame::MatchScope::Loading,
);
});
commands.remove_resource::<MatchLoadRequest>();
commands.queue(move |world: &mut World| {
if world
.get_resource::<MatchLoadRequest>()
.is_some_and(|request| request.load_key == load_key)
{
world.remove_resource::<MatchLoadRequest>();
}
});
}
fn approve_map_load(
@@ -246,8 +286,10 @@ fn approve_map_load(
dvars: Res<frame::UiMenuDvars>,
server_families: Option<Res<frame::AssetFamilies>>,
abort: Option<Res<MatchLoadAbort>>,
mut current: ResMut<CurrentMatchLoad>,
) {
for request in approved.read() {
current.0 = Some(request.load_key);
if abort
.as_ref()
.is_some_and(|abort| abort.0 == request.request_id)
@@ -319,7 +361,7 @@ fn approve_map_load(
// Its own state, never the live map's: tasks from the request before
// this one still hold handles, and they must not land in these rows.
let progress = LoadProgress::new(request.request_id);
commands.insert_resource(MatchLoadRequest {
let candidate = MatchLoadRequest {
request_id: request.request_id,
load_key: request.load_key,
zone,
@@ -327,6 +369,11 @@ fn approve_map_load(
common_mp,
progress,
families,
};
commands.queue(move |world: &mut World| {
if current_load(world, candidate.load_key) {
world.insert_resource(candidate);
}
});
}
}
@@ -337,14 +384,16 @@ fn poll_match_load(
mut inflight: ResMut<MatchLoadBusy>,
abort: Option<Res<MatchLoadAbort>>,
mut failed: MessageWriter<MapLoadFailed>,
current: Res<CurrentMatchLoad>,
) {
let Some(task) = task.as_deref_mut() else {
return;
};
if abort
.as_ref()
.is_some_and(|abort| abort.0 == task.request_id)
if (current.0 != Some(task.load_key)
|| abort
.as_ref()
.is_some_and(|abort| abort.0 == task.request_id))
&& !task.progress.is_canceled()
{
task.progress.cancel();
@@ -363,9 +412,25 @@ fn poll_match_load(
Err(panic) => Err(walk_panic_failure(task, panic.as_ref())),
};
let request_id = task.request_id;
let load_key = task.load_key;
inflight.0 = false;
commands.remove_resource::<MatchLoadTask>();
commands.remove_resource::<MatchLoadAccepted>();
commands.queue(move |world: &mut World| {
if world
.get_resource::<MatchLoadTask>()
.is_some_and(|task| task.load_key == load_key)
{
world.remove_resource::<MatchLoadTask>();
}
if world
.get_resource::<MatchLoadAccepted>()
.is_some_and(|accepted| accepted.load_key == load_key)
{
world.remove_resource::<MatchLoadAccepted>();
}
});
if current.0 != Some(load_key) {
return;
}
let outcome = match outcome {
Ok(ready) => ready,
Err(error) => {
@@ -414,9 +479,10 @@ fn poll_match_load(
.unwrap_or_else(|| Err("the map zone never opened".to_owned())),
};
commands.queue(move |world: &mut World| {
if !current_load(world, ready.load_key) {
return;
}
frame::scope::insert(world, map_sound, frame::MatchScope::Loading);
});
commands.queue(move |world: &mut World| {
frame::scope::insert(world, ready, frame::MatchScope::Loading);
});
}
@@ -454,7 +520,18 @@ fn expire_match_load_abort(
if task.is_some() || request.is_some() || ready.is_some() {
return;
}
commands.remove_resource::<MatchLoadAbort>();
let request_id = abort.expect("checked abort").0;
commands.queue(move |world: &mut World| {
if world
.get_resource::<MatchLoadAbort>()
.is_some_and(|abort| abort.0 == request_id)
&& !world.contains_resource::<MatchLoadTask>()
&& !world.contains_resource::<MatchLoadRequest>()
&& !world.contains_resource::<PreparedMatchReady>()
{
world.remove_resource::<MatchLoadAbort>();
}
});
}
pub fn register_match_load_systems(app: &mut App) {
@@ -465,21 +542,23 @@ pub fn register_match_load_systems(app: &mut App) {
OnExit(frame::MatchScope::Loading),
cancel_loading.in_set(frame::ScopeSet::Release),
);
app.init_resource::<MatchLoadBusy>().add_systems(
Update,
(
approve_map_load.in_set(MapLoadApproval),
app.init_resource::<CurrentMatchLoad>()
.init_resource::<MatchLoadBusy>()
.add_systems(
Update,
(
start_match_load.run_if(in_state(frame::MatchScope::Loading)),
poll_match_load.after(start_match_load),
expire_match_load_abort.after(poll_match_load),
approve_map_load.in_set(MapLoadApproval),
(
start_match_load.run_if(in_state(frame::MatchScope::Loading)),
poll_match_load.after(start_match_load),
expire_match_load_abort.after(poll_match_load),
)
.in_set(MatchLoadDispatch)
.after(MapLoadApproval),
)
.in_set(MatchLoadDispatch)
.after(MapLoadApproval),
)
.after(frame::SessionSwapApplied)
.in_set(ClientSet::Load),
);
.after(frame::SessionSwapApplied)
.in_set(ClientSet::Load),
);
}
fn cancel_loading(task: Option<Res<MatchLoadTask>>, mut busy: ResMut<MatchLoadBusy>) {
+130
View File
@@ -195,3 +195,133 @@ pub struct SessionCompass {
#[derive(Clone, Copy, Debug, Default, Resource, PartialEq, Eq)]
pub struct PreparedBodyClips(pub bool);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DependencyRole {
Authority,
Presentation,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DependencyImportance {
Required,
Optional,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PreparedDependency {
pub key: String,
pub source: String,
pub role: DependencyRole,
pub importance: DependencyImportance,
pub available: bool,
}
#[derive(Clone, Debug, Resource)]
pub struct PreparedCapabilities {
pub version: u32,
pub products: frame::WorldProducts,
pub dependencies: Vec<PreparedDependency>,
pub script_fx: std::collections::BTreeSet<String>,
}
impl PreparedCapabilities {
pub fn prepare(prepared: &crate::PreparedMatch) -> Self {
let mut script_fx = std::collections::BTreeSet::new();
let mut dependencies = Vec::new();
for namespace in [
asset_core::AssetNamespace::Iw4,
asset_core::AssetNamespace::Iw5,
asset_core::AssetNamespace::T5,
asset_core::AssetNamespace::T6,
] {
for name in prepared.fx.names() {
if prepared.fx.get_in(namespace, name).is_none() {
continue;
}
let key = format!("{}:{name}", namespace.as_str()).to_ascii_lowercase();
script_fx.insert(key.clone());
script_fx.insert(name.to_ascii_lowercase());
dependencies.push(PreparedDependency {
key,
source: format!("{} effect corpus", namespace.as_str()),
role: DependencyRole::Presentation,
importance: DependencyImportance::Optional,
available: true,
});
}
}
for (key, source) in prepared.scripts.fx_references() {
let available = script_fx.contains(&key);
script_fx.insert(key.clone());
dependencies.push(PreparedDependency {
key,
source,
role: DependencyRole::Presentation,
importance: DependencyImportance::Optional,
available,
});
}
let has_crossbow = (1..=prepared.weapons.len() as u32)
.filter_map(|id| prepared.weapons.key_of(id))
.any(|key| {
key.namespace == asset_core::AssetNamespace::T6 && key.name.contains("crossbow")
});
if has_crossbow {
let effect = "weapon/crossbow/fx_trail_crossbow_blink_grn_os";
for (key, available) in [
(
format!("t5:{effect}"),
prepared
.fx
.get_in(asset_core::AssetNamespace::T5, effect)
.is_some(),
),
(
"t5:wpn_crossbow_alert".into(),
prepared
.script_sound_aliases
.as_ref()
.is_some_and(|aliases| {
aliases
.get("t5:wpn_crossbow_alert")
.is_some_and(|facts| facts.is_some())
}),
),
] {
dependencies.push(PreparedDependency {
key,
source: "t6 crossbow warning".into(),
role: DependencyRole::Presentation,
importance: DependencyImportance::Required,
available,
});
}
}
Self {
version: 1,
products: frame::WorldProducts::from_walk(prepared.materials.products_id),
dependencies,
script_fx,
}
}
pub fn validate(&self, role: frame::RuntimeRole) -> Result<(), String> {
for dependency in &self.dependencies {
let required_role = match dependency.role {
DependencyRole::Authority => role.runs_authority(),
DependencyRole::Presentation => role.runs_client(),
};
if required_role
&& dependency.importance == DependencyImportance::Required
&& !dependency.available
{
return Err(format!(
"Required capability {} missing ({})",
dependency.key, dependency.source
));
}
}
Ok(())
}
}
+64
View File
@@ -65,6 +65,70 @@ impl ScriptSources {
names
}
pub(crate) fn fx_references(&self) -> BTreeMap<String, String> {
let mut references = BTreeMap::new();
for (module, source) in &self.sources {
let Ok(bytes) = &source.bytes else {
continue;
};
let mut at = 0;
while at < bytes.len() {
if bytes[at..].starts_with(b"//") {
while at < bytes.len() && bytes[at] != b'\n' {
at += 1;
}
} else if bytes[at..].starts_with(b"/*") {
at += 2;
while at + 1 < bytes.len() && &bytes[at..at + 2] != b"*/" {
at += 1;
}
at = (at + 2).min(bytes.len());
} else if bytes[at] == b'"' {
at += 1;
while at < bytes.len() && bytes[at] != b'"' {
at += if bytes[at] == b'\\' { 2 } else { 1 };
}
at = (at + 1).min(bytes.len());
} else if bytes[at].is_ascii_alphabetic() || bytes[at] == b'_' {
let start = at;
while at < bytes.len()
&& (bytes[at].is_ascii_alphanumeric() || bytes[at] == b'_')
{
at += 1;
}
if !bytes[start..at].eq_ignore_ascii_case(b"loadfx") {
continue;
}
let mut value = at;
while bytes.get(value).is_some_and(u8::is_ascii_whitespace) {
value += 1;
}
if bytes.get(value) != Some(&b'(') {
continue;
}
value += 1;
while bytes.get(value).is_some_and(u8::is_ascii_whitespace) {
value += 1;
}
if bytes.get(value) != Some(&b'"') {
continue;
}
value += 1;
let start = value;
while value < bytes.len() && bytes[value] != b'"' {
value += 1;
}
if let Ok(name) = std::str::from_utf8(&bytes[start..value]) {
references.insert(normalize(name), module.clone());
}
} else {
at += 1;
}
}
}
references
}
pub fn schemas(&self) -> &BTreeMap<String, std::sync::Arc<structured_data_iw4::DefinitionSet>> {
&self.schemas
}
@@ -7,6 +7,7 @@ pub struct CommonKey {
pub(super) runtime: Option<PathBuf>,
pub(super) foreign: Option<PathBuf>,
source_revision: u64,
collision_adapter_version: u32,
families: frame::AssetFamilies,
}
@@ -20,6 +21,7 @@ impl CommonKey {
let source_revision = games_root_from_env()
.map_or(0, |games| asset_transport::installation_revision(&games.0));
Self {
collision_adapter_version: asset_world::COLLISION_ADAPTER_VERSION,
source_revision,
runtime: runtime.map(Path::to_path_buf),
foreign: families
@@ -39,6 +41,7 @@ impl CommonKey {
}
};
Self {
collision_adapter_version: asset_world::COLLISION_ADAPTER_VERSION,
source_revision: asset_transport::installation_revision(&games.0),
runtime,
foreign: None,
@@ -107,6 +110,7 @@ pub(super) struct CommonProducts {
pub(super) t6_player_anim_sources: asset_anim::PlayerAnimSources,
pub(super) t5_fx: FxCatalog,
pub(super) t5_impact_fx: Option<asset_game::OwnedFxImpactTable>,
pub(super) t6_impact_fx: Option<asset_game::OwnedFxImpactTable>,
pub(super) iw5_materials: MaterialCatalog,
pub(super) iw5_scene_models: asset_world::MapXModelSceneCatalog,
pub(super) iw5_shared_surfaces: asset_model::SharedXModelSurfaces,
@@ -730,6 +734,7 @@ async fn prepare_common(key: CommonKey) -> Arc<CommonSet> {
t6_weapon_walk.await;
common_report.extend(t6_report);
weapons.absorb(t6_weapons);
let mut t6_impact_fx = None;
if let Some(compiler) = preparation {
let prepared = compiler.compile(crate::lane::CommonPreparationProducts {
weapons,
@@ -747,6 +752,7 @@ async fn prepare_common(key: CommonKey) -> Arc<CommonSet> {
.iter()
.map(|cause| format!("common family preparation refused: {cause:?}")),
);
t6_impact_fx = prepared.impact_fx;
let products = prepared.products;
common_report.extend(products.report);
(
@@ -943,6 +949,7 @@ async fn prepare_common(key: CommonKey) -> Arc<CommonSet> {
t6_player_anim_sources,
t5_fx,
t5_impact_fx,
t6_impact_fx,
iw5_materials,
iw5_scene_models,
iw5_shared_surfaces,
+16 -5
View File
@@ -98,6 +98,7 @@ pub(super) async fn walk_prepared_match(
t6_player_anim_sources,
t5_fx,
t5_impact_fx,
t6_impact_fx,
iw5_materials,
mut iw5_scene_models,
iw5_shared_surfaces,
@@ -720,12 +721,14 @@ pub(super) async fn walk_prepared_match(
.into(),
);
decode_fx_colour_maps(&world.fx, &mut global, &image_trees, &progress, &mut report);
world.family_impact_fx = [t5_impact_fx, t6_impact_fx].into_iter().flatten().collect();
if world.impact_fx.is_none() {
world.impact_fx = if map_namespace == Some(asset_core::AssetNamespace::T5) {
t5_impact_fx.or(common_impact)
} else {
common_impact
};
world.impact_fx = world
.family_impact_fx
.iter()
.find(|table| Some(table.namespace) == map_namespace)
.cloned()
.or(common_impact);
} else if let Some(common_table) = common_impact {
report.push(format!(
"impactfx: map table kept; common_mp table `{}` discarded",
@@ -743,6 +746,14 @@ pub(super) async fn walk_prepared_match(
} else {
report.push("impactfx handoff: missing — combat play_oriented will miss cells".into());
}
for table in &world.family_impact_fx {
report.push(format!(
"impactfx family table: {:?}:`{}` rows={}",
table.namespace,
table.name,
table.row_count()
));
}
report.push(format!(
"tracer catalog handoff: {} named ({} bound, {} unresolved)",
common_tracers.len(),
+2
View File
@@ -129,6 +129,7 @@ pub struct PreparedWorld {
pub map_content: Arc<asset_core::MapContentDefinition>,
pub impact_fx: Option<asset_game::OwnedFxImpactTable>,
pub family_impact_fx: Vec<asset_game::OwnedFxImpactTable>,
pub reflection_probe_images: Vec<Option<bevy::prelude::Image>>,
pub intermission_view: Option<IntermissionView>,
@@ -215,6 +216,7 @@ impl PreparedWorld {
fx_glass: Default::default(),
map_content: Default::default(),
impact_fx: Default::default(),
family_impact_fx: Default::default(),
reflection_probe_images: Default::default(),
intermission_view: Default::default(),
exp_fog: Default::default(),
@@ -76,18 +76,6 @@ pub async fn load_prepared_match(
return MatchLoadOutcome::Ready(prepared);
}
}
let dropped = RESIDENT_MAP
.lock()
.unwrap_or_else(|poison| poison.into_inner())
.take();
if let Some(dropped) = dropped {
diag::info!(
World,
"resident map: `{}` walk #{} dropped before the next walk",
dropped.zone.path.display(),
dropped.prepared.materials.products_id
);
}
let (outcome, common) =
walk_prepared_match(zone_ff, common_mp, progress.clone(), families).await;
let MatchLoadOutcome::Ready(mut prepared) = outcome else {
+73 -22
View File
@@ -12,7 +12,6 @@ use bevy::{
use crate::backend::AudioScope;
use crate::playback::SoundBank;
use crate::sources::{DesiredSource, SourceCueRequest, SourceKey, SourceRenderGroup};
use frame::{MatchScope, ScopeEpoch};
pub(crate) const MAP_BED_SLOT: u32 = 2;
const MAP_EMITTER_SLOT: u32 = 3;
@@ -29,7 +28,10 @@ pub struct SoundIwd(pub Arc<NamespaceSoundIwd>);
pub struct MapAmbientBooted(pub bool);
#[derive(Resource, Default)]
pub(crate) struct SoundBankLoadAttempted(pub bool);
pub struct LoadingAudioOwner(pub Option<frame::LocalLoadKey>);
#[derive(Resource, Default)]
pub(crate) struct SoundBankLoadAttempted(pub Option<frame::LocalLoadKey>);
const SOUND_BANK_COMPOSE_STALL: std::time::Duration = std::time::Duration::from_secs(20);
@@ -155,25 +157,29 @@ pub(crate) struct SoundBankNamespace {
pub(crate) fn start_sound_bank_compose(
mut attempted: ResMut<SoundBankLoadAttempted>,
mut status: ResMut<LoadingAudioStatus>,
mut owner: ResMut<LoadingAudioOwner>,
accepted: Option<Res<assets::MatchLoadAccepted>>,
current: Res<assets::CurrentMatchLoad>,
abort: Option<Res<assets::MatchLoadAbort>>,
identity: Option<Res<frame::LaunchIdentity>>,
silent: Option<Res<crate::AudioSilent>>,
resident: Res<ResidentSoundBank>,
mut commands: Commands,
) {
if attempted.0 {
return;
}
if silent.is_some() {
*status = LoadingAudioStatus::Ready;
attempted.0 = true;
return;
}
let Some(accepted) = accepted else {
return;
};
if abort.is_some_and(|abort| abort.0 == accepted.request_id) {
if current.0 != Some(accepted.load_key)
|| abort.is_some_and(|abort| abort.0 == accepted.request_id)
|| attempted.0 == Some(accepted.load_key)
{
return;
}
owner.0 = Some(accepted.load_key);
*status = LoadingAudioStatus::Pending;
if silent.is_some() {
*status = LoadingAudioStatus::Ready;
attempted.0 = Some(accepted.load_key);
return;
}
let Some(identity) = identity else {
@@ -182,7 +188,7 @@ pub(crate) fn start_sound_bank_compose(
if accepted.zone.is_empty() || identity.games_root.as_os_str().is_empty() {
return;
}
attempted.0 = true;
attempted.0 = Some(accepted.load_key);
let games = GamesRoot(identity.games_root.clone());
let zone = accepted.zone.clone();
let opened_zone = zone.clone();
@@ -211,6 +217,9 @@ pub(crate) fn start_sound_bank_compose(
};
let load_key = accepted.load_key;
commands.queue(move |world: &mut World| {
if world.resource::<assets::CurrentMatchLoad>().0 != Some(load_key) {
return;
}
frame::scope::insert(
world,
SoundBankCompose {
@@ -234,6 +243,7 @@ pub(crate) fn install_sound_bank(
mut compose: Option<ResMut<SoundBankCompose>>,
identity: Option<Res<frame::LaunchIdentity>>,
accepted: Option<Res<assets::MatchLoadAccepted>>,
current: Res<assets::CurrentMatchLoad>,
abort: Option<Res<assets::MatchLoadAbort>>,
mut map_sound: Option<ResMut<assets::PreparedMatchSound>>,
process: Option<Res<assets::MapLoadProcess>>,
@@ -245,16 +255,25 @@ pub(crate) fn install_sound_bank(
return;
};
let superseded = accepted
.as_ref()
.is_some_and(|accepted| accepted.load_key != compose.load_key);
let superseded = current.0 != Some(compose.load_key)
|| accepted
.as_ref()
.is_some_and(|accepted| accepted.load_key != compose.load_key);
if abort.is_some_and(|abort| abort.0 == compose.load_key.local_load_request_id) || superseded {
diag::info!(
Audio,
"audio: sound bank for `{}` dropped — the session moved on",
compose.zone
);
commands.remove_resource::<SoundBankCompose>();
let load_key = compose.load_key;
commands.queue(move |world: &mut World| {
if world
.get_resource::<SoundBankCompose>()
.is_some_and(|compose| compose.load_key == load_key)
{
world.remove_resource::<SoundBankCompose>();
}
});
return;
}
// Install on the load key, not `identity.zone`: gating on a display spelling
@@ -282,7 +301,15 @@ pub(crate) fn install_sound_bank(
compose.common_profile_id = arrived.common_profile_id;
let products_id = arrived.products_id;
let map = std::mem::replace(&mut arrived.sound, Err(String::new()));
commands.remove_resource::<assets::PreparedMatchSound>();
let load_key = compose.load_key;
commands.queue(move |world: &mut World| {
if world
.get_resource::<assets::PreparedMatchSound>()
.is_some_and(|sound| sound.load_key == load_key)
{
world.remove_resource::<assets::PreparedMatchSound>();
}
});
let kept = resident
.0
.as_ref()
@@ -341,7 +368,15 @@ pub(crate) fn install_sound_bank(
}
return;
};
commands.remove_resource::<SoundBankCompose>();
let load_key = compose.load_key;
commands.queue(move |world: &mut World| {
if world
.get_resource::<SoundBankCompose>()
.is_some_and(|compose| compose.load_key == load_key)
{
world.remove_resource::<SoundBankCompose>();
}
});
let IwdOpen::Open(iwd) = std::mem::replace(&mut compose.iwd, IwdOpen::Open(Arc::default()))
else {
unreachable!("the bank is polled only once the archives are open");
@@ -396,10 +431,19 @@ pub(crate) fn install_sound_bank(
zone: compose.zone.clone(),
namespace,
};
let load_key = compose.load_key;
commands.queue(move |world: &mut World| {
if world.resource::<assets::CurrentMatchLoad>().0 != Some(load_key)
|| world
.get_resource::<assets::MatchLoadAbort>()
.is_some_and(|abort| abort.0 == load_key.local_load_request_id)
{
return;
}
frame::scope::insert(
world,
PreparedMatchAudio {
load_key,
iwd: sound_iwd,
clips: new_clips,
bank: SoundBank(bank),
@@ -428,7 +472,7 @@ pub(crate) fn boot_map_ambient_once(
identity: Option<Res<frame::LaunchIdentity>>,
script_sound: Option<Res<asset_audio::SessionMapScriptSound>>,
namespace: Option<Res<SoundBankNamespace>>,
epoch: Res<ScopeEpoch<MatchScope>>,
epoch: Option<Res<net::EntityEventCursor>>,
ready: Res<crate::AudioReady>,
generation: Res<frame::WorldGeneration>,
runtime: Res<crate::AudioRuntime>,
@@ -495,7 +539,7 @@ pub(crate) fn boot_map_ambient_once(
alias,
emitter: None,
scope: AudioScope::Match,
epoch: epoch.0,
epoch: crate::backend::presentation_epoch(epoch.as_deref()),
group,
}) else {
continue;
@@ -503,7 +547,7 @@ pub(crate) fn boot_map_ambient_once(
sources.desired.push(DesiredSource {
key: SourceKey {
scope: AudioScope::Match,
epoch: epoch.0,
epoch: crate::backend::presentation_epoch(epoch.as_deref()),
object,
slot,
},
@@ -538,13 +582,20 @@ pub enum LoadingAudioStatus {
}
#[derive(Resource)]
struct PreparedMatchAudio {
pub struct PreparedMatchAudio {
pub load_key: frame::LocalLoadKey,
iwd: SoundIwd,
clips: crate::ClipStore,
bank: SoundBank,
namespace: SoundBankNamespace,
}
pub fn prepared_match_audio_key(world: &World) -> Option<frame::LocalLoadKey> {
world
.get_resource::<PreparedMatchAudio>()
.map(|prepared| prepared.load_key)
}
pub fn stage_prepared_match_audio(world: &mut World) {
if let Some(prepared) = world.remove_resource::<PreparedMatchAudio>() {
frame::scope::stage(world, prepared.iwd);
+32 -25
View File
@@ -4,12 +4,12 @@ use frame::ClientSet;
pub(crate) use crate::render_core::AudioScope;
use crate::runtime::AudioRuntime;
use frame::{MatchScope, ScopeEpoch};
use frame::MatchScope;
pub(crate) fn register(app: &mut App) {
app.add_systems(
StateTransition,
sync_scope_epoch
sync_presentation_epoch
.in_set(bevy::state::state::StateTransitionSystems::TransitionSchedules)
.after(frame::scope::ScopeRetired),
);
@@ -23,24 +23,23 @@ pub(crate) fn register(app: &mut App) {
app.world()
.resource::<AudioRuntime>()
.set_fire_verdicts(verdicts);
app.init_resource::<ScopeEpoch<MatchScope>>()
.add_systems(
Update,
publish_audio_context
.in_set(ClientSet::Predict)
.after(frame::OwnerEventsPublished),
)
.add_systems(
PostUpdate,
(
submit_presented_audio,
cancel_audio_on_exit.after(submit_presented_audio),
),
);
app.add_systems(
Update,
publish_audio_context
.in_set(ClientSet::Predict)
.after(frame::OwnerEventsPublished),
)
.add_systems(
PostUpdate,
(
submit_presented_audio,
cancel_audio_on_exit.after(submit_presented_audio),
),
);
}
pub(crate) fn publish_audio_context(
epoch: Res<ScopeEpoch<MatchScope>>,
epoch: Option<Res<net::EntityEventCursor>>,
runtime: Res<AudioRuntime>,
verdicts: Option<Res<net::FireVerdictState>>,
clips: Option<Res<crate::ClipStore>>,
@@ -59,7 +58,7 @@ pub(crate) fn publish_audio_context(
if let Some(verdicts) = verdicts {
runtime.set_fire_verdicts(verdicts.clone());
}
runtime.set_match_epoch(epoch.0);
runtime.set_presentation_epoch(crate::backend::presentation_epoch(epoch.as_deref()));
runtime.set_event_context(
generation
.and_then(|generation| generation.0)
@@ -109,15 +108,15 @@ pub(crate) fn publish_audio_context(
.or_else(|| frontend.as_ref().map(|frontend| frontend.clips.service())),
);
if let Some(mix) = mix.as_mut() {
mix.reset_epoch(epoch.0);
mix.reset_epoch(crate::backend::presentation_epoch(epoch.as_deref()));
}
if let Some(channels) = channels.as_mut() {
channels.reset_epoch(epoch.0);
channels.reset_epoch(crate::backend::presentation_epoch(epoch.as_deref()));
}
runtime.set_cue_mix(
mix.zip(channels)
.map(|(mix, channels)| crate::cue_execution::CueMix {
epoch: epoch.0,
epoch: crate::backend::presentation_epoch(epoch.as_deref()),
gain: mix.gain.clone(),
channels: channels.bindings(),
}),
@@ -168,17 +167,18 @@ fn cancel_audio_on_exit(mut exit: MessageReader<AppExit>, runtime: Res<AudioRunt
fn submit_presented_audio(
mut runtime: ResMut<AudioRuntime>,
epoch: Res<ScopeEpoch<MatchScope>>,
epoch: Option<Res<net::EntityEventCursor>>,
settings: Option<Res<frame::GameSettings>>,
listeners: Query<&Transform, With<crate::AmbientListener>>,
destructibles: Option<Res<crate::destructible_loops::DestructibleSources>>,
projectiles: Option<Res<crate::projectile_loops::ProjectileSources>>,
flames: Option<Res<crate::flame_loops::FlameSources>>,
map: Option<Res<crate::ambient::MapSources>>,
menu: Option<Res<crate::frontend::MenuSources>>,
shellshock: Option<Res<crate::shellshock::ShellshockSources>>,
breath: Option<Res<crate::breath::BreathSources>>,
) {
runtime.set_match_epoch(epoch.0);
runtime.set_presentation_epoch(crate::backend::presentation_epoch(epoch.as_deref()));
let mut listener = listeners.iter();
let pose = listener.next();
assert!(listener.next().is_none(), "more than one ambient listener");
@@ -196,6 +196,9 @@ fn submit_presented_audio(
if let Some(projectiles) = projectiles {
desired.extend(projectiles.desired.iter().cloned());
}
if let Some(flames) = flames {
desired.extend(flames.desired.iter().cloned());
}
if let Some(map) = map {
desired.extend(map.desired.iter().cloned());
}
@@ -211,8 +214,12 @@ fn submit_presented_audio(
runtime.set_sources(desired);
}
fn sync_scope_epoch(epoch: Res<ScopeEpoch<MatchScope>>, runtime: Res<AudioRuntime>) {
runtime.set_match_epoch(epoch.0);
pub(crate) fn presentation_epoch(cursor: Option<&net::EntityEventCursor>) -> u64 {
cursor.map_or(0, net::EntityEventCursor::timeline)
}
fn sync_presentation_epoch(epoch: Option<Res<net::EntityEventCursor>>, runtime: Res<AudioRuntime>) {
runtime.set_presentation_epoch(crate::backend::presentation_epoch(epoch.as_deref()));
}
fn release_audio(mut runtime: ResMut<AudioRuntime>) {
+10 -6
View File
@@ -33,7 +33,7 @@ pub(crate) fn update(
mut state: Local<BreathAudio>,
mut sources: ResMut<BreathSources>,
runtime: Res<crate::AudioRuntime>,
epoch: Res<frame::ScopeEpoch<frame::MatchScope>>,
epoch: Option<Res<net::EntityEventCursor>>,
mut play: MessageWriter<AliasCommand>,
) {
let client = local.as_ref().map(|local| local.0);
@@ -45,9 +45,13 @@ pub(crate) fn update(
.for_client(client)
.map(|meta| meta.life_sequence.0)
});
let context = client
.zip(life)
.map(|(client, life)| (epoch.0, client.0, life));
let context = client.zip(life).map(|(client, life)| {
(
crate::backend::presentation_epoch(epoch.as_deref()),
client.0,
life,
)
});
if sources.context != context {
if state.active {
stop_aliases(
@@ -145,7 +149,7 @@ pub(crate) fn update(
alias: cues.heartbeat.into(),
emitter: client.map(|client| client.0),
scope: crate::backend::AudioScope::Match,
epoch: epoch.0,
epoch: crate::backend::presentation_epoch(epoch.as_deref()),
group: None,
})
{
@@ -156,7 +160,7 @@ pub(crate) fn update(
sources.source = Some(DesiredSource {
key: SourceKey {
scope: crate::backend::AudioScope::Match,
epoch: epoch.0,
epoch: crate::backend::presentation_epoch(epoch.as_deref()),
object: u64::from(client.expect("alive client").0),
slot: 6,
},
+5 -6
View File
@@ -9,7 +9,6 @@ use crate::backend::AudioScope;
use crate::playback::SoundBank;
use crate::runtime::AudioRuntime;
use crate::sources::{DesiredSource, SourceKey};
use frame::{MatchScope, ScopeEpoch};
struct PresentedLoop {
alias: String,
@@ -30,12 +29,12 @@ pub(crate) fn update(
presented: Res<PresentedSnapshot>,
bank: Option<Res<SoundBank>>,
namespace: Option<Res<SoundBankNamespace>>,
epoch: Res<ScopeEpoch<MatchScope>>,
epoch: Option<Res<net::EntityEventCursor>>,
runtime: Res<AudioRuntime>,
mut sources: ResMut<DestructibleSources>,
) {
if sources.epoch != epoch.0 {
sources.epoch = epoch.0;
if sources.epoch != crate::backend::presentation_epoch(epoch.as_deref()) {
sources.epoch = crate::backend::presentation_epoch(epoch.as_deref());
sources.live.clear();
sources.desired.clear();
}
@@ -86,7 +85,7 @@ pub(crate) fn update(
alias: alias.into(),
emitter: row.snd_ent,
scope: AudioScope::Match,
epoch: epoch.0,
epoch: crate::backend::presentation_epoch(epoch.as_deref()),
group: None,
}) else {
continue;
@@ -105,7 +104,7 @@ pub(crate) fn update(
source: DesiredSource {
key: SourceKey {
scope: AudioScope::Match,
epoch: epoch.0,
epoch: crate::backend::presentation_epoch(epoch.as_deref()),
object: u64::from(owner),
slot: 1,
},
+187
View File
@@ -0,0 +1,187 @@
use std::collections::HashMap;
use asset_game::WeaponSoundSlot;
use bevy::prelude::*;
use crate::backend::AudioScope;
use crate::sources::{DesiredSource, SourceCueRequest, SourceKey};
use crate::{AudioRuntime, FlameFire, SoundBank, WeaponSound};
const STREAM_GRACE_SECS: f32 = 0.25;
struct FlameStream {
weapon: u32,
player_view: bool,
origin_inches: [f32; 3],
last_fire: f32,
source: Option<DesiredSource>,
}
#[derive(Resource, Default)]
pub(crate) struct FlameSources {
context: Option<frame::PresentationContext>,
next_version: u64,
live: HashMap<u32, FlameStream>,
pub desired: Vec<DesiredSource>,
}
fn slot_pair(
player_view: bool,
world: WeaponSoundSlot,
player: WeaponSoundSlot,
) -> [WeaponSoundSlot; 2] {
if player_view {
[player, world]
} else {
[world, player]
}
}
pub(crate) fn update(
mut fires: MessageReader<FlameFire>,
weapons: Option<Res<assets::PreparedWeapons>>,
presented: Res<net::PresentedSnapshot>,
bank: Option<Res<SoundBank>>,
time: Res<Time>,
timeline: Res<net::EntityEventCursor>,
runtime: Res<AudioRuntime>,
mut sounds: MessageWriter<WeaponSound>,
mut sources: ResMut<FlameSources>,
installed: Res<frame::InstalledMatch>,
) {
let context = Some(frame::PresentationContext::new(
installed.load_key,
timeline.timeline(),
));
if sources.context != context {
sources.context = context;
sources.live.clear();
}
sources.desired.clear();
let (Some(bank), Some(bound)) = (
bank,
weapons
.as_ref()
.and_then(|weapons| weapons.for_snapshot(presented.weapon_epoch()).ok()),
) else {
fires.clear();
sources.live.clear();
return;
};
let weapons = bound.registry();
let key = |weapon: u32, slots: [WeaponSoundSlot; 2]| {
slots
.into_iter()
.find_map(|slot| weapons.weapon_sound_key(weapon, slot, &bank.0))
};
let mut oneshot = |stream: &FlameStream, number: u32, world, player| {
let Some((namespace, alias)) =
key(stream.weapon, slot_pair(stream.player_view, world, player))
else {
return;
};
sounds.write(WeaponSound {
event: None,
namespace,
alias: alias.to_owned(),
origin_inches: (!stream.player_view).then_some(stream.origin_inches),
snd_ent: Some(number),
});
};
let now = time.elapsed_secs();
for fire in fires.read() {
let restarted = sources
.live
.get(&fire.number)
.is_none_or(|stream| stream.weapon != fire.weapon);
if restarted {
let stream = FlameStream {
weapon: fire.weapon,
player_view: fire.player_view,
origin_inches: fire.origin_inches,
last_fire: now,
source: None,
};
oneshot(
&stream,
fire.number,
WeaponSoundSlot::FireStart,
WeaponSoundSlot::FireStartPlayer,
);
sources.live.insert(fire.number, stream);
}
let stream = sources.live.get_mut(&fire.number).expect("flame stream");
stream.player_view = fire.player_view;
stream.origin_inches = fire.origin_inches;
stream.last_fire = now;
}
let mut ended = Vec::new();
for (&number, stream) in &sources.live {
if now - stream.last_fire > STREAM_GRACE_SECS {
ended.push(number);
}
}
for number in ended {
let stream = sources.live.remove(&number).expect("flame stream");
oneshot(
&stream,
number,
WeaponSoundSlot::FireStop,
WeaponSoundSlot::FireStopPlayer,
);
}
let FlameSources {
next_version,
live,
desired,
..
} = &mut *sources;
for (&number, stream) in live.iter_mut() {
if stream.source.is_none() {
let Some((namespace, alias)) = key(
stream.weapon,
slot_pair(
stream.player_view,
WeaponSoundSlot::FireLoop,
WeaponSoundSlot::FireLoopPlayer,
),
) else {
continue;
};
let Some(cue) = runtime.source_cue(SourceCueRequest {
bank: bank.0.clone(),
namespace,
alias: alias.into(),
emitter: Some(number),
scope: AudioScope::Match,
epoch: timeline.timeline(),
group: None,
}) else {
continue;
};
*next_version = next_version
.checked_add(1)
.expect("source version exhausted");
stream.source = Some(DesiredSource {
key: SourceKey {
scope: AudioScope::Match,
epoch: timeline.timeline(),
object: u64::from(number),
slot: 8,
},
version: *next_version,
cue,
origin_inches: None,
start_frame: runtime.audio_frame(),
gain: 1.0,
rate: 1.0,
audible: true,
});
}
let Some(source) = stream.source.as_mut() else {
continue;
};
source.origin_inches = (!stream.player_view).then_some(stream.origin_inches);
desired.push(source.clone());
}
}
+2 -2
View File
@@ -154,7 +154,7 @@ fn play_ui_sound_messages(
mut decisions: ResMut<StartDecisions>,
bank: Option<Res<SoundBank>>,
frontend: Option<Res<FrontendAudio>>,
epoch: Res<frame::ScopeEpoch<frame::MatchScope>>,
epoch: Option<Res<net::EntityEventCursor>>,
namespace: Option<Res<crate::ambient::SoundBankNamespace>>,
) {
let bank = bank
@@ -191,7 +191,7 @@ fn play_ui_sound_messages(
&mut decisions,
Some(crate::SND_ENT_LOCAL),
SoundClass::Ui,
epoch.0,
crate::backend::presentation_epoch(epoch.as_deref()),
);
if outcome.allows_binding_fallback() {
gaps.record(&event.alias);
+6 -3
View File
@@ -24,13 +24,15 @@ pub use aliases::{
land_aliases, quiet_surface_alias, select_cg_fire_alias, select_fire_alias, step_prefix,
surface_alias_candidates, world_surface_alias,
};
pub use ambient::{MapAmbientBooted, SoundIwd, stage_prepared_match_audio};
pub use ambient::{
MapAmbientBooted, SoundIwd, prepared_match_audio_key, stage_prepared_match_audio,
};
pub use clip_store::{ClipPath, ClipPathCost, ClipPrepCost, ClipStore, clip_prep_cost};
pub use emit::{BobCycleTracker, emit_footstep_on_bob_wrap, emit_weapon_fire};
pub use frontend::FrontendAudio;
pub use match_set::{AudioReady, AudioSilent};
pub use messages::{
AliasCommand, BoundWeaponSound, Footstep, LandSound, PlayAlias, SND_ENT_LOCAL,
AliasCommand, BoundWeaponSound, FlameFire, Footstep, LandSound, PlayAlias, SND_ENT_LOCAL,
ViewmodelNotetrack, ViewmodelNotetracks, WeaponSound, ent_from_number,
};
pub use playback::{
@@ -43,6 +45,7 @@ pub use start::{
};
mod destructible_loops;
mod flame_loops;
mod match_set;
mod match_voices;
mod projectile_loops;
@@ -83,4 +86,4 @@ mod cue_execution;
mod event;
pub use event::{AnimationMarkerId, AudioEvent, AudioEventId, AudioOccurrence};
pub use ambient::LoadingAudioStatus;
pub use ambient::{LoadingAudioOwner, LoadingAudioStatus, PreparedMatchAudio};
+8
View File
@@ -51,6 +51,14 @@ pub struct WeaponSound {
pub snd_ent: Option<u32>,
}
#[derive(Message, Clone, Debug)]
pub struct FlameFire {
pub number: u32,
pub weapon: u32,
pub player_view: bool,
pub origin_inches: [f32; 3],
}
#[derive(Message, Clone, Debug)]
pub struct BoundWeaponSound {
pub event: Option<crate::AudioEvent>,
+24 -13
View File
@@ -14,7 +14,6 @@ use crate::messages::{
ViewmodelNotetracks, WeaponSound,
};
use crate::start::{SoundClass, StartDecision, StartDecisions, StartFailure, StartOutcome};
use frame::{MatchScope, ScopeEpoch};
#[derive(Component, Default)]
pub struct AmbientListener;
@@ -49,6 +48,7 @@ pub(crate) struct PlayerSoundPlugin;
impl Plugin for PlayerSoundPlugin {
fn build(&self, app: &mut App) {
app.scoped::<crate::LoadingAudioStatus>(frame::MatchScope::Loading)
.scoped::<crate::LoadingAudioOwner>(frame::MatchScope::Loading)
.init_resource::<crate::clip_store::ResidentClipCache>()
.init_resource::<MissingAliasGaps>()
.init_resource::<StartDecisions>()
@@ -58,6 +58,8 @@ impl Plugin for PlayerSoundPlugin {
.scoped::<crate::script_ambient::ScriptAmbientPlayback>(frame::MatchScope::Live)
.scoped::<crate::destructible_loops::DestructibleSources>(frame::MatchScope::Live)
.scoped::<crate::projectile_loops::ProjectileSources>(frame::Timeline::Running)
.scoped::<crate::flame_loops::FlameSources>(frame::Timeline::Running)
.scoped_message::<crate::FlameFire>(frame::Timeline::Running)
.scoped::<crate::ambient::SoundBankLoadAttempted>(frame::MatchScope::Loading)
.scoped::<crate::ambient::ResidentSoundBank>(frame::MatchScope::Loading)
.scoped::<crate::BobCycleTracker>(frame::MatchScope::Live)
@@ -113,6 +115,7 @@ impl Plugin for PlayerSoundPlugin {
play_land_sound_messages.in_set(frame::InMatch),
crate::destructible_loops::update.in_set(frame::InMatch),
crate::projectile_loops::update.in_set(frame::InMatch),
crate::flame_loops::update.in_set(frame::InMatch),
)
.in_set(ClientSet::Effects),
)
@@ -195,13 +198,16 @@ fn play_alias_messages(
mut pending: ResMut<CueFeedback>,
mut decisions: ResMut<StartDecisions>,
bank: Option<Res<SoundBank>>,
epoch: Res<ScopeEpoch<MatchScope>>,
epoch: Option<Res<net::EntityEventCursor>>,
local: Res<net::LocalPresentClient>,
) {
for command in events.read() {
let (event, pitch_scale) = match command {
AliasCommand::StopEntity { snd_ent } => {
runtime.stop_emitter_cues(*snd_ent, epoch.0);
runtime.stop_emitter_cues(
*snd_ent,
crate::backend::presentation_epoch(epoch.as_deref()),
);
continue;
}
AliasCommand::Play(event) => (event, 1.0),
@@ -212,7 +218,12 @@ fn play_alias_messages(
snd_ent,
} => {
let snd_ent = sound_entity(*snd_ent, local.0);
runtime.stop_cue(*namespace, alias, snd_ent, epoch.0);
runtime.stop_cue(
*namespace,
alias,
snd_ent,
crate::backend::presentation_epoch(epoch.as_deref()),
);
continue;
}
};
@@ -234,7 +245,7 @@ fn play_alias_messages(
&mut decisions,
sound_entity(event.snd_ent, local.0),
SoundClass::World,
epoch.0,
crate::backend::presentation_epoch(epoch.as_deref()),
pitch_scale,
1.0,
event.fallback.iter().cloned().collect(),
@@ -250,7 +261,7 @@ fn play_footstep_messages(
mut pending: ResMut<CueFeedback>,
mut decisions: ResMut<StartDecisions>,
bank: Option<Res<SoundBank>>,
epoch: Res<ScopeEpoch<MatchScope>>,
epoch: Option<Res<net::EntityEventCursor>>,
) {
let Some(bank) = bank else {
drop_without_bank(events.read().map(|e| (e.alias, e.event)), &mut decisions);
@@ -267,7 +278,7 @@ fn play_footstep_messages(
&mut decisions,
event.snd_ent,
SoundClass::World,
epoch.0,
crate::backend::presentation_epoch(epoch.as_deref()),
event.event,
event.volume_scale,
);
@@ -285,7 +296,7 @@ fn play_weapon_sound_messages(
mut pending: ResMut<CueFeedback>,
mut decisions: ResMut<StartDecisions>,
bank: Option<Res<SoundBank>>,
epoch: Res<ScopeEpoch<MatchScope>>,
epoch: Option<Res<net::EntityEventCursor>>,
) {
let Some(bank) = bank else {
drop_without_bank(
@@ -306,7 +317,7 @@ fn play_weapon_sound_messages(
&mut decisions,
event.snd_ent,
SoundClass::Weapon,
epoch.0,
crate::backend::presentation_epoch(epoch.as_deref()),
1.0,
1.0,
Vec::new(),
@@ -325,7 +336,7 @@ fn play_bound_weapon_sounds(
mut pending: ResMut<CueFeedback>,
mut decisions: ResMut<StartDecisions>,
bank: Option<Res<SoundBank>>,
epoch: Res<ScopeEpoch<MatchScope>>,
epoch: Option<Res<net::EntityEventCursor>>,
local: Res<net::LocalPresentClient>,
) {
let Some(bank) = bank else {
@@ -353,7 +364,7 @@ fn play_bound_weapon_sounds(
&mut decisions,
sound_entity(event.snd_ent, local.0),
SoundClass::Weapon,
epoch.0,
crate::backend::presentation_epoch(epoch.as_deref()),
1.0,
1.0,
Vec::new(),
@@ -372,7 +383,7 @@ fn play_land_sound_messages(
mut pending: ResMut<CueFeedback>,
mut decisions: ResMut<StartDecisions>,
bank: Option<Res<SoundBank>>,
epoch: Res<ScopeEpoch<MatchScope>>,
epoch: Option<Res<net::EntityEventCursor>>,
) {
let Some(bank) = bank else {
drop_without_bank(events.read().map(|e| (e.alias, e.event)), &mut decisions);
@@ -389,7 +400,7 @@ fn play_land_sound_messages(
&mut decisions,
event.snd_ent,
SoundClass::World,
epoch.0,
crate::backend::presentation_epoch(epoch.as_deref()),
event.event,
event.volume_scale,
);
+8 -6
View File
@@ -1,7 +1,6 @@
use std::collections::HashMap;
use bevy::prelude::*;
use frame::{MatchScope, ScopeEpoch};
use crate::backend::AudioScope;
use crate::sources::{DesiredSource, SourceCueRequest, SourceKey};
@@ -15,7 +14,7 @@ struct ProjectileLoop {
#[derive(Resource, Default)]
pub(crate) struct ProjectileSources {
context: Option<(u64, u64)>,
context: Option<frame::PresentationContext>,
next_version: u64,
live: HashMap<u32, ProjectileLoop>,
pub desired: Vec<DesiredSource>,
@@ -27,11 +26,14 @@ pub(crate) fn update(
bank: Option<Res<SoundBank>>,
clock: Option<Res<net::FrameClock>>,
timeline: Res<net::EntityEventCursor>,
epoch: Res<ScopeEpoch<MatchScope>>,
runtime: Res<AudioRuntime>,
mut sources: ResMut<ProjectileSources>,
installed: Res<frame::InstalledMatch>,
) {
let context = Some((epoch.0, timeline.timeline()));
let context = Some(frame::PresentationContext::new(
installed.load_key,
timeline.timeline(),
));
if sources.context != context {
sources.context = context;
sources.live.clear();
@@ -81,7 +83,7 @@ pub(crate) fn update(
alias: alias.into(),
emitter: u32::try_from(projectile.entnum).ok(),
scope: AudioScope::Match,
epoch: epoch.0,
epoch: timeline.timeline(),
group: None,
}) else {
continue;
@@ -99,7 +101,7 @@ pub(crate) fn update(
source: DesiredSource {
key: SourceKey {
scope: AudioScope::Match,
epoch: epoch.0,
epoch: timeline.timeline(),
object: u64::from(id),
slot: 7,
},
+3 -3
View File
@@ -153,7 +153,7 @@ pub(crate) struct RenderShared {
rendering: AtomicBool,
state: UnsafeCell<RenderState>,
pub frame: AtomicU64,
pub match_epoch: AtomicU64,
pub presentation_epoch: AtomicU64,
pub master: AtomicU32,
pub busy_blocks: AtomicU64,
pub device_active: AtomicBool,
@@ -193,7 +193,7 @@ impl RenderShared {
limiter: Default::default(),
}),
frame: AtomicU64::new(0),
match_epoch: AtomicU64::new(0),
presentation_epoch: AtomicU64::new(0),
master: AtomicU32::new(1.0f32.to_bits()),
busy_blocks: AtomicU64::new(0),
device_active: AtomicBool::new(false),
@@ -228,7 +228,7 @@ impl RenderShared {
// The lease is held until every reference to this state has expired.
let state = unsafe { &mut *self.state.get() };
let frame = self.frame.load(Ordering::Relaxed);
let epoch = self.match_epoch.load(Ordering::Acquire);
let epoch = self.presentation_epoch.load(Ordering::Acquire);
let cancelled = self.cancelled.load(Ordering::Acquire);
let hold_one_shots = device == Some(false) && self.device_required.load(Ordering::Relaxed);
for (index, slot) in self.slots.iter().enumerate() {
+12 -3
View File
@@ -168,7 +168,7 @@ struct ActiveRumble {
#[derive(Default)]
struct RumblePlayback {
owner: Option<(frame::WorldStamp, sim::ClientId, sim::LifeSequence, u64)>,
last_time: i32,
timeline: u64,
active: Vec<ActiveRumble>,
output: Option<(Entity, GamepadRumbleIntensity)>,
selected: Option<Entity>,
@@ -308,6 +308,7 @@ fn update(
local: Res<LocalPresentClient>,
generation: Res<frame::WorldGeneration>,
time: Res<Time>,
cursor: Option<Res<net::EntityEventCursor>>,
devices: Res<frame::InputDevices>,
mut tests: MessageReader<frame::TestControllerRumble>,
gamepads: Query<Entity, With<Gamepad>>,
@@ -316,6 +317,15 @@ fn update(
settings: Res<frame::GameSettings>,
active: Option<Res<frame::ActivePad>>,
) {
let timeline = crate::backend::presentation_epoch(cursor.as_deref());
if state.timeline != timeline {
state.active.clear();
if let Some((gamepad, _)) = state.output.take() {
output.write(GamepadRumbleRequest::Stop { gamepad });
}
state.timeline = timeline;
state.next_refresh_ms = 0;
}
let now = (time.elapsed_secs_f64() * 1000.0) as i32;
let selected = active
.and_then(|active| active.0)
@@ -347,14 +357,13 @@ fn update(
bank.0.revision(),
))
});
if owner != state.owner || now < state.last_time {
if owner != state.owner {
state.active.clear();
if let Some((gamepad, _)) = state.output.take() {
output.write(GamepadRumbleRequest::Stop { gamepad });
}
state.owner = owner;
}
state.last_time = now;
state
.active
.retain(|row| now.saturating_sub(row.start) < row.rumble.duration_ms);
+11 -9
View File
@@ -198,8 +198,10 @@ impl AudioRuntime {
self.shared.master.store(gain.to_bits(), Ordering::Release);
}
pub fn set_match_epoch(&self, epoch: u64) {
self.shared.match_epoch.store(epoch, Ordering::Release);
pub fn set_presentation_epoch(&self, epoch: u64) {
self.shared
.presentation_epoch
.store(epoch, Ordering::Release);
self.source_publisher
.lock()
.unwrap_or_else(|poison| poison.into_inner())
@@ -224,7 +226,7 @@ impl AudioRuntime {
.unwrap_or_else(|poison| poison.into_inner())
.reconcile(
sources,
self.shared.match_epoch.load(Ordering::Acquire),
self.shared.presentation_epoch.load(Ordering::Acquire),
self.shared.cancelled.load(Ordering::Acquire),
);
self.sources.dropped.fetch_add(rejected, Ordering::Relaxed);
@@ -544,7 +546,7 @@ fn control(
{
desired = scene;
}
let epoch = shared.match_epoch.load(Ordering::Acquire);
let epoch = shared.presentation_epoch.load(Ordering::Acquire);
desired.sources.retain(|source| {
!shared.cancelled.load(Ordering::Acquire)
&& (source.key.scope != AudioScope::Match || source.key.epoch == epoch)
@@ -670,7 +672,7 @@ fn control(
});
let diag_stage = crate::diagnostics::slow_stage("null_and_reclaim", diag_stage);
resolver.retain_epoch(shared.match_epoch.load(Ordering::Acquire));
resolver.retain_epoch(shared.presentation_epoch.load(Ordering::Acquire));
for _ in 0..CONTROL_BATCH {
let Ok(request) = cue_rx.try_recv() else {
break;
@@ -679,7 +681,7 @@ fn control(
// Validate a newly received cue against the latest published clocks.
events.advance(event_context.get());
if request.scope == AudioScope::Match
&& request.epoch != shared.match_epoch.load(Ordering::Acquire)
&& request.epoch != shared.presentation_epoch.load(Ordering::Acquire)
{
request.reject(CueFailure::StaleScope);
} else if crate::event::EventJournal::fire_refused(
@@ -709,7 +711,7 @@ fn control(
let mut work = pending_cues.pop_front().expect("pending cue");
if shared.cancelled.load(Ordering::Acquire)
|| (work.request.scope == AudioScope::Match
&& work.request.epoch != shared.match_epoch.load(Ordering::Acquire))
&& work.request.epoch != shared.presentation_epoch.load(Ordering::Acquire))
{
work.complete(crate::StartOutcome::Failed(
crate::StartFailure::CueRefused(if shared.cancelled.load(Ordering::Acquire) {
@@ -885,7 +887,7 @@ fn control(
apply_source_render_budget(&instances);
instances.sort_by_key(|logical| logical.source.is_some());
let now = shared.frame.load(Ordering::Acquire);
let epoch = shared.match_epoch.load(Ordering::Acquire);
let epoch = shared.presentation_epoch.load(Ordering::Acquire);
instances.retain_mut(|logical| {
let instance = &logical.request.instance;
@@ -1283,7 +1285,7 @@ fn admit(
{
Some(AdmissionFailure::Cancelled)
} else if request.instance.scope == AudioScope::Match
&& request.instance.epoch != shared.match_epoch.load(Ordering::Acquire)
&& request.instance.epoch != shared.presentation_epoch.load(Ordering::Acquire)
{
Some(AdmissionFailure::StaleScope)
} else if instances.len() == LOGICAL_INSTANCES {
+4 -4
View File
@@ -14,7 +14,7 @@ pub(crate) struct ScriptAmbientPlayback {
}
pub(crate) fn update_script_ambient(
epoch: Res<frame::ScopeEpoch<frame::MatchScope>>,
epoch: Option<Res<net::EntityEventCursor>>,
ready: Res<crate::AudioReady>,
generation: Res<frame::WorldGeneration>,
loading: Option<Res<assets::LoadingScreen>>,
@@ -27,8 +27,8 @@ pub(crate) fn update_script_ambient(
mut map: Option<ResMut<MapSources>>,
mut feedback: ResMut<crate::clip_store::CueFeedback>,
) {
if playback.epoch != epoch.0 {
playback.epoch = epoch.0;
if playback.epoch != crate::backend::presentation_epoch(epoch.as_deref()) {
playback.epoch = crate::backend::presentation_epoch(epoch.as_deref());
playback.target = None;
playback.started = false;
playback.voices.clear();
@@ -109,7 +109,7 @@ pub(crate) fn update_script_ambient(
origin_inches: None,
emitter: None,
class: SoundClass::Ambience,
epoch: epoch.0,
epoch: crate::backend::presentation_epoch(epoch.as_deref()),
pitch_scale: 1.0,
volume_scale: 1.0,
fallbacks: Vec::new(),
+2 -3
View File
@@ -3,7 +3,6 @@ use asset_audio::ChannelKey;
use asset_core::AssetNamespace;
use bevy::prelude::*;
use frame::ScopeApp;
use frame::{MatchScope, ScopeEpoch};
use std::{collections::HashMap, sync::Arc};
#[derive(Resource, Default)]
@@ -166,11 +165,11 @@ fn update_channel_mix(
bank: Option<Res<crate::SoundBank>>,
namespace: Option<Res<crate::ambient::SoundBankNamespace>>,
local: Option<Res<net::LocalPresentClient>>,
epoch: Res<ScopeEpoch<MatchScope>>,
epoch: Option<Res<net::EntityEventCursor>>,
runtime: Res<crate::AudioRuntime>,
) {
let now = runtime.audio_frame();
mix.reset_epoch(epoch.0);
mix.reset_epoch(crate::backend::presentation_epoch(epoch.as_deref()));
for event in events.read() {
if event.0.target().is_some() {
mix.pending.push_back(event.0.clone());
+3 -3
View File
@@ -12,7 +12,7 @@ pub(crate) struct ScriptMusicPlayback {
}
pub(crate) fn update_script_music(
epoch: Res<frame::ScopeEpoch<frame::MatchScope>>,
epoch: Option<Res<net::EntityEventCursor>>,
ready: Res<crate::AudioReady>,
generation: Res<frame::WorldGeneration>,
runtime: Res<crate::AudioRuntime>,
@@ -23,7 +23,7 @@ pub(crate) fn update_script_music(
mut mix: ResMut<crate::script_mix::ScriptAudioMix>,
mut feedback: ResMut<crate::clip_store::CueFeedback>,
) {
mix.reset_epoch(epoch.0);
mix.reset_epoch(crate::backend::presentation_epoch(epoch.as_deref()));
if playback
.active
.as_ref()
@@ -81,7 +81,7 @@ pub(crate) fn update_script_music(
origin_inches: None,
emitter: None,
class: SoundClass::Music,
epoch: epoch.0,
epoch: crate::backend::presentation_epoch(epoch.as_deref()),
pitch_scale: 1.0,
volume_scale: 1.0,
fallbacks: Vec::new(),
+8 -4
View File
@@ -25,7 +25,7 @@ pub(crate) fn update_shellshock_tinnitus(
runtime: Res<crate::AudioRuntime>,
mut sources: ResMut<ShellshockSources>,
mut play: MessageWriter<crate::AliasCommand>,
epoch: Res<frame::ScopeEpoch<frame::MatchScope>>,
epoch: Option<Res<net::EntityEventCursor>>,
) {
let local_id = local.as_ref().map(|l| l.0.0);
let life = local.as_ref().and_then(|local| {
@@ -51,7 +51,11 @@ pub(crate) fn update_shellshock_tinnitus(
sources.was_active = false;
return;
};
let context = (epoch.0, local.0.0, life.unwrap_or(0));
let context = (
crate::backend::presentation_epoch(epoch.as_deref()),
local.0.0,
life.unwrap_or(0),
);
if sources.context != Some(context) {
sources.source = None;
sources.was_active = false;
@@ -87,7 +91,7 @@ pub(crate) fn update_shellshock_tinnitus(
alias: parms.loop_alias.clone(),
emitter: Some(local.0.0),
scope: crate::backend::AudioScope::Match,
epoch: epoch.0,
epoch: crate::backend::presentation_epoch(epoch.as_deref()),
group: None,
})
{
@@ -98,7 +102,7 @@ pub(crate) fn update_shellshock_tinnitus(
sources.source = Some(DesiredSource {
key: SourceKey {
scope: crate::backend::AudioScope::Match,
epoch: epoch.0,
epoch: crate::backend::presentation_epoch(epoch.as_deref()),
object: u64::from(local.0.0),
slot: 5,
},
+2 -1
View File
@@ -222,7 +222,7 @@ fn install_class_catalog(
if key.contains('+')
&& asset_core::AssetKey::parse(key)
.is_ok_and(|key| key.namespace == asset_core::AssetNamespace::Iw4)
&& menus.material_images.contains_key(&preview.image)
&& menus.material_images().contains_key(&preview.image)
{
preview.image = format!("iw4:material/{}", preview.image);
}
@@ -735,6 +735,7 @@ fn run_map(
.insert_resource(cheats)
.insert_resource(probe)
.insert_resource(menus)
.insert_resource(assets::CurrentMatchLoad(Some(Default::default())))
.insert_resource(MatchLoadRequest {
request_id: 0,
load_key: Default::default(),
+3 -3
View File
@@ -21,9 +21,9 @@ pub use envelope::{
MAGIC_UNSIGNED, Signing, ZONE_VERSION_PC, parse_file_header,
};
pub use load::{
AssetLinkSink, FontCapture, GlyphCapture, MenuChoiceValue, MenuDefCapture, MenuItemLayout,
MenuRectCapture, MenuScriptKind, load_asset_at, load_asset_at_observed, load_asset_at_with,
load_asset_body,
AssetLinkSink, FontCapture, GlyphCapture, ItemExpressionField, MenuChoiceValue, MenuDefCapture,
MenuItemLayout, MenuRectCapture, MenuScriptKind, load_asset_at, load_asset_at_observed,
load_asset_at_with, load_asset_body,
};
pub use stream::{
alloc_stream_pos, convert_offset_to_alias, convert_offset_to_pointer, inc_stream_pos,
+56 -20
View File
@@ -1,6 +1,6 @@
use asset_iw4::size as sz;
use super::{AssetLinkSink, asset_ptr_at, asset_ptr_at_linked, follow_name};
use super::{AssetLinkSink, ItemExpressionField, asset_ptr_at, asset_ptr_at_linked, follow_name};
use crate::asset_type::AssetType;
use crate::zone::{Ptr, Result, XFILE_BLOCK_VIRTUAL, ZonePtr, ZoneStream};
@@ -911,7 +911,7 @@ fn load_event_handler(
let c = s.alloc_load(4, s.layout(sz::CONDITIONAL_SCRIPT, 16))?;
let mut condition_buf = [0u8; ITEM_STATEMENT_DUMP];
let mut condition_len = 0;
capture_expr_body_or_alias(s, c, s.layout(4, 8), cache, |dump| {
capture_expr_body_or_alias(s, c, s.layout(4, 8), cache, |_source, dump| {
condition_len = copy_into(dump, &mut condition_buf).len();
})?;
let condition = core::str::from_utf8(&condition_buf[..condition_len]).unwrap_or("");
@@ -933,7 +933,7 @@ fn load_event_handler(
let name = copy_into(string_at(s, v, 0), &mut name_buf);
let mut captured = Ok(());
capture_expr_body_or_alias(s, v, s.layout(4, 8), cache, |expr| {
capture_expr_body_or_alias(s, v, s.layout(4, 8), cache, |_source, expr| {
captured = links.capture_menu_set_local_var(menu, item, kind, t, name, expr);
})?;
captured?;
@@ -1110,21 +1110,51 @@ fn load_item_def(
for i in 0..float_expression_count {
let e = arr.at(i * s.layout(sz::ITEM_FLOAT_EXPRESSION, 16));
let key = s.u32_at(e, 0).unwrap_or(0);
capture_expr_body_or_alias(s, e, s.layout(4, 8), cache, |dump| {
capture_expr_body_or_alias(s, e, s.layout(4, 8), cache, |source, dump| {
let _ = links.capture_item_expression_reference(
menu_name,
item_name,
ItemExpressionField::Float(key),
source,
);
let _ = links.capture_item_float_exp(menu_name, item_name, key, dump);
})?;
}
}
capture_expr_body_or_alias(s, p, s.layout(324, 416), cache, |dump| {
let _ = links.capture_item_visible_exp(menu_name, item_name, dump);
capture_expr_body_or_alias(s, p, s.layout(324, 416), cache, |source, dump| {
let _ = links.capture_item_expression_reference(
menu_name,
item_name,
ItemExpressionField::Visible,
source,
);
let _ = links.capture_item_visible_reference(menu_name, item_name, source, dump);
})?;
capture_expr_body_or_alias(s, p, s.layout(328, 424), cache, |dump| {
capture_expr_body_or_alias(s, p, s.layout(328, 424), cache, |source, dump| {
let _ = links.capture_item_expression_reference(
menu_name,
item_name,
ItemExpressionField::Disabled,
source,
);
let _ = links.capture_item_disabled_exp(menu_name, item_name, dump);
})?;
capture_expr_body_or_alias(s, p, s.layout(332, 432), cache, |dump| {
capture_expr_body_or_alias(s, p, s.layout(332, 432), cache, |source, dump| {
let _ = links.capture_item_expression_reference(
menu_name,
item_name,
ItemExpressionField::Text,
source,
);
let _ = links.capture_item_text_exp(menu_name, item_name, dump);
})?;
capture_expr_body_or_alias(s, p, s.layout(336, 440), cache, |dump| {
capture_expr_body_or_alias(s, p, s.layout(336, 440), cache, |source, dump| {
let _ = links.capture_item_expression_reference(
menu_name,
item_name,
ItemExpressionField::Material,
source,
);
let _ = links.capture_item_material_exp(menu_name, item_name, dump);
})?;
Ok(())
@@ -1135,24 +1165,27 @@ fn capture_expr_body_or_alias(
parent: Ptr,
field: usize,
cache: &mut StatementCache,
mut on_dump: impl FnMut(&str),
mut on_dump: impl FnMut(Ptr, &str),
) -> Result<()> {
if s.begin_body(parent.at(field))? {
let mut buf = [0u8; ITEM_STATEMENT_DUMP];
let dump = dump_item_statement(s, &mut buf, cache)?;
if !dump.is_empty() {
on_dump(dump);
}
let mut dump = ExpDump {
buf: &mut buf,
used: 0,
};
let source = load_statement_rec(s, &mut dump, cache)?;
cache.insert(source, dump.as_str());
s.expr_stmt_insert(source, dump.as_str());
s.fixup_slot(parent.at(field), source)?;
on_dump(source, dump.as_str());
return Ok(());
}
let Ok(ZonePtr::Offset(stmt)) = s.ptr_at(parent, field) else {
let ZonePtr::Offset(stmt) = s.ptr_at(parent, field)? else {
return Ok(());
};
let body = s.resolve_alias(stmt);
if let Some(cached) = s.expr_stmt_get(body).or_else(|| cache.get(body)) {
if !cached.is_empty() {
on_dump(cached);
}
on_dump(body, cached);
return Ok(());
}
let mut buf = [0u8; ITEM_STATEMENT_DUMP];
@@ -1160,9 +1193,12 @@ fn capture_expr_body_or_alias(
buf: &mut buf,
used: 0,
};
let _ = dump_loaded_statement(s, body, &mut dump, cache, 0, None);
dump_loaded_statement(s, body, &mut dump, cache, 0, None)?;
if dump.used == 0 || dump.as_str().contains("fnmiss") {
return Err(crate::ZoneError::UnresolvedPointer(body));
}
if dump.used > 0 {
on_dump(dump.as_str());
on_dump(body, dump.as_str());
}
Ok(())
}
+31
View File
@@ -159,6 +159,15 @@ fn load_asset_body_observed(
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ItemExpressionField {
Visible,
Disabled,
Text,
Material,
Float(u32),
}
pub trait AssetLinkSink {
fn loaded(
&mut self,
@@ -264,6 +273,28 @@ pub trait AssetLinkSink {
Ok(())
}
fn capture_item_expression_reference(
&mut self,
menu: &str,
item: &str,
field: ItemExpressionField,
source: Ptr,
) -> Result<()> {
let _ = (menu, item, field, source);
Ok(())
}
fn capture_item_visible_reference(
&mut self,
menu: &str,
item: &str,
source: Ptr,
dump: &str,
) -> Result<()> {
let _ = source;
self.capture_item_visible_exp(menu, item, dump)
}
fn capture_item_visible_exp(&mut self, menu: &str, item: &str, dump: &str) -> Result<()> {
let _ = (menu, item, dump);
Ok(())
+9 -9
View File
@@ -30,13 +30,13 @@ pub use size::{
};
pub use zone::{
ALIGN_WASTE_SITE_COUNT, AlignWasteSite, AlignWasteStats, BLOCK_STACK_CAP, BlockType,
ClipMapGeometry, ComWorldGeometry, FxEffectDefGeometry, GfxImageGeometry, GfxLightGridGeometry,
GfxLightmapImages, GfxWorldGeometry, MAX_LIGHTMAP_PAGES, MAX_XFILE_COUNT, MapEntsGeometry,
MaterialGeometry, PTR_SIZE, Ptr, Result, ShaderGeometry, TECHNIQUE_ARGUMENT_CAP,
TECHNIQUE_PASS_ROW_CAP, TechniqueArgumentGeometry, TechniqueGraphGeometry,
TechniquePassGeometry, TechniqueSetGeometry, VertexDeclGeometry, WeaponGeometry,
XAnimPartsGeometry, XFILE_BLOCK_LARGE, XFILE_BLOCK_LARGE_RUNTIME, XFILE_BLOCK_PHYSICAL,
XFILE_BLOCK_PHYSICAL_RUNTIME, XFILE_BLOCK_RUNTIME, XFILE_BLOCK_TEMP, XFILE_BLOCK_VIRTUAL,
XFILE_HEADER_LEN, XModelGeometry, ZoneError, ZoneHeader, ZonePtr, ZoneStream,
block_is_aliasable, parse_zone_header,
ClipMapGeometry, ComWorldGeometry, FLAME_TABLE_MATERIAL_COUNT, FxEffectDefGeometry,
GfxImageGeometry, GfxLightGridGeometry, GfxLightmapImages, GfxWorldGeometry,
MAX_LIGHTMAP_PAGES, MAX_XFILE_COUNT, MapEntsGeometry, MaterialGeometry, PTR_SIZE, Ptr, Result,
ShaderGeometry, TECHNIQUE_ARGUMENT_CAP, TECHNIQUE_PASS_ROW_CAP, TechniqueArgumentGeometry,
TechniqueGraphGeometry, TechniquePassGeometry, TechniqueSetGeometry, VertexDeclGeometry,
WeaponGeometry, XAnimPartsGeometry, XFILE_BLOCK_LARGE, XFILE_BLOCK_LARGE_RUNTIME,
XFILE_BLOCK_PHYSICAL, XFILE_BLOCK_PHYSICAL_RUNTIME, XFILE_BLOCK_RUNTIME, XFILE_BLOCK_TEMP,
XFILE_BLOCK_VIRTUAL, XFILE_HEADER_LEN, XModelGeometry, ZoneError, ZoneHeader, ZonePtr,
ZoneStream, block_is_aliasable, parse_zone_header,
};
+1
View File
@@ -328,6 +328,7 @@ pub(super) fn load_material(s: &mut ZoneStream<'_>, links: &mut dyn AssetLinkSin
surface_type_bits: s.u32_at(p, 0x18)?,
layered_surface_types: s.u32_at(p, 0x1c)?,
sort_key: s.u8_at(p, 9)?,
texture_atlas: [s.u8_at(p, 10)?, s.u8_at(p, 11)?],
info_game_flags: s.u8_at(p, sz::MATERIAL_INFO_GAME_FLAGS_OFF)?,
state_flags: s.u8_at(p, sz::MATERIAL_STATE_FLAGS_OFF)?,
camera_region: s.u8_at(p, sz::MATERIAL_CAMERA_REGION_OFF)?,
+42 -14
View File
@@ -1,7 +1,10 @@
use super::{AssetLinkSink, asset_ptr_at, follow_name, load_asset_at_observed};
use crate::asset_type::AssetType;
use crate::size as sz;
use crate::zone::{Ptr, Result, WeaponGeometry, XFILE_BLOCK_VIRTUAL, ZonePtr, ZoneStream};
use crate::zone::{
FLAME_TABLE_MATERIAL_COUNT, Ptr, Result, WeaponGeometry, XFILE_BLOCK_VIRTUAL, ZonePtr,
ZoneStream,
};
pub(super) fn load_weapon(s: &mut ZoneStream<'_>, links: &mut dyn AssetLinkSink) -> Result<()> {
let p = s.alloc_load(4, sz::WEAPON_VARIANT_DEF)?;
@@ -19,6 +22,14 @@ pub(super) fn load_weapon(s: &mut ZoneStream<'_>, links: &mut dyn AssetLinkSink)
kill_icon: material_name_at(s, links, body.at(sz::WEAPON_DEF_KILL_ICON_OFF)),
reticle_center: material_name_at(s, links, body.at(0x1a0)),
reticle_side: material_name_at(s, links, body.at(0x1a4)),
flame_materials: [0x7f0, 0x7f4].map(|off| match s.ptr_at(body, off) {
Ok(ZonePtr::Offset(t)) => {
let table = s.resolve_alias(t);
FLAME_TABLE_MATERIAL_OFFS
.map(|m| material_name_at(s, links, table.at(m)))
}
_ => [None; FLAME_TABLE_MATERIAL_COUNT],
}),
..WeaponAssets::default()
},
)
@@ -71,6 +82,7 @@ pub(super) fn load_weapon(s: &mut ZoneStream<'_>, links: &mut dyn AssetLinkSink)
overlay_material_lowres_name,
reticle_center_name: assets.reticle_center,
reticle_side_name: assets.reticle_side,
flame_material_names: assets.flame_materials,
alternate_weapon_name,
alternate_raise_time_ms: s.i32_at(p, 0x3c)?,
alternate_drop_time_ms: weap_def
@@ -208,15 +220,21 @@ fn load_weapon_def(
follow_name(s, p, off)?;
}
if s.begin_body(p.at(0x7f0))? {
let ft = s.alloc_load(4, sz::FLAME_TABLE)?;
s.fixup_slot(p.at(0x7f0), ft)?;
load_flame_table(s, links, ft)?;
}
if s.begin_body(p.at(0x7f4))? {
let ft = s.alloc_load(4, sz::FLAME_TABLE)?;
s.fixup_slot(p.at(0x7f4), ft)?;
load_flame_table(s, links, ft)?;
let mut flame_materials = [[None; FLAME_TABLE_MATERIAL_COUNT]; 2];
for (view, off) in [0x7f0, 0x7f4].into_iter().enumerate() {
flame_materials[view] = if s.begin_body(p.at(off))? {
let ft = s.alloc_load(4, sz::FLAME_TABLE)?;
s.fixup_slot(p.at(off), ft)?;
load_flame_table(s, links, ft)?
} else {
match s.ptr_at(p, off)? {
ZonePtr::Offset(t) => {
let table = s.resolve_alias(t);
FLAME_TABLE_MATERIAL_OFFS.map(|m| material_name_at(s, links, table.at(m)))
}
_ => [None; FLAME_TABLE_MATERIAL_COUNT],
}
};
}
asset_ptr_at(s, links, AssetType::Fx, p.at(0x7f8))?;
@@ -230,6 +248,7 @@ fn load_weapon_def(
world: world0,
reticle_center,
reticle_side,
flame_materials,
})
}
@@ -336,15 +355,23 @@ fn follow_xmodel_ptr(
}))
}
fn load_flame_table(s: &mut ZoneStream<'_>, links: &mut dyn AssetLinkSink, p: Ptr) -> Result<()> {
const FLAME_TABLE_MATERIAL_OFFS: [usize; FLAME_TABLE_MATERIAL_COUNT] =
[0x1ac, 0x1b0, 0x1b4, 0x1b8, 0x1bc, 0x1c0, 0x1c4, 0x1c8];
fn load_flame_table(
s: &mut ZoneStream<'_>,
links: &mut dyn AssetLinkSink,
p: Ptr,
) -> Result<[Option<Ptr>; FLAME_TABLE_MATERIAL_COUNT]> {
follow_name(s, p, 0x1a8)?;
for off in [0x1ac, 0x1b0, 0x1b4, 0x1b8, 0x1bc, 0x1c0, 0x1c4, 0x1c8] {
asset_ptr_at(s, links, AssetType::Material, p.at(off))?;
let mut names = [None; FLAME_TABLE_MATERIAL_COUNT];
for (name, off) in names.iter_mut().zip(FLAME_TABLE_MATERIAL_OFFS) {
*name = follow_material_name(s, links, p.at(off))?;
}
for off in [0x1cc, 0x1d0, 0x1d4, 0x1d8] {
follow_name(s, p, off)?;
}
Ok(())
Ok(names)
}
#[derive(Clone, Copy, Debug, Default)]
@@ -408,4 +435,5 @@ struct WeaponAssets {
world: Option<Ptr>,
reticle_center: Option<Ptr>,
reticle_side: Option<Ptr>,
flame_materials: [[Option<Ptr>; FLAME_TABLE_MATERIAL_COUNT]; 2],
}
+12
View File
@@ -582,6 +582,8 @@ pub const WEAPON_DEF_SND_RELOAD_START_OFF: usize = 0xf8;
pub const WEAPON_DEF_SND_RELOAD_START_PLAYER_OFF: usize = 0xfc;
pub const WEAPON_DEF_SND_RELOAD_END_OFF: usize = 0x100;
pub const WEAPON_DEF_SND_RELOAD_END_PLAYER_OFF: usize = 0x104;
pub const WEAPON_DEF_SND_ALT_SWITCH_OFF: usize = 0x148;
pub const WEAPON_DEF_SND_ALT_SWITCH_PLAYER_OFF: usize = 0x14c;
pub const WEAPON_DEF_SND_RAISE_PLAYER_OFF: usize = 0x154;
pub const WEAPON_DEF_SND_PUTAWAY_PLAYER_OFF: usize = 0x164;
pub const WEAPON_DEF_AMMO_COUNTER_CLIP_OFF: usize = 0x338;
@@ -592,6 +594,16 @@ pub const WEAPON_DEF_AMMO_COUNT_CLIP_RELATIVE_OFF: usize = 0x359;
pub const WEAPON_DEF_SHOT_COUNT_OFF: usize = 0x348;
pub const WEAPON_DEF_DAMAGE_OFF: usize = 0x35c;
pub const WEAPON_DEF_DAMAGE_INTERVAL_OFF: usize = 0x364;
pub const WEAPON_DEF_FUEL_TANK_WEAPON_OFF: usize = 0x61;
pub const WEAPON_DEF_TANK_LIFE_TIME_OFF: usize = 0x64;
pub const WEAPON_DEF_FLAME_TABLE_FIRST_PERSON_OFF: usize = 0x7f0;
pub const WEAPON_DEF_FLAME_TABLE_THIRD_PERSON_OFF: usize = 0x7f4;
pub const FLAME_TABLE_STREAM_FUEL_LENGTH_OFF: usize = 0xa8;
pub const FLAME_TABLE_STREAM_FLAME_LENGTH_OFF: usize = 0xbc;
pub const FLAME_TABLE_IGNITE_SOUND_OFF: usize = 0x1d0;
pub const FLAME_TABLE_ON_LOOP_SOUND_OFF: usize = 0x1d4;
pub const FLAME_TABLE_COOLDOWN_SOUND_OFF: usize = 0x1d8;
pub const WEAPON_DEF_FIRE_DELAY_OFF: usize = 0x378;
pub const WEAPON_DEF_MELEE_DAMAGE_OFF: usize = 0x36c;
+6
View File
@@ -382,6 +382,8 @@ pub struct FxEffectDefGeometry {
pub elem_def_count: usize,
}
pub const FLAME_TABLE_MATERIAL_COUNT: usize = 8;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct WeaponGeometry {
pub kill_icon_name: Option<Ptr>,
@@ -389,6 +391,9 @@ pub struct WeaponGeometry {
pub overlay_material_lowres_name: Option<Ptr>,
pub reticle_center_name: Option<Ptr>,
pub reticle_side_name: Option<Ptr>,
/// Material names of the first- and third-person flame tables, in
/// `FlameTable` order: fire, smoke, heat, drips, fuel, fuel2, flame, flame2.
pub flame_material_names: [[Option<Ptr>; FLAME_TABLE_MATERIAL_COUNT]; 2],
pub name: Option<Ptr>,
pub alternate_weapon_name: Option<Ptr>,
pub alternate_raise_time_ms: i32,
@@ -632,6 +637,7 @@ pub struct MaterialGeometry {
pub surface_type_bits: u32,
pub layered_surface_types: u32,
pub sort_key: u8,
pub texture_atlas: [u8; 2],
pub info_game_flags: u8,
+4
View File
@@ -50,6 +50,8 @@ pub mod def {
pub const FIRE_TYPE: u32 = 48;
pub const OFFHAND_CLASS: u32 = 96;
pub const OFFHAND_SLOT: u32 = 100;
pub const VIEW_FLASH_EFFECT: u32 = 108;
pub const WORLD_FLASH_EFFECT: u32 = 112;
pub const PULLBACK_SOUND: u32 = 168;
pub const PULLBACK_SOUND_PLAYER: u32 = 172;
pub const FIRE_SOUND: u32 = 176;
@@ -62,6 +64,8 @@ pub mod def {
pub const MELEE_SWIPE_SOUND_PLAYER: u32 = 252;
pub const MELEE_HIT_SOUND: u32 = 256;
pub const MELEE_MISS_SOUND: u32 = 260;
pub const ALT_SWITCH_SOUND: u32 = 376;
pub const ALT_SWITCH_SOUND_PLAYER: u32 = 380;
pub const RAISE_SOUND: u32 = 384;
pub const RAISE_SOUND_PLAYER: u32 = 388;
pub const FIRST_RAISE_SOUND: u32 = 392;
+3 -2
View File
@@ -35,8 +35,9 @@ pub use session::{
DisplayEncodedCamera, Headless, HostClassLoadouts, HostClassSlot, HudDraw, HudInputView,
InstalledMatch, LaunchIdentity, LaunchReport, LifeEndCause, LifeEnded, LifeStartReason,
LifeStarted, LocalLoadKey, LocalSpawnArmed, MapLoadApproved, MapLoadFailed, MatchKey,
ReadinessState, RuntimeRole, TeardownReason, UiCamera, UiDraw, UiOverlayInput, UiPointerFree,
ViewSubject, WorldGeneration, WorldProducts, WorldReadiness, WorldStamp,
PresentationContext, ReadinessState, RuntimeRole, TeardownReason, UiCamera, UiDraw,
UiOverlayInput, UiPointerFree, ViewSubject, WorldGeneration, WorldProducts, WorldReadiness,
WorldStamp,
};
pub use settings::{DisplayResolution, GameSettings, OtherGame};
pub use ui::{
-6
View File
@@ -240,12 +240,6 @@ fn retire_state<S: States>(
}
world.resource_mut::<ScopeEpoch<S>>().0 =
world.resource::<ScopeEpoch<S>>().0.wrapping_add(1);
// Match-epoch consumers (audio voices) must also drop what a restarted
// timeline played.
if TypeId::of::<S>() == TypeId::of::<Timeline>() {
world.resource_mut::<ScopeEpoch<MatchScope>>().0 =
world.resource::<ScopeEpoch<MatchScope>>().0.wrapping_add(1);
}
if !actions.is_empty() {
world.resource_mut::<crate::Retiring>().mark_teardown();
let mut registry = world.resource_mut::<ScopeRegistry>();
+15
View File
@@ -239,6 +239,21 @@ impl LocalLoadKey {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct PresentationContext {
pub installation: LocalLoadKey,
pub timeline: u64,
}
impl PresentationContext {
pub fn new(installation: LocalLoadKey, timeline: u64) -> Self {
Self {
installation,
timeline,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct AdmissionKey {
pub match_key: MatchKey,
+2
View File
@@ -15,6 +15,7 @@ pub struct FxElemSlot {
pub visual_count: u8,
pub sequence: u8,
pub at_rest_fraction: u8,
pub supported_since_ms: Option<i32>,
pub emit_residual: u8,
pub next_elem_handle: u16,
@@ -51,6 +52,7 @@ impl Default for FxElemSlot {
visual_count: 0,
sequence: 0,
at_rest_fraction: FX_ELEM_AT_REST_NONE,
supported_since_ms: None,
emit_residual: 0,
next_elem_handle: FX_ELEM_HANDLE_NONE,
prev_elem_handle: FX_ELEM_HANDLE_NONE,
+49 -7
View File
@@ -65,6 +65,7 @@ pub fn evaluate_elem_collide_motion(
base_vel: q.base_vel,
remove: false,
at_rest_fraction: Some(q.at_rest_fraction),
supported_since_ms: q.supported_since_ms,
spawn_impact: None,
});
}
@@ -107,6 +108,7 @@ pub fn evaluate_elem_collide_motion(
effect_axis: q.effect_axis,
orient: q.orient,
at_rest_fraction: q.at_rest_fraction,
supported_since_ms: q.supported_since_ms,
};
let free = integrate_free_flight(
flags,
@@ -127,6 +129,9 @@ pub fn evaluate_elem_collide_motion(
orientation_pos_to_world(q.orient.origin, q.orient.axis, end_local),
);
let hit = trace(start, end, coll_mins, coll_maxs, mask);
if !hit.complete {
return None;
}
if hit.allsolid || (hit.startsolid && !leaves_start_solid) {
base_vel = [0.0; 3];
break;
@@ -222,6 +227,7 @@ pub fn evaluate_elem_collide_motion(
base_vel,
remove,
at_rest_fraction,
supported_since_ms: None,
spawn_impact,
})
}
@@ -241,12 +247,20 @@ pub fn evaluate_elem_physics_motion(
q: FxElemMotionQuery<'_>,
mut trace: impl FnMut([f32; 3], [f32; 3], [f32; 3], [f32; 3], u32) -> FxElemTraceHit,
) -> FxElemMotionResult {
let mut landed = (q.at_rest_fraction != FX_ELEM_AT_REST_NONE).then_some(q.at_rest_fraction);
let mut landed = (q.at_rest_fraction != FX_ELEM_AT_REST_NONE && q.base_vel == [0.0; 3])
.then_some(q.at_rest_fraction);
let mut supported_since =
if q.at_rest_fraction != FX_ELEM_AT_REST_NONE && q.base_vel != [0.0; 3] {
None
} else {
q.supported_since_ms
};
let mut result = FxElemMotionResult {
origin_delta: [0.0; 3],
base_vel: q.base_vel,
remove: false,
at_rest_fraction: landed,
supported_since_ms: q.supported_since_ms,
spawn_impact: None,
};
if landed.is_some() && q.base_vel == [0.0; 3] {
@@ -279,20 +293,39 @@ pub fn evaluate_elem_physics_motion(
pos[2] + vel[2] * dt,
];
let hit = trace(pos, end, mins, maxs, mask);
if !hit.complete {
result.remove = true;
return result;
}
if hit.allsolid {
vel = [0.0; 3];
supported_since = None;
match lift_out_of_solid(pos, mins, maxs, mask, &mut trace) {
Some(lifted) => {
pos = lifted;
continue;
}
None => {
landed.get_or_insert(at_rest_byte(step.msec_start, q.msec_begin, q.life_ms));
result.remove = true;
break;
}
}
}
if hit.fraction >= 1.0 {
if supported_since.is_some()
&& vec3_length_sq(vel) < FX_PHYS_REST_SPEED * FX_PHYS_REST_SPEED
{
let support = trace(end, [end[0], end[1], end[2] - 0.5], mins, maxs, mask);
if !support.complete
|| support.allsolid
|| support.startsolid
|| support.fraction >= 1.0
|| !collide_on_ground(support.normal[2])
{
supported_since = None;
}
} else {
supported_since = None;
}
pos = end;
continue;
}
@@ -302,6 +335,9 @@ pub fn evaluate_elem_physics_motion(
pos[2] + (end[2] - pos[2]) * hit.fraction,
];
let n = hit.normal;
for (position, normal) in pos.iter_mut().zip(n) {
*position += normal * FX_PHYS_CONTACT_SLOP;
}
let into = dot(vel, n);
if into < 0.0 {
let push = (1.0 + FX_PHYS_BOUNCE) * into;
@@ -314,12 +350,16 @@ pub fn evaluate_elem_physics_motion(
*v = normal_part + (*v - normal_part) * (1.0 - FX_PHYS_FRICTION);
}
}
if collide_on_ground(n[2]) {
landed.get_or_insert(at_rest_byte(step.msec_end, q.msec_begin, q.life_ms));
if vec3_length_sq(vel) < FX_PHYS_REST_SPEED * FX_PHYS_REST_SPEED {
if collide_on_ground(n[2]) && vec3_length_sq(vel) < FX_PHYS_REST_SPEED * FX_PHYS_REST_SPEED
{
let since = *supported_since.get_or_insert(step.msec_start);
if step.msec_end.saturating_sub(since) >= 200 {
landed = Some(at_rest_byte(step.msec_end, q.msec_begin, q.life_ms));
vel = [0.0; 3];
break;
}
} else {
supported_since = None;
}
}
let stored = orientation_pos_from_world(q.orient.origin, q.orient.axis, pos);
@@ -330,6 +370,7 @@ pub fn evaluate_elem_physics_motion(
];
result.base_vel = vel;
result.at_rest_fraction = landed;
result.supported_since_ms = supported_since;
result
}
@@ -345,7 +386,7 @@ fn lift_out_of_solid(
while dz <= cap {
let lifted = [pos[0], pos[1], pos[2] + dz];
let probe = trace(lifted, lifted, mins, maxs, mask);
if !probe.startsolid && !probe.allsolid {
if probe.complete && !probe.startsolid && !probe.allsolid {
return Some(lifted);
}
dz *= 2.0;
@@ -422,6 +463,7 @@ fn integrate_free_flight(
base_vel,
remove: false,
at_rest_fraction: None,
supported_since_ms: None,
spawn_impact: None,
}
}
+1
View File
@@ -506,6 +506,7 @@ fn spawn_elem(
visual_count: elem_def.visual_count,
sequence,
at_rest_fraction: FX_ELEM_AT_REST_NONE,
supported_since_ms: None,
emit_residual: 0,
next_elem_handle: prev_head,
prev_elem_handle: FX_ELEM_HANDLE_NONE,
+12 -3
View File
@@ -48,6 +48,7 @@ pub struct FxElemMotionQuery<'a> {
pub orient: fx_iw4::FxOrientation,
pub at_rest_fraction: u8,
pub supported_since_ms: Option<i32>,
}
#[derive(Clone, Debug)]
@@ -70,6 +71,7 @@ pub struct PendingCollide {
effect_axis: [[f32; 3]; 3],
orient: fx_iw4::FxOrientation,
at_rest_fraction: u8,
supported_since_ms: Option<i32>,
}
impl PendingCollide {
@@ -92,6 +94,7 @@ impl PendingCollide {
effect_axis: self.effect_axis,
orient: self.orient,
at_rest_fraction: self.at_rest_fraction,
supported_since_ms: self.supported_since_ms,
}
}
}
@@ -104,6 +107,7 @@ pub struct FxElemMotionResult {
pub remove: bool,
pub at_rest_fraction: Option<u8>,
pub supported_since_ms: Option<i32>,
pub spawn_impact: Option<FxImpactSpawn>,
}
@@ -138,6 +142,7 @@ pub struct FxChildSpawnRequest<'a> {
#[derive(Clone, Copy, Debug)]
pub struct FxElemTraceHit {
pub complete: bool,
pub fraction: f32,
pub normal: [f32; 3],
pub startsolid: bool,
@@ -424,6 +429,7 @@ fn pending_collide_for_elem(
effect_axis: now.axis,
orient,
at_rest_fraction: elem.at_rest_fraction,
supported_since_ms: elem.supported_since_ms,
})
}
@@ -620,6 +626,7 @@ fn update_element(
let elem_type = elem.elem_type;
let spark_handle = elem.spark_cloud_handle;
let at_rest_fraction = elem.at_rest_fraction;
let supported_since_ms = elem.supported_since_ms;
let (def_name, catalog_index, now, alt, products, mark_entity) =
match host.effect_at(effect_slot) {
Some(e) => (
@@ -672,6 +679,7 @@ fn update_element(
effect_axis: now.axis,
orient,
at_rest_fraction,
supported_since_ms,
};
match collide_cache
.get(&handle)
@@ -687,9 +695,10 @@ fn update_element(
if let Some(elem) = host.elems.get_mut(slot) {
elem.origin = origin_end;
elem.base_vel = result.base_vel;
if let Some(frac) = result.at_rest_fraction {
elem.at_rest_fraction = frac;
}
elem.at_rest_fraction = result
.at_rest_fraction
.unwrap_or(fx_iw4::FX_ELEM_AT_REST_NONE);
elem.supported_since_ms = result.supported_since_ms;
}
let emit_residual = host.elems.get(slot).map(|e| e.emit_residual).unwrap_or(0);
if let Some(sched) = on_emit(FxEmitQuery {
+16 -5
View File
@@ -288,7 +288,10 @@ pub(crate) fn item_screen_rects(
};
let mut out = Vec::new();
for (index, item) in menu.items.iter().enumerate() {
if !matches!(exprs.is_true(&item.vis_exp, host), Ok(true)) {
if !matches!(
exprs.is_true_source(item.visibility_expression, &item.vis_exp, host),
Ok(true)
) {
continue;
}
let Ok(style) = evaluate_item_style(
@@ -333,7 +336,7 @@ fn paint_item(
owner_draw: Option<&mut dyn FnMut(OwnerDrawArgs<'_>, &mut ChromeFrame) -> OwnerDrawPaint>,
frame: &mut ChromeFrame,
) {
match exprs.is_true(&item.vis_exp, host) {
match exprs.is_true_source(item.visibility_expression, &item.vis_exp, host) {
Ok(false) => {
frame.coverage.vis_false();
return;
@@ -835,7 +838,11 @@ fn resolved_material(
exprs: &mut MenuExprCache,
) -> Result<Option<String>, ExprError> {
if !item.material_exp.is_empty() {
let name = exprs.evaluate_string(&item.material_exp, host)?;
let name = exprs.string_source(
item.expression_ids.get("Material").copied(),
&item.material_exp,
host,
)?;
if name.is_empty() {
return Ok(background_stem(background.unwrap_or(&item.background)));
}
@@ -901,7 +908,11 @@ fn evaluate_item_style(
style.unsupported.get_or_insert(key);
continue;
}
let value = match exprs.evaluate_float(dump, host) {
let value = match exprs.float_source(
item.expression_ids.get(&format!("Float({key})")).copied(),
dump,
host,
) {
Ok(value) => value,
Err(err) if key <= FLOAT_RECT_H => return Err(err),
Err(_) => {
@@ -973,7 +984,7 @@ pub(crate) fn atlas_frame_st(
let [rows, columns] = catalog
.and_then(|catalog| {
catalog
.material_2d_plans
.material_2d_plans()
.get(&material.to_ascii_lowercase())
})
.map(|plan| plan.texture_atlas.map(|n| u32::from(n.max(1))))
+41
View File
@@ -5,6 +5,7 @@ use hud_iw4::{ExprError, ExprHost, Statement};
#[derive(Resource, Default)]
pub(crate) struct MenuExprCache {
by_dump: HashMap<String, Result<Statement, ExprError>>,
by_source: HashMap<asset_game::ExpressionId, Result<Statement, ExprError>>,
}
impl MenuExprCache {
@@ -20,6 +21,46 @@ impl MenuExprCache {
}
}
fn source_statement(
&mut self,
id: Option<asset_game::ExpressionId>,
dump: &str,
) -> Result<&Statement, ExprError> {
let Some(id) = id else {
return self.statement(dump);
};
let parsed = self
.by_source
.entry(id)
.or_insert_with(|| Statement::parse(dump));
parsed.as_ref().map_err(Clone::clone)
}
pub(crate) fn is_true_source(
&mut self,
id: Option<asset_game::ExpressionId>,
dump: &str,
host: &impl ExprHost,
) -> Result<bool, ExprError> {
self.source_statement(id, dump)?.is_true(host)
}
pub(crate) fn float_source(
&mut self,
id: Option<asset_game::ExpressionId>,
dump: &str,
host: &impl ExprHost,
) -> Result<f32, ExprError> {
self.source_statement(id, dump)?.evaluate_float(host)
}
pub(crate) fn string_source(
&mut self,
id: Option<asset_game::ExpressionId>,
dump: &str,
host: &impl ExprHost,
) -> Result<String, ExprError> {
self.source_statement(id, dump)?.evaluate_string(host)
}
pub(crate) fn is_true(&mut self, dump: &str, host: &impl ExprHost) -> Result<bool, ExprError> {
self.statement(dump)?.is_true(host)
}
+12 -10
View File
@@ -72,7 +72,7 @@ pub(crate) struct BloodMaterialBinding {
fn blood_material_binding(catalog: &MenuCatalog) -> Result<BloodMaterialBinding, String> {
let pass = "splatter_alt/unlit/pass 0";
let plan = catalog
.material_2d_plans
.material_2d_plans()
.get("splatter_alt")
.ok_or_else(|| format!("{pass}: material plan missing"))?;
if plan.unlit_pass_count != Some(1) || plan.unlit_pass_states.len() != 1 {
@@ -152,7 +152,7 @@ pub struct HudImages {
zone_samplers: HashMap<String, u8>,
blood_plan: Option<Result<BloodMaterialBinding, String>>,
zone_installed: bool,
zone_identity: Option<u64>,
zone_identity: Option<asset_game::ImageProductRevision>,
zone_uploaded: bool,
iwd_warmed: bool,
}
@@ -235,7 +235,9 @@ impl HudImages {
}
pub fn install_zone_catalog(&mut self, catalog: &MenuCatalog) {
let identity = catalog.image_identity();
let identity = catalog
.image_revision()
.unwrap_or_else(|| asset_game::ImageProductRevision(catalog.image_identity()));
if self.zone_identity == Some(identity) {
return;
}
@@ -246,17 +248,17 @@ impl HudImages {
self.blood_plan = Some(blood_material_binding(catalog));
self.zone_srgb_reads.extend(
catalog
.material_srgb_reads
.material_srgb_reads()
.iter()
.map(|(name, &srgb)| (cache_key(name), srgb)),
);
self.material_images.extend(
catalog
.material_images
.material_images()
.iter()
.map(|(material, image)| (cache_key(material), image.clone())),
);
for (name, plan) in &catalog.material_2d_plans {
for (name, plan) in catalog.material_2d_plans() {
let binding =
plan.textures
.iter()
@@ -268,13 +270,13 @@ impl HudImages {
})
});
if let Some(binding) = binding
&& catalog.material_images.get(name) == binding.image.as_ref()
&& catalog.material_images().get(name) == binding.image.as_ref()
{
self.zone_samplers
.insert(cache_key(name), binding.sampler_state);
}
}
for (name, state) in &catalog.material_state_bits {
for (name, state) in catalog.material_state_bits() {
let compiled = state.agreed().and_then(|words| {
let compiled = asset_material::compile_material_state(HUD_CHROME_NAMESPACE, words);
if let Some(fields) = compiled.unsupported_host_fields() {
@@ -285,11 +287,11 @@ impl HudImages {
}
});
self.zone_states.insert(name.clone(), compiled);
if catalog.zone_images.contains_key(name) && state.agreed().is_none() {
if catalog.zone_images().contains_key(name) && state.agreed().is_none() {
diag::warn!(Ui, "hud material state gap: {name}: {state:?}");
}
}
for (key, atlas) in &catalog.zone_images {
for (key, atlas) in catalog.zone_images() {
self.zone_rgba
.insert(key.clone(), (atlas.width, atlas.height, atlas.rgba.clone()));
self.zone_image_name
+1
View File
@@ -297,6 +297,7 @@ pub(crate) fn update_script_menus(
}
return;
}
menus.reconcile(catalog);
let screen_changed = menus.screen.map(|s| s == AppScreen::MainMenu) != Some(frontend);
if screen_changed {
out.binding.command = None;
+8 -1
View File
@@ -355,6 +355,7 @@ impl Runner<'_, '_> {
focus: None,
hover: None,
items: vec![ItemState::default(); def.items.len()],
item_ids: super::state::item_ids(def),
});
diag::info!(Ui, "menu open: {}", def.name);
let name = def.name.clone();
@@ -586,7 +587,13 @@ impl Runner<'_, '_> {
}
let enabled = item.disabled_exp.is_empty()
|| matches!(self.exprs.is_true(&item.disabled_exp, &host), Ok(false));
if enabled && matches!(self.exprs.is_true(&item.vis_exp, &host), Ok(true)) {
if enabled
&& matches!(
self.exprs
.is_true_source(item.visibility_expression, &item.vis_exp, &host),
Ok(true)
)
{
out.push(index);
}
}
+86
View File
@@ -15,6 +15,7 @@ pub(crate) struct OpenMenu {
pub(crate) focus: Option<usize>,
pub(crate) hover: Option<usize>,
pub(crate) items: Vec<ItemState>,
pub(crate) item_ids: Vec<String>,
}
#[derive(Clone, Debug)]
@@ -42,7 +43,92 @@ pub struct ScriptMenus {
pub(crate) bind_requests: Vec<String>,
}
pub(crate) fn item_ids(def: &asset_game::MenuDef) -> Vec<String> {
let mut occurrences = std::collections::HashMap::<String, usize>::new();
def.items
.iter()
.map(|item| {
let name = if !item.authored_id.is_empty() {
format!("authored:{}", item.authored_id.to_ascii_lowercase())
} else if item.name.is_empty() {
format!(
"anonymous:{}:{}:{}:{}",
item.item_type, item.owner_draw, item.dvar, item.text_key
)
} else {
item.name.to_ascii_lowercase()
};
let occurrence = occurrences.entry(name.clone()).or_default();
let id = format!("{name}#{}", *occurrence);
*occurrence += 1;
id
})
.collect()
}
impl ScriptMenus {
pub(crate) fn reconcile(&mut self, catalog: &asset_game::MenuCatalog) {
self.stack.retain(|open| catalog.get(&open.name).is_some());
for open in &mut self.stack {
let Some(def) = catalog.get(&open.name) else {
continue;
};
let ids = item_ids(def);
if open.item_ids == ids {
continue;
}
let remap = |index: Option<usize>| {
index
.and_then(|index| open.item_ids.get(index))
.and_then(|id| ids.iter().position(|next| next == id))
};
open.focus = remap(open.focus);
open.hover = remap(open.hover);
if let Some(editing) = &mut self.editing
&& editing.menu.eq_ignore_ascii_case(&open.name)
{
if let Some(next) = remap(Some(editing.item)) {
editing.item = next;
} else {
self.editing = None;
}
}
if let Some((menu, index)) = &mut self.slider_drag
&& menu.eq_ignore_ascii_case(&open.name)
{
if let Some(next) = remap(Some(*index)) {
*index = next;
} else {
self.slider_drag = None;
}
}
let mut states: std::collections::HashMap<_, _> = open
.item_ids
.drain(..)
.zip(std::mem::take(&mut open.items))
.collect();
open.items = ids
.iter()
.map(|id| states.remove(id).unwrap_or_default())
.collect();
open.item_ids = ids;
}
if self
.editing
.as_ref()
.is_some_and(|editing| !self.is_open(&editing.menu))
{
self.editing = None;
}
if self
.slider_drag
.as_ref()
.is_some_and(|(menu, _)| !self.is_open(menu))
{
self.slider_drag = None;
}
}
pub fn captures_input(&self) -> bool {
self.input_menu().is_some()
}
+21
View File
@@ -0,0 +1,21 @@
use master_protocol::budget::TransportBudget;
use std::sync::OnceLock;
pub(crate) fn configured() -> Result<TransportBudget, String> {
static BUDGET: OnceLock<Result<TransportBudget, String>> = OnceLock::new();
BUDGET
.get_or_init(|| {
TransportBudget::from_settings(TransportBudget::SETTINGS.into_iter().filter_map(
|name| {
std::env::var(name)
.ok()
.map(|value| (name.to_owned(), value))
},
))
})
.clone()
}
pub(crate) fn values() -> TransportBudget {
configured().unwrap_or_default()
}
+104 -18
View File
@@ -1,5 +1,7 @@
#![forbid(unsafe_code)]
mod limits;
mod updates;
use std::collections::HashMap;
@@ -22,20 +24,53 @@ use master_protocol::{
use quinn::crypto::rustls::{QuicClientConfig, QuicServerConfig};
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls_platform_verifier::ConfigVerifierExt;
use tokio::sync::mpsc::error::TrySendError;
use tokio::sync::{Mutex, Semaphore};
type Error = Box<dyn std::error::Error + Send + Sync>;
type Result<T> = std::result::Result<T, Error>;
const MAX_ROOMS: usize = 10_000;
const CONTROL_CAP: usize = 32;
const CONTROL_PRIORITY: i32 = 0;
struct QueuedControl {
frame: ControlFrame,
deadline: tokio::time::Instant,
_bytes: tokio::sync::OwnedSemaphorePermit,
}
#[derive(Clone)]
struct ControlSender {
tx: tokio::sync::mpsc::Sender<QueuedControl>,
bytes: Arc<Semaphore>,
}
impl ControlSender {
fn try_send(&self, frame: ControlFrame) -> Result<()> {
let len = encode_stream_frame(&frame)?.len();
let bytes = self
.bytes
.clone()
.try_acquire_many_owned(len as u32)
.map_err(|_| "control byte budget exhausted")?;
self.tx
.try_send(QueuedControl::new(frame, bytes))
.map_err(|_| "control queue exhausted")?;
Ok(())
}
}
impl QueuedControl {
fn new(frame: ControlFrame, bytes: tokio::sync::OwnedSemaphorePermit) -> Self {
Self {
frame,
deadline: tokio::time::Instant::now() + limits::values().queue_age,
_bytes: bytes,
}
}
}
const BOOTSTRAP_PRIORITY: i32 = -32;
const CLI_DEADLINE: Duration = Duration::from_secs(8);
const BOOTSTRAP_FORWARD: Duration = SESSION_IDLE;
const HELLO_DEADLINE: Duration = Duration::from_secs(20);
const MAX_CONNECTIONS: usize = 256;
/// A household or a LAN party shares one address.
const MAX_CONNECTIONS_PER_ADDRESS: usize = 32;
@@ -122,7 +157,7 @@ struct Peer {
online: Option<Instant>,
player_name: String,
connection: quinn::Connection,
control_tx: tokio::sync::mpsc::Sender<ControlFrame>,
control_tx: ControlSender,
}
struct PasswordVerifier {
@@ -184,7 +219,7 @@ impl ServiceState {
connection_id: u64,
player_name: String,
connection: quinn::Connection,
control_tx: tokio::sync::mpsc::Sender<ControlFrame>,
control_tx: ControlSender,
) {
self.peers.insert(
connection_id,
@@ -395,6 +430,7 @@ impl ServiceState {
#[tokio::main]
async fn main() -> Result<()> {
limits::configured()?;
match parse_args()? {
Command::Serve {
bind,
@@ -549,9 +585,6 @@ async fn serve(bind: SocketAddr, cert_path: &Path, key_path: &Path) -> Result<()
transport.keep_alive_interval(Some(SESSION_KEEP_ALIVE));
transport.datagram_receive_buffer_size(Some(128 * 1024));
transport.datagram_send_buffer_size(DATAGRAM_SEND_BUFFER);
transport.congestion_controller_factory(Arc::new(
master_protocol::congestion::StallTolerantConfig::default(),
));
let endpoint = quinn::Endpoint::server(config, bind)?;
let state = Arc::new(Mutex::new(ServiceState::default()));
@@ -660,11 +693,11 @@ async fn run_connection(
connection_id: u64,
connection: quinn::Connection,
) -> Result<()> {
let (mut send, recv) = tokio::time::timeout(HELLO_DEADLINE, connection.accept_bi())
let (mut send, recv) = tokio::time::timeout(limits::values().response, connection.accept_bi())
.await
.map_err(|_| "hello deadline (accept_bi)")??;
send.set_priority(CONTROL_PRIORITY)?;
let (recv, hello) = tokio::time::timeout(HELLO_DEADLINE, read_owned_frame(recv))
let (recv, hello) = tokio::time::timeout(limits::values().response, read_owned_frame(recv))
.await
.map_err(|_| "hello deadline (first frame)")?;
let hello = hello?;
@@ -679,7 +712,12 @@ async fn run_connection(
hello.game_protocol,
hello.build
);
let (control_tx, mut control_rx) = tokio::sync::mpsc::channel::<ControlFrame>(CONTROL_CAP);
let (tx, mut control_rx) =
tokio::sync::mpsc::channel::<QueuedControl>(limits::values().queue_items);
let control_tx = ControlSender {
tx,
bytes: Arc::new(Semaphore::new(limits::values().queue_bytes)),
};
state.lock().await.attach_peer(
connection_id,
normalize_player_name(&hello.player_name),
@@ -691,7 +729,9 @@ async fn run_connection(
children.spawn(async move {
let result = async {
while let Some(frame) = control_rx.recv().await {
write_frame(&mut send, &frame).await?;
tokio::time::timeout_at(frame.deadline, write_frame(&mut send, &frame.frame))
.await
.map_err(|_| "control queue/write deadline")??;
}
Result::<()>::Ok(())
}
@@ -782,7 +822,7 @@ async fn dispatch_publishes(state: &Mutex<ServiceState>, publishes: Vec<(u64, Co
};
match peer.control_tx.try_send(frame) {
Ok(()) => {}
Err(TrySendError::Full(_)) | Err(TrySendError::Closed(_)) => {
Err(_) => {
failed.push(connection_id);
peer.connection
.close(0_u8.into(), b"control delivery failed");
@@ -1363,8 +1403,8 @@ async fn bootstrap_forward(
mut recv: quinn::RecvStream,
_permit: tokio::sync::OwnedSemaphorePermit,
) -> ConnTask {
let result = tokio::time::timeout(BOOTSTRAP_FORWARD, async {
let bytes = recv.read_to_end(MAX_BOOTSTRAP_STREAM_BYTES).await?;
let result = tokio::time::timeout(limits::values().transfer, async {
let bytes = read_transfer(&mut recv).await?;
let relay = decode_relay_stream(&bytes)?;
let (target, outgoing) = {
let current = state.lock().await;
@@ -1372,11 +1412,17 @@ async fn bootstrap_forward(
};
let mut send = target.open_uni().await?;
send.set_priority(BOOTSTRAP_PRIORITY)?;
send.write_all(&outgoing).await?;
send.finish()?;
let result = write_transfer(&mut send, &outgoing).await;
if result.is_err() {
let _ = send.reset(0_u8.into());
}
result?;
Result::<()>::Ok(())
})
.await;
if !matches!(result, Ok(Ok(()))) {
let _ = recv.stop(0_u8.into());
}
match result {
Ok(Ok(())) => {}
Ok(Err(error)) => {
@@ -1585,6 +1631,46 @@ async fn rpc(
}
}
async fn read_transfer(recv: &mut quinn::RecvStream) -> Result<Vec<u8>> {
let mut bytes = Vec::new();
loop {
let chunk =
tokio::time::timeout(limits::values().progress, recv.read_chunk(16 * 1024, true))
.await
.map_err(|_| "transfer progress deadline")??;
let Some(chunk) = chunk else {
return Ok(bytes);
};
if chunk.bytes.is_empty() || bytes.len() + chunk.bytes.len() > MAX_BOOTSTRAP_STREAM_BYTES {
return Err("transfer byte budget exceeded".into());
}
bytes.extend_from_slice(&chunk.bytes);
}
}
async fn write_transfer(send: &mut quinn::SendStream, bytes: &[u8]) -> Result<()> {
let mut offset = 0;
while offset < bytes.len() {
let end = (offset + 16 * 1024).min(bytes.len());
let written =
tokio::time::timeout(limits::values().progress, send.write(&bytes[offset..end]))
.await
.map_err(|_| "transfer progress deadline")??;
if written == 0 {
return Err("transfer made no progress".into());
}
offset += written;
}
send.finish()?;
if let Some(code) = tokio::time::timeout(limits::values().progress, send.stopped())
.await
.map_err(|_| "transfer acknowledgement deadline")??
{
return Err(format!("transfer refused with code {code}").into());
}
Ok(())
}
async fn read_owned_frame(
mut recv: quinn::RecvStream,
) -> (quinn::RecvStream, Result<ControlFrame>) {
-3
View File
@@ -7,6 +7,3 @@ description = "Versioned control and opaque relay protocol shared by iw4l and iw
[lints]
workspace = true
[dependencies]
quinn-proto = { version = "0.11.17", default-features = false }
+98
View File
@@ -0,0 +1,98 @@
use core::time::Duration;
#[derive(Clone, Copy, Debug)]
pub struct TransportBudget {
pub response: Duration,
pub progress: Duration,
pub transfer: Duration,
pub queue_age: Duration,
pub connect: Duration,
pub shutdown: Duration,
pub loading: Duration,
pub silence: Duration,
pub admission: Duration,
pub queue_items: usize,
pub queue_bytes: usize,
}
impl Default for TransportBudget {
fn default() -> Self {
Self {
response: Duration::from_secs(20),
progress: Duration::from_secs(5),
transfer: Duration::from_secs(90),
queue_age: Duration::from_secs(20),
connect: Duration::from_secs(60),
shutdown: Duration::from_millis(500),
loading: Duration::from_secs(120),
silence: Duration::from_secs(60),
admission: Duration::from_secs(60),
queue_items: 32,
queue_bytes: 8 * 1024 * 1024,
}
}
}
impl TransportBudget {
pub const SETTINGS: [&'static str; 11] = [
"IW4L_NET_RESPONSE_MS",
"IW4L_NET_PROGRESS_MS",
"IW4L_NET_TRANSFER_MS",
"IW4L_NET_QUEUE_AGE_MS",
"IW4L_NET_CONNECT_MS",
"IW4L_NET_SHUTDOWN_MS",
"IW4L_NET_LOADING_MS",
"IW4L_NET_SILENCE_MS",
"IW4L_NET_ADMISSION_MS",
"IW4L_NET_QUEUE_ITEMS",
"IW4L_NET_QUEUE_BYTES",
];
pub fn from_settings(
settings: impl IntoIterator<Item = (String, String)>,
) -> Result<Self, String> {
let mut budget = Self::default();
for (name, text) in settings {
let n = text
.parse::<u64>()
.map_err(|_| format!("{name} must be a positive integer"))?;
match name.as_str() {
"IW4L_NET_QUEUE_ITEMS" if (1..=1024).contains(&n) => {
budget.queue_items = n as usize
}
"IW4L_NET_QUEUE_BYTES" if (256 * 1024..=64 * 1024 * 1024).contains(&n) => {
budget.queue_bytes = n as usize
}
"IW4L_NET_RESPONSE_MS"
| "IW4L_NET_PROGRESS_MS"
| "IW4L_NET_TRANSFER_MS"
| "IW4L_NET_QUEUE_AGE_MS"
| "IW4L_NET_CONNECT_MS"
| "IW4L_NET_SHUTDOWN_MS"
| "IW4L_NET_LOADING_MS"
| "IW4L_NET_SILENCE_MS"
| "IW4L_NET_ADMISSION_MS"
if (1..=600_000).contains(&n) =>
{
let duration = Duration::from_millis(n);
match name.as_str() {
"IW4L_NET_RESPONSE_MS" => budget.response = duration,
"IW4L_NET_PROGRESS_MS" => budget.progress = duration,
"IW4L_NET_TRANSFER_MS" => budget.transfer = duration,
"IW4L_NET_CONNECT_MS" => budget.connect = duration,
"IW4L_NET_SHUTDOWN_MS" => budget.shutdown = duration,
"IW4L_NET_LOADING_MS" => budget.loading = duration,
"IW4L_NET_SILENCE_MS" => budget.silence = duration,
"IW4L_NET_ADMISSION_MS" => budget.admission = duration,
_ => budget.queue_age = duration,
}
}
_ => return Err(format!("invalid transport setting {name}={text}")),
}
}
if budget.progress > budget.transfer {
return Err("transfer progress deadline exceeds total transfer deadline".into());
}
Ok(budget)
}
}
-102
View File
@@ -1,102 +0,0 @@
use std::{
any::Any,
sync::Arc,
time::{Duration, Instant},
};
use quinn_proto::{
RttEstimator,
congestion::{BbrConfig, Controller, ControllerFactory},
};
const STALL: Duration = Duration::from_secs(1);
#[derive(Debug, Default)]
pub struct StallTolerantConfig(BbrConfig);
impl ControllerFactory for StallTolerantConfig {
fn build(self: Arc<Self>, now: Instant, current_mtu: u16) -> Box<dyn Controller> {
Box::new(StallTolerant {
inner: Arc::new(self.0.clone()).build(now, current_mtu),
waiting_since: None,
stalled: false,
})
}
}
struct StallTolerant {
inner: Box<dyn Controller>,
waiting_since: Option<Instant>,
stalled: bool,
}
impl Controller for StallTolerant {
fn on_sent(&mut self, now: Instant, bytes: u64, last_packet_number: u64) {
let since = *self.waiting_since.get_or_insert(now);
self.stalled = now.saturating_duration_since(since) >= STALL;
self.inner.on_sent(now, bytes, last_packet_number);
}
fn on_ack(
&mut self,
now: Instant,
sent: Instant,
bytes: u64,
app_limited: bool,
rtt: &RttEstimator,
) {
self.inner.on_ack(now, sent, bytes, app_limited, rtt);
}
fn on_end_acks(
&mut self,
now: Instant,
in_flight: u64,
app_limited: bool,
largest_packet_num_acked: Option<u64>,
) {
self.waiting_since = (in_flight > 0).then_some(now);
self.stalled = false;
self.inner
.on_end_acks(now, in_flight, app_limited, largest_packet_num_acked);
}
fn on_congestion_event(
&mut self,
now: Instant,
sent: Instant,
is_persistent_congestion: bool,
lost_bytes: u64,
) {
self.inner
.on_congestion_event(now, sent, is_persistent_congestion, lost_bytes);
}
fn on_mtu_update(&mut self, new_mtu: u16) {
self.inner.on_mtu_update(new_mtu);
}
fn window(&self) -> u64 {
if self.stalled {
u64::MAX
} else {
self.inner.window()
}
}
fn clone_box(&self) -> Box<dyn Controller> {
Box::new(StallTolerant {
inner: self.inner.clone_box(),
waiting_since: self.waiting_since,
stalled: self.stalled,
})
}
fn initial_window(&self) -> u64 {
self.inner.initial_window()
}
fn into_any(self: Box<Self>) -> Box<dyn Any> {
self
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
use core::{fmt, str::FromStr};
pub mod congestion;
pub mod budget;
pub const PROTOCOL_VERSION: u16 = 10;
pub const ALPN: &[u8] = b"iw4l-master/10";
@@ -353,6 +353,7 @@ pub fn corpse_slot_to_entity_state(slot: &PlayerCorpseSlot) -> EntityState {
es.client_num = i32::try_from(slot.victim.0).unwrap_or(0);
es.time2 = slot.spawn_time_ms;
es.e_flags = slot.e_flags;
es.weapon_model = slot.weapon_model;
es.tr_type = slot.tr_type;
es.tr_time = slot.tr_time;
es.tr_duration = slot.tr_duration;
+44 -7
View File
@@ -12,12 +12,20 @@ use crate::{AuthoritySet, ClientSet, LastAdoptedSnapshot, ServerTick};
pub const CLIENT_ENTITY_SLOT_COUNT: usize = ENTITYNUM_NONE as usize;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct CorpseIdentity {
pub match_key: frame::MatchKey,
pub slot: u16,
pub spawn_sequence: u64,
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Eq)]
#[component(immutable)]
#[require(bevy::prelude::DespawnOnExit<frame::Timeline> = bevy::prelude::DespawnOnExit(frame::Timeline::Running))]
pub struct CEntity {
number: u16,
client: Option<ClientId>,
corpse: Option<CorpseIdentity>,
}
impl CEntity {
@@ -25,6 +33,10 @@ impl CEntity {
self.number
}
pub const fn corpse_identity(&self) -> Option<CorpseIdentity> {
self.corpse
}
pub const fn client(&self) -> Option<ClientId> {
self.client
}
@@ -119,12 +131,14 @@ pub fn sync_client_entities(
proxy: Option<Res<crate::RemoteProxyState>>,
clock: Option<Res<crate::FrameClock>>,
local: Option<Res<crate::LocalPresentClient>>,
mut selected_tick: Local<Option<(sim::Tick, bool)>>,
mut selected_tick: Local<Option<(sim::Tick, bool, u64)>>,
timeline: Res<crate::EntityEventCursor>,
mut slots: ResMut<CEntitySlots>,
mut gaps: ResMut<NetIdentityGaps>,
identities: Query<&CEntity>,
mut runtimes: Query<&mut CEntityRuntime>,
mut census: ResMut<CEntityBirthCensus>,
installed: Res<frame::InstalledMatch>,
) {
let archived = adopted.next().is_some_and(|snap| {
snap.players.iter().any(|(id, ps)| {
@@ -141,7 +155,7 @@ pub fn sync_client_entities(
None
};
let next = selected.as_deref().or_else(|| adopted.next());
let key = next.map(|snap| (snap.tick, archived));
let key = next.map(|snap| (snap.tick, archived, timeline.timeline()));
if archived {
if *selected_tick == key {
return;
@@ -182,6 +196,7 @@ pub fn sync_client_entities(
.flatten(),
CEntitySyncArm::Client,
Some(&mut census),
installed.load_key.match_key,
);
}
@@ -193,6 +208,7 @@ pub(crate) fn sync_authority_entities(
identities: Query<&CEntity>,
mut runtimes: Query<&mut CEntityRuntime>,
mut census: ResMut<CEntityBirthCensus>,
installed: Res<frame::InstalledMatch>,
) {
let players = server_tick
.0
@@ -230,6 +246,7 @@ pub(crate) fn sync_authority_entities(
None,
CEntitySyncArm::Authority,
Some(&mut census),
installed.load_key.match_key,
);
}
@@ -237,12 +254,14 @@ pub(crate) fn sync_authority_entities(
struct WantedOccupant {
client: Option<ClientId>,
next_state: entity_iw4::EntityState,
corpse_sequence: Option<u64>,
}
fn occupant_from_player(client: ClientId, ps: &playerstate_iw4::PlayerState) -> WantedOccupant {
WantedOccupant {
client: Some(client),
next_state: player_state_to_entity_state(client, ps),
corpse_sequence: None,
}
}
@@ -250,6 +269,7 @@ fn occupant_from_corpse(slot: &PlayerCorpseSlot) -> WantedOccupant {
WantedOccupant {
client: Some(slot.victim),
next_state: corpse_slot_to_entity_state(slot),
corpse_sequence: Some(slot.spawn_sequence),
}
}
@@ -260,6 +280,7 @@ fn occupant_from_replicated(es: entity_iw4::EntityState) -> WantedOccupant {
WantedOccupant {
client,
next_state: es,
corpse_sequence: None,
}
}
@@ -288,6 +309,7 @@ fn sync_snapshot_entities(
seated_viewer: Option<ClientId>,
arm: CEntitySyncArm,
mut census: Option<&mut CEntityBirthCensus>,
match_key: frame::MatchKey,
) {
if pair_ready {
for mut runtime in runtimes.iter_mut() {
@@ -350,11 +372,17 @@ fn sync_snapshot_entities(
let index = usize::from(number);
let client = occupant.client;
let next_state = occupant.next_state;
let corpse = occupant
.corpse_sequence
.map(|spawn_sequence| CorpseIdentity {
match_key,
slot: number,
spawn_sequence,
});
let valid = slots.slots[index].is_some_and(|slot| {
identities
.get(slot.entity)
.is_ok_and(|identity| identity.number == number && identity.client == client)
&& runtimes.get(slot.entity).is_ok()
identities.get(slot.entity).is_ok_and(|identity| {
identity.number == number && identity.client == client && identity.corpse == corpse
}) && runtimes.get(slot.entity).is_ok()
});
if valid {
let entity = slots.slots[index].expect("valid slot").entity;
@@ -374,7 +402,16 @@ fn sync_snapshot_entities(
}
let mut runtime = CEntityRuntime::default();
reset_entity(&mut runtime, next_state, at_time_ms, true);
let entity = commands.spawn((CEntity { number, client }, runtime)).id();
let entity = commands
.spawn((
CEntity {
number,
client,
corpse,
},
runtime,
))
.id();
slots.slots[index] = Some(CEntitySlot { client, entity });
if let Some(census) = census.as_mut() {
census.note_birth(arm);
@@ -158,7 +158,7 @@ struct DeferredEntityEvent {
target: DeferredTarget,
}
#[derive(Resource, Default, Debug)]
#[derive(Resource, Debug)]
pub struct EntityEventCursor {
seen_through: u32,
@@ -172,6 +172,32 @@ pub struct EntityEventCursor {
deferred: VecDeque<DeferredEntityEvent>,
}
fn fresh_timeline() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
NEXT.fetch_update(
std::sync::atomic::Ordering::Relaxed,
std::sync::atomic::Ordering::Relaxed,
|next| next.checked_add(1),
)
.expect("presentation timeline exhausted")
}
impl Default for EntityEventCursor {
fn default() -> Self {
Self {
seen_through: 0,
archived_through: None,
archive_tick: None,
archive_start_tick: None,
in_killcam: false,
timeline: fresh_timeline(),
predicted_shots: 0,
authority_shots: 0,
deferred: VecDeque::new(),
}
}
}
#[derive(Resource, Default, Debug, Clone, Copy, PartialEq, Eq)]
pub struct AppliedEntityEventWalk {
pub walked: u32,
@@ -308,7 +334,7 @@ fn dispatch_entity_events(
.is_some_and(|(next, old)| next.0 < old.0);
cursor.archive_tick = pending.archive_tick;
if killcam_transition {
cursor.timeline = cursor.timeline.wrapping_add(1);
cursor.timeline = fresh_timeline();
cursor.in_killcam = pending.in_killcam;
cursor.archived_through = None;
cursor.archive_start_tick = pending.archive_tick;
@@ -744,6 +770,10 @@ pub fn register_entity_event_dispatch(app: &mut App) {
.in_set(frame::InMatch)
.in_set(ClientSet::Reconcile)
.after(crate::sync_client_entities),
crate::client::runtime::publish_accepted_impacts
.in_set(frame::InMatch)
.in_set(frame::OwnerEventsPublished)
.after(dispatch_owner_events),
dispatch_owner_events
.in_set(frame::InMatch)
.in_set(frame::OwnerEventsPublished)
+28
View File
@@ -123,6 +123,34 @@ impl RemoteProxy {
Self::default()
}
pub(crate) fn impact_window(
&self,
after: Option<sim::Tick>,
through: sim::Tick,
) -> (Option<sim::Tick>, Vec<(sim::Tick, sim::PelletFxRecord)>) {
let oldest = self.buffer.front().map(|entry| entry.snap.tick);
let gap =
oldest.filter(|oldest| after.is_some_and(|after| after.0.saturating_add(1) < oldest.0));
let records = self
.buffer
.iter()
.filter(|entry| {
entry.snap.tick.0 <= through.0
&& after.is_none_or(|after| entry.snap.tick.0 > after.0)
})
.flat_map(|entry| {
entry
.snap
.meta
.pellet_fx
.iter()
.copied()
.map(move |record| (entry.snap.tick, record))
})
.collect();
(gap, records)
}
pub fn delay_ms(&self) -> i32 {
self.delay_ms
}
+148 -1
View File
@@ -41,6 +41,140 @@ pub struct PendingPresentedEntityEvents {
pub in_killcam: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct ImpactOccurrence {
pub installation: frame::LocalLoadKey,
pub timeline: u64,
pub tick: sim::Tick,
pub cause: Option<sim::FireCause>,
pub attacker: i32,
pub correlation: u32,
pub pellet: u16,
pub segment: u16,
}
#[derive(Message, Clone, Copy, Debug)]
pub struct AcceptedImpact {
pub occurrence: ImpactOccurrence,
pub world: frame::WorldStamp,
pub record: sim::PelletFxRecord,
}
#[derive(Message, Clone, Copy, Debug)]
pub struct ImpactReconstruction {
pub context: frame::PresentationContext,
pub retained_from: Option<sim::Tick>,
}
#[derive(Resource, Default)]
pub struct PendingAcceptedImpacts(Vec<(frame::WorldStamp, sim::Tick, sim::PelletFxRecord)>);
#[derive(Resource, Default)]
pub(crate) struct ImpactCursor {
replay_tick: Option<sim::Tick>,
context: Option<(frame::LocalLoadKey, u64)>,
seen: std::collections::HashSet<ImpactOccurrence>,
order: VecDeque<ImpactOccurrence>,
}
pub(crate) fn publish_accepted_impacts(
mut pending: ResMut<PendingAcceptedImpacts>,
mut cursor: ResMut<ImpactCursor>,
installed: Res<frame::InstalledMatch>,
timeline: Res<crate::EntityEventCursor>,
mut impacts: MessageWriter<AcceptedImpact>,
presented: Res<PresentedSnapshot>,
mut visuals: ResMut<PendingPelletFx>,
proxy: Res<RemoteProxyState>,
mut reconstruction: MessageWriter<ImpactReconstruction>,
) {
let context = Some((installed.load_key, timeline.timeline()));
if cursor.context != context {
cursor.context = context;
cursor.seen.clear();
cursor.order.clear();
cursor.replay_tick = None;
reconstruction.write(ImpactReconstruction {
context: frame::PresentationContext::new(installed.load_key, timeline.timeline()),
retained_from: None,
});
}
let mut accepted = std::mem::take(&mut pending.0);
if presented.is_archived_replay()
&& let Some(snapshot) = presented.snapshot()
{
let world = frame::WorldStamp::from_install(installed.request_id);
let (gap, records) = proxy.0.impact_window(cursor.replay_tick, snapshot.tick);
if gap.is_some() {
cursor.seen.clear();
cursor.order.clear();
reconstruction.write(ImpactReconstruction {
context: frame::PresentationContext::new(installed.load_key, timeline.timeline()),
retained_from: gap,
});
}
if records.is_empty() && cursor.replay_tick != Some(snapshot.tick) {
accepted.extend(
snapshot
.meta
.pellet_fx
.iter()
.copied()
.map(|record| (world, snapshot.tick, record)),
);
} else {
accepted.extend(
records
.into_iter()
.map(|(tick, record)| (world, tick, record)),
);
}
cursor.replay_tick = Some(snapshot.tick);
}
for (world, tick, record) in accepted {
if world != frame::WorldStamp::from_install(installed.request_id)
|| record.normal == [0.0; 3]
{
continue;
}
let occurrence = ImpactOccurrence {
installation: installed.load_key,
timeline: timeline.timeline(),
tick: if record.fire_cause.is_some() {
sim::Tick(0)
} else {
tick
},
cause: record.fire_cause,
attacker: record.attacker,
correlation: record.correlation,
pellet: record.pellet,
segment: record.segment,
};
if !cursor.seen.insert(occurrence) {
continue;
}
cursor.order.push_back(occurrence);
while cursor.order.len() > 4096 {
if let Some(old) = cursor.order.pop_front() {
cursor.seen.remove(&old);
}
}
if presented.is_archived_replay() {
visuals
.0
.push((world, crate::EntityEventDomain::Snapshot, record));
}
impacts.write(AcceptedImpact {
occurrence,
world,
record,
});
}
let excess = visuals.0.len().saturating_sub(8192);
visuals.0.drain(..excess);
}
#[derive(Resource, Default)]
pub struct PendingPelletFx(
pub(crate) Vec<(
@@ -559,6 +693,7 @@ pub fn reconcile_prediction(
mut pending: ResMut<PendingClientSends>,
mut entity_events: ResMut<PendingPresentedEntityEvents>,
mut pellet_fx: ResMut<PendingPelletFx>,
mut accepted: ResMut<PendingAcceptedImpacts>,
role: Res<RuntimeRole>,
cg_clock: Res<FrameClock>,
trace: Option<ResMut<ClientPhaseTrace>>,
@@ -584,9 +719,17 @@ pub fn reconcile_prediction(
tick.snapshot.meta.world_objects.map_round_epoch == match_key.match_epoch
};
if same_match {
accepted.0.extend(
tick.snapshot
.meta
.pellet_fx
.iter()
.copied()
.map(|record| (reliable.generation.stamp(), tick.snapshot.tick, record)),
);
pellet_fx
.0
.extend(tick.snapshot.meta.pellet_fx.drain(..).map(|record| {
.extend(tick.snapshot.meta.pellet_fx.iter().copied().map(|record| {
(
reliable.generation.stamp(),
crate::EntityEventDomain::Snapshot,
@@ -2031,6 +2174,10 @@ pub fn register_client_runtime(app: &mut App) {
.scoped::<PresentLocalCensus>(frame::Timeline::Running)
.scoped::<PendingPresentedEntityEvents>(frame::Timeline::Running)
.scoped::<PendingPelletFx>(frame::Timeline::Running)
.scoped::<PendingAcceptedImpacts>(frame::Timeline::Running)
.scoped::<ImpactCursor>(frame::Timeline::Running)
.scoped_message::<AcceptedImpact>(frame::Timeline::Running)
.scoped_message::<ImpactReconstruction>(frame::Timeline::Running)
.scoped::<PendingClientSends>(frame::MatchScope::Live)
.init_resource::<ClientCmdTemplate>()
.scoped::<WeaponSelect>(frame::MatchScope::Live)
+12 -11
View File
@@ -53,8 +53,8 @@ pub use client::presentation::centity_runtime::{
remote_body_submits, remote_pose_sample,
};
pub use client::presentation::entities::{
CEntity, CEntityBirthCensus, CEntitySlots, CLIENT_ENTITY_SLOT_COUNT, register_client_entities,
sync_client_entities,
CEntity, CEntityBirthCensus, CEntitySlots, CLIENT_ENTITY_SLOT_COUNT, CorpseIdentity,
register_client_entities, sync_client_entities,
};
pub use client::presentation::entity_event_dispatch::{
AppliedEntityEventWalk, DispatchedEntityEvent, EntityBulletHit, EntityEjectBrass,
@@ -80,14 +80,15 @@ pub use client::proxy::{
};
pub use client::realtime::ClientRealtime;
pub use client::runtime::{
ClientClock, ClientCmdTemplate, ClientPhaseTrace, ClientPredictionState, ClientReliableAck,
ClockTick, FireCommandVerdicts, LastAdoptedSnapshot, LocationCursor, PendingClientSends,
PendingPelletFx, PendingPresentedEntityEvents, ReceivedTicks, ReliableControlEvent,
RemoteProxyState, WeaponSelect, advance_cg_frame_clock, advance_cls_realtime,
arm_listen_prediction, cycle_weapon_select, follow_held_weapon_select,
listen_prediction_needs_content, predict_local_move, publish_presented, receive_ticks,
reconcile_prediction, register_client_runtime, register_listen_prediction_arm,
sample_client_input, send_pending_commands,
AcceptedImpact, ClientClock, ClientCmdTemplate, ClientPhaseTrace, ClientPredictionState,
ClientReliableAck, ClockTick, FireCommandVerdicts, ImpactOccurrence, ImpactReconstruction,
LastAdoptedSnapshot, LocationCursor, PendingClientSends, PendingPelletFx,
PendingPresentedEntityEvents, ReceivedTicks, ReliableControlEvent, RemoteProxyState,
WeaponSelect, advance_cg_frame_clock, advance_cls_realtime, arm_listen_prediction,
cycle_weapon_select, follow_held_weapon_select, listen_prediction_needs_content,
predict_local_move, publish_presented, receive_ticks, reconcile_prediction,
register_client_runtime, register_listen_prediction_arm, sample_client_input,
send_pending_commands,
};
pub use gaps::{NetGap, NetGapCause, NetIdentityGaps, ScriptNotify};
pub use plugin::NetPlugin;
@@ -174,6 +175,6 @@ pub use transport::wire::{WireError, WireReader, WireWriter};
pub use svc_script_audio::SvcScriptAudio;
pub const PROTOCOL_VERSION: u32 = 118;
pub const PROTOCOL_VERSION: u32 = 119;
pub use client::fire_verdict::{FireVerdictState, PredictedFireStatus};
+48 -17
View File
@@ -53,10 +53,6 @@ pub(crate) fn format_session_transition(
)
}
pub const LOADING_DEADLINE_MS: u64 = 120_000;
pub const RUNNING_SILENCE_MS: u64 = 60_000;
pub const PEER_ADMISSION_DEADLINE_MS: u64 = master_protocol::SESSION_IDLE.as_millis() as u64;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProgressPhase {
Idle,
@@ -102,10 +98,16 @@ impl ProgressWatch {
match self.phase {
ProgressPhase::Idle => false,
ProgressPhase::Loading => {
now_ms.saturating_sub(self.phase_started_at) >= LOADING_DEADLINE_MS
now_ms.saturating_sub(self.phase_started_at)
>= crate::transport::master::limits::values()
.loading
.as_millis() as u64
}
ProgressPhase::Running => {
now_ms.saturating_sub(self.last_progress_ms) >= RUNNING_SILENCE_MS
now_ms.saturating_sub(self.last_progress_ms)
>= crate::transport::master::limits::values()
.silence
.as_millis() as u64
}
}
}
@@ -230,7 +232,7 @@ pub enum PeerPhase {
pub struct PeerAdmission {
pub phase: PeerPhase,
pub map_loaded: Option<HostWorldReady>,
pub started_at_ms: u64,
pub started_at_ms: Option<u64>,
pub connection_id: Option<u64>,
}
@@ -239,7 +241,7 @@ impl Default for PeerAdmission {
Self {
phase: PeerPhase::Waiting,
map_loaded: None,
started_at_ms: 0,
started_at_ms: None,
connection_id: None,
}
}
@@ -251,6 +253,11 @@ pub enum HostMatchEvent {
match_key: MatchKey,
now_ms: u64,
},
PeerRoster {
match_key: MatchKey,
members: Vec<MemberId>,
now_ms: u64,
},
AuthorityReady {
ready: HostWorldReady,
now_ms: u64,
@@ -363,6 +370,7 @@ impl TransitionIds {
fn from_event(event: &HostMatchEvent, elapsed_from: u64) -> Self {
match event {
HostMatchEvent::Start { now_ms, .. }
| HostMatchEvent::PeerRoster { now_ms, .. }
| HostMatchEvent::AuthorityReady { now_ms, .. }
| HostMatchEvent::AuthorityProgress { now_ms }
| HostMatchEvent::Tick { now_ms } => Self {
@@ -526,6 +534,26 @@ impl HostMatchCore {
self.progress.set_phase(ProgressPhase::Loading, now_ms);
self.finish(&ids, before, "start loading", Vec::new())
}
HostMatchEvent::PeerRoster {
match_key,
members,
now_ms,
} => {
if match_key != self.match_key
|| !matches!(self.phase, MatchPhase::Loading | MatchPhase::Running)
|| members.len() > master_protocol::MAX_SESSION_MEMBERS as usize
{
return self.reject(&ids, before, "stale or invalid peer roster");
}
self.peers.retain(|member, _| members.contains(member));
for member in members {
self.peers.entry(member).or_insert_with(|| PeerAdmission {
started_at_ms: Some(now_ms),
..Default::default()
});
}
self.finish(&ids, before, "peer roster", Vec::new())
}
HostMatchEvent::AuthorityReady { ready, now_ms } => {
if !epoch_applies(ready.epoch, self.match_key.match_epoch) {
return self.reject(&ids, before, "stale host world");
@@ -539,10 +567,8 @@ impl HostMatchCore {
self.phase = MatchPhase::Running;
self.progress.set_phase(ProgressPhase::Running, now_ms);
for peer in self.peers.values_mut() {
peer.started_at_ms = now_ms;
peer.started_at_ms = Some(now_ms);
}
} else {
self.progress.beat(now_ms);
}
let match_key = self.match_key;
let effects = self
@@ -568,12 +594,14 @@ impl HostMatchCore {
{
return self.reject(&ids, before, "stale map loaded");
}
let peer = self.peers.entry(member).or_default();
let Some(peer) = self.peers.get_mut(&member) else {
return self.reject(&ids, before, "map loaded outside peer roster");
};
if matches!(peer.phase, PeerPhase::Failed | PeerPhase::Cancelled) {
return self.reject(&ids, before, "map loaded after admission ended");
}
if self.phase == MatchPhase::Running && peer.started_at_ms == 0 {
peer.started_at_ms = now_ms;
if self.phase == MatchPhase::Running && peer.started_at_ms.is_none() {
peer.started_at_ms = Some(now_ms);
}
peer.map_loaded = Some(loaded);
let effects = self
@@ -721,9 +749,12 @@ impl HostMatchCore {
if self.phase == MatchPhase::Running {
for (member, peer) in &self.peers {
if matches!(peer.phase, PeerPhase::Waiting | PeerPhase::Syncing { .. })
&& peer.started_at_ms != 0
&& now_ms.saturating_sub(peer.started_at_ms)
>= PEER_ADMISSION_DEADLINE_MS
&& peer.started_at_ms.is_some_and(|started| {
now_ms.saturating_sub(started)
>= crate::transport::master::limits::values()
.admission
.as_millis() as u64
})
{
expired.push(*member);
}
+5
View File
@@ -53,6 +53,8 @@ fn archived_snapshot_bytes(snapshot: &Snapshot) -> usize {
.sum();
SNAPSHOT_BASE
+ snapshot.players.len() * core::mem::size_of::<PlayerState>()
+ snapshot.meta.pellet_fx.len() * core::mem::size_of::<sim::PelletFxRecord>()
+ snapshot.meta.entity_events.len() * core::mem::size_of::<sim::EntityEventRecord>()
+ snapshot.projectiles.len() * 64
+ snapshot.meta.entities.len() * core::mem::size_of::<entity_iw4::EntityState>()
+ hud_elems * core::mem::size_of::<hud_iw4::HudElem>()
@@ -98,6 +100,9 @@ impl FrameArchive {
}
pub fn push(&mut self, frame: ArchivedFrame) -> bool {
if frame.bytes() > ARCHIVE_BYTE_BUDGET {
return false;
}
if let Some(newest) = self.frames.back()
&& frame.tick.0 <= newest.tick.0
{
+81 -8
View File
@@ -225,8 +225,15 @@ pub struct BootstrapAck {
pub struct BootstrapLane {
pub(super) host_map_ready: Mutex<HashSet<master_protocol::MemberId>>,
to_worker: Mutex<VecDeque<(Option<master_protocol::MemberId>, Vec<u8>)>>,
from_worker: Mutex<VecDeque<BootstrapIngress>>,
to_worker: Mutex<
VecDeque<(
Option<master_protocol::MemberId>,
Vec<u8>,
std::time::Instant,
)>,
>,
from_worker: Mutex<VecDeque<(BootstrapIngress, std::time::Instant)>>,
fault: Mutex<Option<&'static str>>,
acks: Mutex<VecDeque<BootstrapAck>>,
epoch: AtomicU32,
entered_bootstrap: AtomicU32,
@@ -240,6 +247,7 @@ impl BootstrapLane {
to_worker: Mutex::new(VecDeque::new()),
from_worker: Mutex::new(VecDeque::new()),
acks: Mutex::new(VecDeque::new()),
fault: Mutex::new(None),
epoch: AtomicU32::new(0),
entered_bootstrap: AtomicU32::new(0),
entered_client: AtomicU32::new(0),
@@ -249,6 +257,7 @@ impl BootstrapLane {
pub fn set_epoch(&self, epoch: u32) {
let prev = self.epoch.swap(epoch, Ordering::AcqRel);
if prev != epoch {
*self.fault.lock().expect("bootstrap fault poisoned") = None;
self.host_map_ready
.lock()
.expect("bootstrap readiness poisoned")
@@ -291,16 +300,36 @@ impl BootstrapLane {
bytes: Vec<u8>,
) -> Result<(), &'static str> {
let mut queue = self.to_worker.lock().expect("bootstrap lane poisoned");
if queue.len() >= MAX_BOOTSTRAP_PENDING {
if queue.len() >= MAX_BOOTSTRAP_PENDING.min(super::master::limits::values().queue_items)
|| bytes.len() > master_protocol::MAX_BOOTSTRAP_STREAM_BYTES
|| queue.iter().map(|(_, bytes, _)| bytes.len()).sum::<usize>() + bytes.len()
> super::master::limits::values().queue_bytes
{
return Err("bootstrap to-worker queue is full");
}
queue.push_back((peer, bytes));
queue.push_back((
peer,
bytes,
std::time::Instant::now() + super::master::limits::values().queue_age,
));
Ok(())
}
pub fn take_to_worker(&self) -> Vec<(Option<master_protocol::MemberId>, Vec<u8>)> {
let mut queue = self.to_worker.lock().expect("bootstrap lane poisoned");
queue.drain(..).collect()
let entries: Vec<_> = queue.drain(..).collect();
if entries
.iter()
.any(|(_, _, deadline)| *deadline <= std::time::Instant::now())
{
*self.fault.lock().expect("bootstrap fault poisoned") =
Some("bootstrap queue deadline");
return Vec::new();
}
entries
.into_iter()
.map(|(peer, bytes, _)| (peer, bytes))
.collect()
}
pub fn push_from_worker(
@@ -309,16 +338,60 @@ impl BootstrapLane {
bytes: Vec<u8>,
) -> Result<(), &'static str> {
let mut queue = self.from_worker.lock().expect("bootstrap lane poisoned");
if queue.len() >= MAX_BOOTSTRAP_PENDING {
if queue.len() >= MAX_BOOTSTRAP_PENDING.min(super::master::limits::values().queue_items)
|| bytes.len() > master_protocol::MAX_BOOTSTRAP_STREAM_BYTES
|| queue
.iter()
.map(|(entry, _)| entry.bytes.len())
.sum::<usize>()
+ bytes.len()
> super::master::limits::values().queue_bytes
{
return Err("bootstrap from-worker queue is full");
}
queue.push_back(BootstrapIngress { member_id, bytes });
queue.push_back((
BootstrapIngress { member_id, bytes },
std::time::Instant::now() + super::master::limits::values().queue_age,
));
Ok(())
}
pub fn take_from_worker(&self) -> Vec<BootstrapIngress> {
let mut queue = self.from_worker.lock().expect("bootstrap lane poisoned");
queue.drain(..).collect()
let entries: Vec<_> = queue.drain(..).collect();
if entries
.iter()
.any(|(_, deadline)| *deadline <= std::time::Instant::now())
{
*self.fault.lock().expect("bootstrap fault poisoned") =
Some("bootstrap ingress queue deadline");
return Vec::new();
}
entries.into_iter().map(|(entry, _)| entry).collect()
}
pub fn check_deadline(&self) -> Result<(), &'static str> {
let now = std::time::Instant::now();
if self
.to_worker
.lock()
.expect("bootstrap lane poisoned")
.iter()
.any(|(_, _, deadline)| *deadline <= now)
|| self
.from_worker
.lock()
.expect("bootstrap lane poisoned")
.iter()
.any(|(_, deadline)| *deadline <= now)
{
*self.fault.lock().expect("bootstrap fault poisoned") =
Some("bootstrap queue deadline");
}
match *self.fault.lock().expect("bootstrap fault poisoned") {
Some(fault) => Err(fault),
None => Ok(()),
}
}
pub fn push_ack(&self, ack: BootstrapAck) -> Result<(), &'static str> {
+510 -87
View File
@@ -4,7 +4,7 @@ use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use std::time::{Duration, Instant};
use bevy::prelude::*;
use master_protocol::{
@@ -29,6 +29,7 @@ use crate::transport::fragment::{Fragmenter, Reassembler};
use crate::transport::protocol::{ContentFingerprint, HandshakeHello, MatchDescriptor};
use crate::transport::udp_session::{RelayMailbox, UdpAuthorityHub, UdpClientLink};
pub(crate) mod limits;
mod population;
pub use population::CommunityPlayerCounts;
@@ -59,16 +60,105 @@ pub fn content_names(flags: ContentFlags) -> String {
const RELAY_PACKET_BYTES: usize = crate::transport::protocol::MAX_PACKET_BYTES as usize;
const HOST_DATA_CAP: usize = 64;
const HOST_CONTROL_CAP: usize = 32;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MasterOperationOutcome {
Succeeded,
Refused(String),
RemoteOutcomeUnknown(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MasterOperationResult {
pub request_id: u64,
pub outcome: MasterOperationOutcome,
}
struct QueuedControl {
frame: ControlFrame,
deadline: tokio::time::Instant,
_bytes: tokio::sync::OwnedSemaphorePermit,
}
struct ControlSender {
tx: tokio::sync::mpsc::Sender<QueuedControl>,
pending: Mutex<HashMap<u64, tokio::time::Instant>>,
outcomes: Arc<Mutex<VecDeque<MasterOperationResult>>>,
bytes: Arc<tokio::sync::Semaphore>,
}
impl ControlSender {
fn complete_response(&self, response: &ControlResponse) -> Option<tokio::time::Instant> {
let deadline = self
.pending
.lock()
.expect("control requests poisoned")
.remove(&response.request_id)?;
let outcome = if deadline <= tokio::time::Instant::now() {
MasterOperationOutcome::RemoteOutcomeUnknown("response deadline".into())
} else if let ResponseBody::Error(error) = &response.body {
MasterOperationOutcome::Refused(error.to_string())
} else {
MasterOperationOutcome::Succeeded
};
self.outcomes
.lock()
.expect("control outcomes poisoned")
.push_back(MasterOperationResult {
request_id: response.request_id,
outcome,
});
Some(deadline)
}
fn close_pending(&self) {
let mut pending = self.pending.lock().expect("control requests poisoned");
let mut outcomes = self.outcomes.lock().expect("control outcomes poisoned");
outcomes.extend(
pending
.drain()
.map(|(request_id, _)| MasterOperationResult {
request_id,
outcome: MasterOperationOutcome::RemoteOutcomeUnknown(
"connection closed before correlated response".into(),
),
}),
);
}
fn expire(&self, role: &'static str) -> std::result::Result<(), TransportFault> {
let now = tokio::time::Instant::now();
let mut pending = self.pending.lock().expect("control requests poisoned");
if let Some(id) = pending
.iter()
.find_map(|(id, deadline)| (*deadline <= now).then_some(*id))
{
pending.remove(&id);
self.outcomes
.lock()
.expect("control outcomes poisoned")
.push_back(MasterOperationResult {
request_id: id,
outcome: MasterOperationOutcome::RemoteOutcomeUnknown(
"response deadline".into(),
),
});
return Err(TransportFault::new(
"control_response",
role,
format!("request {id} deadline; remote outcome unknown"),
));
}
Ok(())
}
}
const BOOTSTRAP_PRIORITY: i32 = -32;
const DNS_DEADLINE: Duration = Duration::from_secs(8);
const CONNECT_RETRY_FIRST: Duration = Duration::from_secs(1);
const CONNECT_RETRY_MAX: Duration = Duration::from_secs(6);
const CONNECT_RACE: usize = 4;
const CONNECT_BUDGET: Duration = SESSION_IDLE;
const CLIENT_KEEP_ALIVE: Duration = Duration::from_millis(250);
const LEAVE_DRAIN: Duration = Duration::from_millis(500);
const DATAGRAM_SEND_BUFFER: usize = 1024 * 1024;
const QUEUE_POLL: Duration = Duration::from_millis(5);
const WATCHDOG_INTERVAL: Duration = Duration::from_secs(1);
@@ -80,10 +170,12 @@ fn blocking_runtime() -> std::io::Result<tokio::runtime::Runtime> {
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
static START: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
START
.get_or_init(Instant::now)
.elapsed()
.as_millis()
.min(u64::MAX as u128) as u64
}
fn endpoint_build() -> String {
@@ -103,9 +195,52 @@ fn relay_reassembler() -> Reassembler {
Reassembler::new(master_protocol::MAX_OPAQUE_PAYLOAD, RELAY_PACKET_BYTES)
}
fn take_commands(commands: &Mutex<Vec<MasterBridgeCommand>>) -> Vec<MasterBridgeCommand> {
struct QueuedCommand {
command: MasterBridgeCommand,
deadline: tokio::time::Instant,
}
fn take_commands(
commands: &Mutex<Vec<QueuedCommand>>,
role: &'static str,
) -> std::result::Result<Vec<MasterBridgeCommand>, TransportFault> {
let mut queue = commands.lock().expect("master command queue poisoned");
std::mem::take(&mut *queue)
let commands = std::mem::take(&mut *queue);
if commands
.iter()
.any(|command| command.deadline <= tokio::time::Instant::now())
{
return Err(TransportFault::new(
"local_command",
role,
"command queue deadline",
));
}
Ok(commands
.into_iter()
.map(|command| command.command)
.collect())
}
async fn command_deadline(
commands: &Mutex<Vec<QueuedCommand>>,
role: &'static str,
) -> TransportFault {
loop {
if commands
.lock()
.expect("master command queue poisoned")
.iter()
.any(|command| command.deadline <= tokio::time::Instant::now())
{
return TransportFault::new(
"local_command",
role,
"command expired before connection; not sent",
);
}
tokio::time::sleep(QUEUE_POLL).await;
}
}
fn classify_try_send<T>(
@@ -485,7 +620,9 @@ enum MasterBridgeCommand {
MatchEnded {
match_key: frame::MatchKey,
},
AuthorityProgress,
AuthorityProgress {
now_ms: u64,
},
AdmitEnter {
member_id: MemberId,
epoch: u32,
@@ -496,6 +633,19 @@ enum MasterBridgeCommand {
Shutdown,
}
impl MasterBridgeCommand {
fn queued_bytes(&self) -> usize {
std::mem::size_of::<Self>()
+ match self {
Self::SetPassword { password } => password.capacity(),
Self::UpdateLobby { map, mode } | Self::StartMatch { map, mode } => {
map.capacity() + mode.capacity()
}
_ => 0,
}
}
}
#[derive(Resource, Default)]
pub struct PendingMasterMenuAction(pub Option<MasterMenuAction>);
@@ -623,7 +773,8 @@ impl MasterBridgeState {
#[derive(Resource)]
pub struct MasterBridge {
state: Arc<Mutex<MasterBridgeState>>,
commands: Arc<Mutex<Vec<MasterBridgeCommand>>>,
commands: Arc<Mutex<Vec<QueuedCommand>>>,
outcomes: Arc<Mutex<VecDeque<MasterOperationResult>>>,
installed_load: Arc<Mutex<Option<frame::LocalLoadKey>>>,
mailbox: RelayMailbox,
bootstrap: Arc<BootstrapLane>,
@@ -652,15 +803,52 @@ impl MasterBridge {
self.close.cancel();
}
pub fn take_operation_results(&self) -> Vec<MasterOperationResult> {
self.outcomes
.lock()
.expect("control outcomes poisoned")
.drain(..)
.collect()
}
pub fn mailbox(&self) -> RelayMailbox {
self.mailbox.clone()
}
fn send(&self, command: MasterBridgeCommand) {
self.commands
.lock()
.expect("master command queue poisoned")
.push(command);
let mut commands = self.commands.lock().expect("master command queue poisoned");
if matches!(command, MasterBridgeCommand::AuthorityProgress { .. })
&& let Some(queued) = commands.iter_mut().find(|queued| {
matches!(
queued.command,
MasterBridgeCommand::AuthorityProgress { .. }
)
})
{
queued.command = command;
return;
}
if commands.len() >= limits::values().queue_items
|| commands
.iter()
.map(|queued| queued.command.queued_bytes())
.sum::<usize>()
.saturating_add(command.queued_bytes())
> limits::values().queue_bytes
{
drop(commands);
publish_failed(
&self.state,
self.state().identity(),
TransportFault::new("local_command", "local", "command budget exhausted"),
);
self.close.cancel();
return;
}
commands.push(QueuedCommand {
command,
deadline: tokio::time::Instant::now() + limits::values().queue_age,
});
}
pub fn set_installed_load(&self, load: Option<frame::LocalLoadKey>) {
@@ -692,7 +880,7 @@ impl MasterBridge {
}
pub fn report_authority_progress(&self) {
self.send(MasterBridgeCommand::AuthorityProgress);
self.send(MasterBridgeCommand::AuthorityProgress { now_ms: now_ms() });
}
pub fn report_match_ended(&self, match_key: frame::MatchKey) {
@@ -736,7 +924,10 @@ impl MasterBridge {
impl Drop for MasterBridge {
fn drop(&mut self) {
if let Ok(mut queue) = self.commands.lock() {
queue.push(MasterBridgeCommand::Shutdown);
queue.push(QueuedCommand {
command: MasterBridgeCommand::Shutdown,
deadline: tokio::time::Instant::now() + limits::values().shutdown,
});
}
self.close.cancel();
let _ = self.worker.take();
@@ -1112,9 +1303,11 @@ async fn browse(
}),
)
.await?;
let response_deadline = tokio::time::Instant::now() + limits::values().response;
let (generation, adverts) = loop {
let frame = tokio::select! {
_ = cancel.cancelled() => return Ok(()),
_ = tokio::time::sleep_until(response_deadline) => return Err("room list response deadline".into()),
frame = read_frame(&mut recv) => frame?,
};
match frame {
@@ -1127,9 +1320,9 @@ async fn browse(
},
}) if id == request_id => break (generation, adverts),
ControlFrame::Response(ControlResponse {
request_id: id,
body: ResponseBody::Error(error),
..
}) => return Err(error.into()),
}) if id == request_id => return Err(error.into()),
ControlFrame::Response(_)
| ControlFrame::RoomView(_)
| ControlFrame::Closed { .. }
@@ -1188,7 +1381,8 @@ async fn wait_for_refresh(refresh: &AtomicU64, cancel: &CancellationToken) -> bo
struct WorkerCtx {
player_name: String,
state: Arc<Mutex<MasterBridgeState>>,
commands: Arc<Mutex<Vec<MasterBridgeCommand>>>,
commands: Arc<Mutex<Vec<QueuedCommand>>>,
outcomes: Arc<Mutex<VecDeque<MasterOperationResult>>>,
installed_load: Arc<Mutex<Option<frame::LocalLoadKey>>>,
mailbox: RelayMailbox,
bootstrap: Arc<BootstrapLane>,
@@ -1223,6 +1417,7 @@ fn spawn_worker(role: &'static str, kind: SessionKind, player_name: String) -> M
}
let state = Arc::new(Mutex::new(MasterBridgeState::Connecting { identity }));
let commands = Arc::new(Mutex::new(Vec::new()));
let outcomes = Arc::new(Mutex::new(VecDeque::new()));
let installed_load = Arc::new(Mutex::new(None));
let bootstrap = BootstrapLane::new();
let mailbox = RelayMailbox::new(HOST_DATA_CAP);
@@ -1234,6 +1429,7 @@ fn spawn_worker(role: &'static str, kind: SessionKind, player_name: String) -> M
player_name,
state: Arc::clone(&state),
commands: Arc::clone(&commands),
outcomes: Arc::clone(&outcomes),
installed_load: Arc::clone(&installed_load),
mailbox: mailbox.clone(),
bootstrap: Arc::clone(&bootstrap),
@@ -1257,6 +1453,7 @@ fn spawn_worker(role: &'static str, kind: SessionKind, player_name: String) -> M
MasterBridge {
state,
commands,
outcomes,
installed_load,
mailbox,
bootstrap,
@@ -1382,6 +1579,8 @@ fn report_committed_authority_progress(
bridge: Option<Res<MasterBridge>>,
server_tick: Option<Res<crate::authority::runtime::ServerTick>>,
hold: Option<Res<crate::AuthorityLoadHold>>,
installed: Option<Res<frame::InstalledMatch>>,
mut reported: Local<Option<(u64, frame::LocalLoadKey, sim::Tick)>>,
) {
let Some(bridge) = bridge else {
return;
@@ -1389,9 +1588,23 @@ fn report_committed_authority_progress(
if hold.is_some_and(|hold| hold.0) {
return;
}
if server_tick.is_some_and(|tick| tick.0.is_some()) {
bridge.report_authority_progress();
let (Some(installed), Some(tick)) = (
installed,
server_tick
.as_ref()
.and_then(|tick| tick.0.as_ref())
.map(|tick| tick.snapshot.tick),
) else {
return;
};
let context = (bridge.incarnation(), installed.load_key);
if reported.is_some_and(|(incarnation, key, previous)| {
(incarnation, key) == context && tick.0 <= previous.0
}) {
return;
}
*reported = Some((context.0, context.1, tick));
bridge.report_authority_progress();
}
fn open_hosted_epoch_when_world_is_live(
@@ -1449,6 +1662,14 @@ fn observe_master_bridge(
let Some(bridge) = bridge else {
return;
};
for result in bridge.take_operation_results() {
diag::info!(
Net,
"master operation: request={} outcome={:?}",
result.request_id,
result.outcome
);
}
let current = bridge.state();
let identity = current.identity();
if current.in_match()
@@ -1532,7 +1753,7 @@ fn execute_host_match_effects(
effects: Vec<HostMatchEffect>,
map_ready: &Mutex<HashSet<MemberId>>,
facts: &Mutex<Vec<MasterLifecycleFact>>,
control_tx: &tokio::sync::mpsc::Sender<ControlFrame>,
control_tx: &ControlSender,
request_id: &mut u64,
role: &'static str,
) -> std::result::Result<(), TransportFault> {
@@ -1633,6 +1854,7 @@ async fn session_main(
player_name,
state,
commands,
outcomes,
installed_load,
mailbox,
bootstrap,
@@ -1667,9 +1889,11 @@ async fn session_main(
target.server_name,
endpoint_build()
);
let (_endpoint, connection) = connect(&target, &cancel)
.await
.map_err(|error| TransportFault::new("connect", role, error.to_string()))?;
let (_endpoint, connection) = tokio::select! {
_ = close.cancelled() => return Ok(()),
fault = command_deadline(&commands, role) => return Err(fault),
result = connect(&target, &cancel) => result.map_err(|error| TransportFault::new("connect", role, error.to_string()))?,
};
diag::info!(
Net,
"master {role} hello build={} wire={} game={} attempt={}",
@@ -1697,7 +1921,14 @@ async fn session_main(
.await
.map_err(|error| TransportFault::with_connection("hello", role, error, &connection))?;
let (control_tx, mut control_rx) = tokio::sync::mpsc::channel::<ControlFrame>(HOST_CONTROL_CAP);
let (tx, mut control_rx) =
tokio::sync::mpsc::channel::<QueuedControl>(limits::values().queue_items);
let control_tx = ControlSender {
tx,
pending: Mutex::new(HashMap::new()),
outcomes,
bytes: Arc::new(tokio::sync::Semaphore::new(limits::values().queue_bytes)),
};
let mut children = tokio::task::JoinSet::new();
{
let writer_connection = connection.clone();
@@ -1710,7 +1941,10 @@ async fn session_main(
_ = writer_cancel.cancelled() => return Ok(()),
frame = control_rx.recv() => {
let Some(frame) = frame else { return Ok(()) };
write_frame(&mut writer, &frame).await.map_err(|error| {
tokio::time::timeout_at(frame.deadline, write_frame(&mut writer, &frame.frame))
.await
.map_err(|_| TransportFault::new("control_write", writer_role, "control queue/write deadline"))?
.map_err(|error| {
TransportFault::with_connection(
"control_write",
writer_role,
@@ -1743,10 +1977,12 @@ async fn session_main(
role,
is_host,
));
let (bootstrap_jobs, bootstrap_rx) =
tokio::sync::mpsc::channel::<(frame::MatchKey, MemberId, Vec<u8>)>(
MAX_CONCURRENT_BOOTSTRAP as usize,
);
let (bootstrap_tx, bootstrap_rx) =
tokio::sync::mpsc::channel::<BootstrapJob>(MAX_CONCURRENT_BOOTSTRAP as usize);
let bootstrap_jobs = BootstrapSender {
tx: bootstrap_tx,
bytes: Arc::new(tokio::sync::Semaphore::new(limits::values().queue_bytes)),
};
let (prepared_tx, mut prepared_rx) =
tokio::sync::mpsc::channel::<HostMatchEvent>(MAX_CONCURRENT_BOOTSTRAP as usize);
children.spawn(bootstrap_egress(
@@ -1806,6 +2042,9 @@ async fn session_main(
if cancel.is_cancelled() {
break Ok(());
}
if !closing && let Err(error) = control_tx.expire(role) {
break Err(error);
}
if !closing
&& let Err(error) = pump_local_queues(
identity.match_key(),
@@ -1820,6 +2059,9 @@ async fn session_main(
{
break Err(error);
}
if let Err(error) = mailbox.check_control_deadlines() {
break Err(TransportFault::new("relay_queue", role, error));
}
let mut control_error = None;
for (member_id, payload) in mailbox.take_control_outbound() {
let envelope = if is_host {
@@ -1853,7 +2095,11 @@ async fn session_main(
break Err(error);
}
let mut command_error = None;
for command in take_commands(&commands) {
let queued = match take_commands(&commands, role) {
Ok(commands) => commands,
Err(error) => break Err(error),
};
for command in queued {
match handle_command(
command,
&installed_load,
@@ -1875,7 +2121,7 @@ async fn session_main(
);
closing = true;
leave_request = Some(request_id);
leave_at = Some(tokio::time::Instant::now() + LEAVE_DRAIN);
leave_at = Some(tokio::time::Instant::now() + limits::values().shutdown);
}
Err(error) => {
command_error = Some(error);
@@ -1888,7 +2134,14 @@ async fn session_main(
}
tokio::select! {
_ = cancel.cancelled() => break Ok(()),
_ = close.cancelled(), if !closing => {}
_ = close.cancelled(), if !closing => {
let id = take_id(&mut request_id);
let body = if is_host { RequestBody::CloseRoom } else { RequestBody::LeaveRoom };
if let Err(error) = enqueue_control(&control_tx, ControlFrame::Request(ControlRequest { request_id: id, body }), role) { break Err(error); }
closing = true;
leave_request = Some(id);
leave_at = Some(tokio::time::Instant::now() + limits::values().shutdown);
}
_ = async {
match leave_at {
Some(at) => tokio::time::sleep_until(at).await,
@@ -1898,7 +2151,7 @@ async fn session_main(
diag::warn!(
Net,
"master {role} closed: relay did not confirm within {}ms",
LEAVE_DRAIN.as_millis()
limits::values().shutdown.as_millis()
);
break Ok(());
}
@@ -1938,6 +2191,9 @@ async fn session_main(
}
}
(recv, frame) = async {
while facts.lock().expect("master facts poisoned").len() >= limits::values().queue_items.max(master_protocol::MAX_SESSION_MEMBERS as usize + 1) {
tokio::time::sleep(QUEUE_POLL).await;
}
mailbox.wait_control_inbound_capacity().await;
(&mut next_control).await
} => {
@@ -2012,10 +2268,11 @@ async fn session_main(
}
};
control_tx.close_pending();
cancel.cancel();
connection.close(0_u8.into(), b"session closed");
drop(bootstrap_jobs);
let _ = tokio::time::timeout(LEAVE_DRAIN, async {
let _ = tokio::time::timeout(limits::values().shutdown, async {
while children.join_next().await.is_some() {}
})
.await;
@@ -2037,19 +2294,46 @@ fn take_id(request_id: &mut u64) -> u64 {
}
fn enqueue_control(
tx: &tokio::sync::mpsc::Sender<ControlFrame>,
tx: &ControlSender,
frame: ControlFrame,
role: &'static str,
) -> std::result::Result<(), TransportFault> {
classify_try_send(tx.try_send(frame), "control_write").map_err(|loss| {
TransportFault::new(
match loss {
QueueLoss::Full { operation } | QueueLoss::Closed { operation } => operation,
},
let encoded = encode_stream_frame(&frame)
.map_err(|error| TransportFault::new("control_encode", role, error.to_string()))?;
let bytes = tx
.bytes
.clone()
.try_acquire_many_owned(encoded.len() as u32)
.map_err(|_| TransportFault::new("control_write", role, "control byte budget exhausted"))?;
let deadline = tokio::time::Instant::now() + limits::values().queue_age;
let id = match &frame {
ControlFrame::Request(request) => Some(request.request_id),
_ => None,
};
let mut pending = tx.pending.lock().expect("control requests poisoned");
if id.is_some()
&& pending.len() + tx.outcomes.lock().expect("control outcomes poisoned").len()
>= limits::values().queue_items
{
return Err(TransportFault::new(
"control_request",
role,
loss.to_string(),
)
})
"pending request budget exhausted",
));
}
classify_try_send(
tx.tx.try_send(QueuedControl {
frame,
deadline,
_bytes: bytes,
}),
"control_write",
)
.map_err(|loss| TransportFault::new("control_write", role, loss.to_string()))?;
if let Some(id) = id {
pending.insert(id, tokio::time::Instant::now() + limits::values().response);
}
Ok(())
}
fn flatten_task(
@@ -2202,7 +2486,7 @@ fn sync_membership_facts(
fn handle_command(
command: MasterBridgeCommand,
installed_load: &Mutex<Option<frame::LocalLoadKey>>,
control_tx: &tokio::sync::mpsc::Sender<ControlFrame>,
control_tx: &ControlSender,
request_id: &mut u64,
facts: &Mutex<Vec<MasterLifecycleFact>>,
host_match: &mut HostMatchCore,
@@ -2369,14 +2653,11 @@ fn handle_command(
role,
)?;
}
MasterBridgeCommand::AuthorityProgress => {
MasterBridgeCommand::AuthorityProgress { now_ms } => {
if !is_host {
return Ok(CommandEffect::Continue);
}
let applied = apply_host(
host_match,
HostMatchEvent::AuthorityProgress { now_ms: now_ms() },
);
let applied = apply_host(host_match, HostMatchEvent::AuthorityProgress { now_ms });
execute_host_match_effects(
applied.effects,
map_ready,
@@ -2457,7 +2738,7 @@ fn handle_frame(
frame: ControlFrame,
state: &Mutex<MasterBridgeState>,
facts: &Mutex<Vec<MasterLifecycleFact>>,
control_tx: &tokio::sync::mpsc::Sender<ControlFrame>,
control_tx: &ControlSender,
request_id: &mut u64,
bootstrap: &BootstrapLane,
session: &mut SessionCore,
@@ -2484,6 +2765,20 @@ fn handle_frame(
"service sent a request on the client stream",
)),
ControlFrame::Response(response) => {
let deadline = control_tx.complete_response(&response);
let Some(deadline) = deadline else {
return Ok(());
};
if deadline <= tokio::time::Instant::now() {
return Err(TransportFault::new(
"control_response",
role,
format!(
"request {} deadline; remote outcome unknown",
response.request_id
),
));
}
let (view, member) = match response.body {
ResponseBody::RoomCreated { member_id, view }
| ResponseBody::RoomJoined { member_id, view } => (view, member_id),
@@ -2581,7 +2876,7 @@ fn apply_room_view(
local_member: MemberId,
state: &Mutex<MasterBridgeState>,
facts: &Mutex<Vec<MasterLifecycleFact>>,
control_tx: &tokio::sync::mpsc::Sender<ControlFrame>,
control_tx: &ControlSender,
request_id: &mut u64,
bootstrap: &BootstrapLane,
session: &mut SessionCore,
@@ -2631,6 +2926,29 @@ fn apply_room_view(
)?;
*started_epoch = view.epoch;
}
if is_host && view.phase.in_match() {
let applied = apply_host(
host_match,
HostMatchEvent::PeerRoster {
match_key: identity.match_key(),
members: view
.members
.iter()
.copied()
.filter(|member| *member != local_member)
.collect(),
now_ms: now_ms(),
},
);
execute_host_match_effects(
applied.effects,
map_ready,
facts,
control_tx,
request_id,
role,
)?;
}
if is_host && !view.phase.in_match() && *started_epoch != 0 {
let applied = apply_host(
host_match,
@@ -2665,7 +2983,7 @@ fn apply_room_view(
fn apply_peer_event(
event: master_protocol::PeerEvent,
facts: &Mutex<Vec<MasterLifecycleFact>>,
control_tx: &tokio::sync::mpsc::Sender<ControlFrame>,
control_tx: &ControlSender,
request_id: &mut u64,
session: &mut SessionCore,
host_match: &mut HostMatchCore,
@@ -2815,9 +3133,22 @@ fn apply_peer_event(
Ok(())
}
struct BootstrapSender {
tx: tokio::sync::mpsc::Sender<BootstrapJob>,
bytes: Arc<tokio::sync::Semaphore>,
}
struct BootstrapJob {
_bytes: tokio::sync::OwnedSemaphorePermit,
match_key: frame::MatchKey,
member: MemberId,
bytes: Vec<u8>,
deadline: tokio::time::Instant,
}
fn enqueue_bootstrap_job(
match_key: frame::MatchKey,
jobs: &tokio::sync::mpsc::Sender<(frame::MatchKey, MemberId, Vec<u8>)>,
jobs: &BootstrapSender,
member: MemberId,
bytes: Vec<u8>,
role: &'static str,
@@ -2826,7 +3157,24 @@ fn enqueue_bootstrap_job(
{
return Ok(());
}
classify_try_send(jobs.try_send((match_key, member, bytes)), "bootstrap_job").map_err(|loss| {
let permit = jobs
.bytes
.clone()
.try_acquire_many_owned(bytes.len() as u32)
.map_err(|_| {
TransportFault::new("bootstrap_job", role, "bootstrap byte budget exhausted")
})?;
classify_try_send(
jobs.tx.try_send(BootstrapJob {
_bytes: permit,
match_key,
member,
bytes,
deadline: tokio::time::Instant::now() + limits::values().queue_age,
}),
"bootstrap_job",
)
.map_err(|loss| {
TransportFault::new(
match loss {
QueueLoss::Full { operation } | QueueLoss::Closed { operation } => operation,
@@ -2840,13 +3188,16 @@ fn enqueue_bootstrap_job(
fn pump_local_queues(
match_key: frame::MatchKey,
bootstrap: &BootstrapLane,
control_tx: &tokio::sync::mpsc::Sender<ControlFrame>,
jobs: &tokio::sync::mpsc::Sender<(frame::MatchKey, MemberId, Vec<u8>)>,
control_tx: &ControlSender,
jobs: &BootstrapSender,
role: &'static str,
is_host: bool,
request_id: &mut u64,
map_ready: &Mutex<HashSet<MemberId>>,
) -> std::result::Result<(), TransportFault> {
bootstrap
.check_deadline()
.map_err(|error| TransportFault::new("bootstrap_queue", role, error))?;
for (peer, bytes) in bootstrap.take_to_worker() {
if is_host {
let Some(member) = peer else {
@@ -2904,7 +3255,7 @@ fn pump_local_queues(
async fn bootstrap_egress(
connection: quinn::Connection,
mut jobs: tokio::sync::mpsc::Receiver<(frame::MatchKey, MemberId, Vec<u8>)>,
mut jobs: tokio::sync::mpsc::Receiver<BootstrapJob>,
prepared: tokio::sync::mpsc::Sender<HostMatchEvent>,
cancel: CancellationToken,
role: &'static str,
@@ -2913,9 +3264,10 @@ async fn bootstrap_egress(
tokio::select! {
_ = cancel.cancelled() => return Ok(()),
job = jobs.recv() => {
let Some((match_key, member, bytes)) = job else {
let Some(BootstrapJob { match_key, member, bytes, deadline, _bytes }) = job else {
return Ok(());
};
if deadline <= tokio::time::Instant::now() { return Err(TransportFault::new("bootstrap_job", role, "bootstrap queue deadline")); }
send_bootstrap_stream(&connection, member, &bytes, &cancel, role).await?;
if let Ok(Some(BootstrapMessage::Offer {
bootstrap_id,
@@ -2956,7 +3308,7 @@ async fn datagram_ingress(
role: &'static str,
is_host: bool,
) -> std::result::Result<(), TransportFault> {
let mut reassemblers: HashMap<MemberId, Reassembler> = HashMap::new();
let mut reassemblers: HashMap<MemberId, (Reassembler, Instant)> = HashMap::new();
loop {
tokio::select! {
_ = cancel.cancelled() => return Ok(()),
@@ -2991,7 +3343,15 @@ async fn datagram_ingress(
continue;
}
};
let reassembler = reassemblers.entry(member).or_insert_with(relay_reassembler);
let now = Instant::now();
reassemblers.retain(|_, (_, seen)| now.duration_since(*seen) < limits::values().queue_age);
if !reassemblers.contains_key(&member)
&& reassemblers.len() >= master_protocol::MAX_SESSION_MEMBERS as usize
{
continue;
}
let (reassembler, seen) = reassemblers.entry(member).or_insert_with(|| (relay_reassembler(), now));
*seen = now;
match reassembler.push(payload) {
Ok(Some(packet)) => {
if mailbox.push_inbound(member, packet) {
@@ -3031,7 +3391,12 @@ async fn uni_ingress(
};
let bytes = tokio::select! {
_ = cancel.cancelled() => return Ok(()),
read = recv.read_to_end(MAX_BOOTSTRAP_STREAM_BYTES) => match read {
read = tokio::time::timeout(limits::values().transfer, read_transfer(&mut recv)) => match read {
Err(_) => {
let _ = recv.stop(0_u8.into());
return Err(TransportFault::new("bootstrap_uni_read", role, "bootstrap transfer deadline"));
}
Ok(read) => match read {
Ok(bytes) => bytes,
Err(error) => {
return Err(TransportFault::with_connection(
@@ -3041,6 +3406,7 @@ async fn uni_ingress(
&connection,
));
}
},
},
};
match decode_relay_stream(&bytes) {
@@ -3081,7 +3447,7 @@ async fn gameplay_egress(
let mut fragmenter = relay_fragmenter();
// Quinn reserves bookkeeping space even for an empty datagram buffer.
let empty_buffer_space = connection.datagram_send_buffer_space();
let mut pending: VecDeque<(MemberId, Vec<u8>)> = VecDeque::new();
let mut pending: VecDeque<(MemberId, Vec<u8>, tokio::time::Instant)> = VecDeque::new();
let mut tick = tokio::time::interval(QUEUE_POLL);
let mut sampled = tokio::time::Instant::now();
loop {
@@ -3104,13 +3470,19 @@ async fn gameplay_egress(
// Gameplay packets carry the newest input/snapshot plus
// unacknowledged reliable rows. Replace unsent older state
// for this peer rather than building a latency backlog.
if let Some((_, previous)) = pending.iter_mut().find(|(id, _)| *id == member) {
if let Some((_, previous, _)) = pending.iter_mut().find(|(id, _, _)| *id == member) {
*previous = packet;
} else {
pending.push_back((member, packet));
if pending.len() >= master_protocol::MAX_SESSION_MEMBERS as usize {
return Err(TransportFault::new("send_datagram", role, "pending peer budget exhausted"));
}
pending.push_back((member, packet, tokio::time::Instant::now() + limits::values().queue_age));
}
}
while let Some((member, packet)) = pending.front() {
while let Some((member, packet, deadline)) = pending.front() {
if *deadline <= tokio::time::Instant::now() {
return Err(TransportFault::new("send_datagram", role, "gameplay queue deadline"));
}
let fragments = match fragmenter.split(packet) {
Ok(fragments) => fragments,
Err(error) => {
@@ -3200,14 +3572,56 @@ async fn send_bootstrap_stream(
send.set_priority(BOOTSTRAP_PRIORITY).map_err(|error| {
TransportFault::with_connection("bootstrap_priority", role, error, connection)
})?;
cancellable(cancel, send.write_all(&bytes))
let result = tokio::select! {
_ = cancel.cancelled() => Err("session canceled".into()),
result = tokio::time::timeout(limits::values().transfer, write_transfer(&mut send, &bytes)) =>
result.map_err(|_| Error::from("transfer total deadline")).and_then(|result| result),
};
if result.is_err() {
let _ = send.reset(0_u8.into());
}
result.map_err(|error| {
TransportFault::with_connection("bootstrap_uni_write", role, error, connection)
})
}
async fn read_transfer(recv: &mut quinn::RecvStream) -> Result<Vec<u8>> {
let mut bytes = Vec::new();
loop {
let chunk =
tokio::time::timeout(limits::values().progress, recv.read_chunk(16 * 1024, true))
.await
.map_err(|_| "transfer progress deadline")??;
let Some(chunk) = chunk else {
return Ok(bytes);
};
if chunk.bytes.is_empty() || bytes.len() + chunk.bytes.len() > MAX_BOOTSTRAP_STREAM_BYTES {
return Err("transfer byte budget exceeded".into());
}
bytes.extend_from_slice(&chunk.bytes);
}
}
async fn write_transfer(send: &mut quinn::SendStream, bytes: &[u8]) -> Result<()> {
let mut offset = 0;
while offset < bytes.len() {
let end = (offset + 16 * 1024).min(bytes.len());
let written =
tokio::time::timeout(limits::values().progress, send.write(&bytes[offset..end]))
.await
.map_err(|_| "transfer progress deadline")??;
if written == 0 {
return Err("transfer made no progress".into());
}
offset += written;
}
send.finish()?;
if let Some(code) = tokio::time::timeout(limits::values().progress, send.stopped())
.await
.map_err(|error| {
TransportFault::with_connection("bootstrap_uni_write", role, error, connection)
})?;
send.finish().map_err(|error| {
TransportFault::with_connection("bootstrap_uni_finish", role, error, connection)
})?;
.map_err(|_| "transfer acknowledgement deadline")??
{
return Err(format!("transfer refused with code {code}").into());
}
Ok(())
}
@@ -3228,7 +3642,12 @@ async fn read_frame(recv: &mut quinn::RecvStream) -> Result<ControlFrame> {
}
async fn write_frame(send: &mut quinn::SendStream, frame: &ControlFrame) -> Result<()> {
send.write_all(&encode_stream_frame(frame)?).await?;
tokio::time::timeout(
limits::values().response,
send.write_all(&encode_stream_frame(frame)?),
)
.await
.map_err(|_| "control write deadline")??;
Ok(())
}
@@ -3238,7 +3657,9 @@ async fn cancellable<T>(
) -> Result<T> {
tokio::select! {
_ = cancel.cancelled() => Err("session cancelled".into()),
result = fut => result.map_err(Into::into),
result = tokio::time::timeout(limits::values().response, fut) => {
result.map_err(|_| "operation deadline")?.map_err(Into::into)
},
}
}
@@ -3271,9 +3692,6 @@ fn client_config(target: &MasterTarget) -> Result<quinn::ClientConfig> {
transport.keep_alive_interval(Some(CLIENT_KEEP_ALIVE));
transport.datagram_send_buffer_size(DATAGRAM_SEND_BUFFER);
transport.max_concurrent_uni_streams(MAX_RELAY_UNI_STREAMS.into());
transport.congestion_controller_factory(Arc::new(
master_protocol::congestion::StallTolerantConfig::default(),
));
client_config.transport_config(Arc::new(transport));
Ok(client_config)
}
@@ -3282,10 +3700,17 @@ async fn connect(
target: &MasterTarget,
cancel: &CancellationToken,
) -> Result<(quinn::Endpoint, quinn::Connection)> {
let address = dns_timeout(cancel, tokio::net::lookup_host(target.address.as_str()))
.await?
.next()
.ok_or_else(|| format!("{} resolved no addresses", target.address))?;
limits::configured()?;
let started = Instant::now();
let deadline = tokio::time::Instant::now() + limits::values().connect;
let address = tokio::time::timeout_at(
deadline,
dns_timeout(cancel, tokio::net::lookup_host(target.address.as_str())),
)
.await
.map_err(|_| "connection deadline during address lookup")??
.next()
.ok_or_else(|| format!("{} resolved no addresses", target.address))?;
let client_config = client_config(target)?;
let bind: std::net::SocketAddr = if address.is_ipv4() {
"0.0.0.0:0"
@@ -3293,8 +3718,6 @@ async fn connect(
"[::]:0"
}
.parse()?;
let started = Instant::now();
let deadline = tokio::time::Instant::now() + CONNECT_BUDGET;
let mut attempts = tokio::task::JoinSet::new();
let mut in_flight = VecDeque::new();
let mut next_attempt = tokio::time::Instant::now();
+21
View File
@@ -0,0 +1,21 @@
use master_protocol::budget::TransportBudget;
use std::sync::OnceLock;
pub(crate) fn configured() -> Result<TransportBudget, String> {
static BUDGET: OnceLock<Result<TransportBudget, String>> = OnceLock::new();
BUDGET
.get_or_init(|| {
TransportBudget::from_settings(TransportBudget::SETTINGS.into_iter().filter_map(
|name| {
std::env::var(name)
.ok()
.map(|value| (name.to_owned(), value))
},
))
})
.clone()
}
pub(crate) fn values() -> TransportBudget {
configured().unwrap_or_default()
}
+39
View File
@@ -2180,12 +2180,17 @@ fn phase_from_tag(tag: u8) -> Result<MatchPhase, WireError> {
fn encode_corpse_pool(out: &mut WireWriter, pool: &PlayerCorpsePool) {
out.put_u8(pool.spawn_ring);
out.put_u64(pool.next_spawn_sequence);
for slot in &pool.slots {
if !slot.occupied {
out.put_u8(0);
continue;
}
out.put_u8(1);
out.put_u64(slot.spawn_sequence);
out.put_u8(slot.weapon_model);
out.put_i32(slot.client_state_team);
out.put_u8(slot.ffa_team.unwrap_or(u8::MAX));
out.put_u32(slot.victim.0);
for v in slot.origin {
out.put_f32(v);
@@ -2218,8 +2223,13 @@ fn encode_corpse_pool(out: &mut WireWriter, pool: &PlayerCorpsePool) {
fn decode_corpse_pool(input: &mut WireReader<'_>) -> Result<PlayerCorpsePool, WireError> {
let spawn_ring = input.get_u8()?;
let next_spawn_sequence = input.get_u64()?;
if spawn_ring >= playerstate_iw4::MAX_CLIENT_CORPSES as u8 {
return Err(WireError::Malformed("corpse ring outside slots"));
}
let mut pool = PlayerCorpsePool {
spawn_ring,
next_spawn_sequence,
..PlayerCorpsePool::default()
};
for slot in &mut pool.slots {
@@ -2228,6 +2238,13 @@ fn decode_corpse_pool(input: &mut WireReader<'_>) -> Result<PlayerCorpsePool, Wi
1 => {
*slot = PlayerCorpseSlot {
occupied: true,
spawn_sequence: input.get_u64()?,
weapon_model: input.get_u8()?,
client_state_team: input.get_i32()?,
ffa_team: {
let team = input.get_u8()?;
(team != u8::MAX).then_some(team)
},
victim: ClientId(input.get_u32()?),
origin: [input.get_f32()?, input.get_f32()?, input.get_f32()?],
viewangles: [input.get_f32()?, input.get_f32()?, input.get_f32()?],
@@ -2249,10 +2266,32 @@ fn decode_corpse_pool(input: &mut WireReader<'_>) -> Result<PlayerCorpsePool, Wi
spawn_time_ms: input.get_i32()?,
shield: decode_corpse_shield(input)?,
};
if slot.spawn_sequence == 0 || slot.spawn_sequence > next_spawn_sequence {
return Err(WireError::Malformed("invalid corpse spawn sequence"));
}
}
_ => return Err(WireError::Malformed("bad corpse slot tag")),
}
}
let mut identities = std::collections::HashSet::new();
let mut entities = std::collections::HashSet::new();
for slot in pool.slots.iter().filter(|slot| slot.occupied) {
if !identities.insert(slot.spawn_sequence)
|| !entities.insert(slot.entnum)
|| slot.entnum < playerstate_iw4::PLAYER_CORPSE_ENTITY_BASE
|| slot.entnum
>= playerstate_iw4::PLAYER_CORPSE_ENTITY_BASE + playerstate_iw4::MAX_CLIENT_CORPSES
|| slot
.origin
.iter()
.chain(slot.viewangles.iter())
.chain(slot.tr_base.iter())
.chain(slot.tr_delta.iter())
.any(|n| !n.is_finite())
{
return Err(WireError::Malformed("invalid corpse occupancy"));
}
}
Ok(pool)
}
+66 -13
View File
@@ -36,9 +36,9 @@ pub(crate) const CMDS_PER_PACKET: usize = 16;
pub struct RelayMailbox {
inbound: Arc<Mutex<DataLanes>>,
outbound: Arc<Mutex<DataLanes>>,
control_inbound: Arc<Mutex<Vec<(MemberId, Vec<u8>)>>>,
control_inbound: Arc<Mutex<Vec<(MemberId, Vec<u8>, std::time::Instant)>>>,
control_inbound_drained: Arc<tokio::sync::Notify>,
control_outbound: Arc<Mutex<Vec<(MemberId, Vec<u8>)>>>,
control_outbound: Arc<Mutex<Vec<(MemberId, Vec<u8>, std::time::Instant)>>>,
cap: usize,
}
@@ -95,6 +95,21 @@ impl RelayMailbox {
take_mail(&self.control_outbound)
}
pub(crate) fn check_control_deadlines(&self) -> Result<(), &'static str> {
let now = std::time::Instant::now();
for queue in [&self.control_inbound, &self.control_outbound] {
if queue
.lock()
.expect("relay mailbox poisoned")
.iter()
.any(|(_, _, deadline)| *deadline <= now)
{
return Err("reliable gameplay queue deadline");
}
}
Ok(())
}
pub fn with_default_cap() -> Self {
Self::new(RELAY_MAIL_CAP)
}
@@ -118,24 +133,47 @@ impl RelayMailbox {
#[derive(Debug, Default)]
struct DataLanes {
lanes: BTreeMap<MemberId, VecDeque<Vec<u8>>>,
lanes: BTreeMap<MemberId, VecDeque<(Vec<u8>, std::time::Instant)>>,
bytes: usize,
}
impl DataLanes {
fn push(&mut self, cap: usize, member: MemberId, bytes: Vec<u8>) -> bool {
let lane = self.lanes.entry(member).or_default();
let dropped = lane.len() >= cap;
if dropped {
lane.pop_front();
if bytes.len() > crate::transport::protocol::MAX_PACKET_BYTES as usize
|| (!self.lanes.contains_key(&member)
&& self.lanes.len() >= master_protocol::MAX_SESSION_MEMBERS as usize)
{
return true;
}
lane.push_back(bytes);
let lane = self.lanes.entry(member).or_default();
let mut dropped = false;
while !lane.is_empty()
&& (lane.len() >= cap
|| self.bytes + bytes.len() > super::master::limits::values().queue_bytes)
{
self.bytes -= lane.pop_front().expect("nonempty lane").0.len();
dropped = true;
}
if self.bytes + bytes.len() > super::master::limits::values().queue_bytes || cap == 0 {
return true;
}
self.bytes += bytes.len();
lane.push_back((
bytes,
std::time::Instant::now() + super::master::limits::values().queue_age,
));
dropped
}
fn take(&mut self) -> Vec<(MemberId, Vec<u8>)> {
let mut out = Vec::new();
self.bytes = 0;
for (member, lane) in std::mem::take(&mut self.lanes) {
out.extend(lane.into_iter().map(|bytes| (member, bytes)));
out.extend(
lane.into_iter()
.filter(|(_, deadline)| *deadline > std::time::Instant::now())
.map(|(bytes, _)| (member, bytes)),
);
}
out
}
@@ -146,21 +184,36 @@ fn lock_lanes(lanes: &Mutex<DataLanes>) -> std::sync::MutexGuard<'_, DataLanes>
}
fn push_mail(
queue: &Mutex<Vec<(MemberId, Vec<u8>)>>,
queue: &Mutex<Vec<(MemberId, Vec<u8>, std::time::Instant)>>,
cap: usize,
member: MemberId,
bytes: Vec<u8>,
) -> Result<(), &'static str> {
if bytes.len() > crate::transport::protocol::MAX_PACKET_BYTES as usize {
return Err("relay packet exceeds byte budget");
}
let mut queue = queue.lock().expect("relay mailbox poisoned");
if queue.len() >= cap {
if queue.len() >= cap
|| queue.iter().map(|(_, bytes, _)| bytes.len()).sum::<usize>() + bytes.len()
> super::master::limits::values().queue_bytes
{
return Err("relay mailbox full");
}
queue.push((member, bytes));
queue.push((
member,
bytes,
std::time::Instant::now() + super::master::limits::values().queue_age,
));
Ok(())
}
fn take_mail(queue: &Mutex<Vec<(MemberId, Vec<u8>)>>) -> Vec<(MemberId, Vec<u8>)> {
fn take_mail(
queue: &Mutex<Vec<(MemberId, Vec<u8>, std::time::Instant)>>,
) -> Vec<(MemberId, Vec<u8>)> {
std::mem::take(&mut *queue.lock().expect("relay mailbox poisoned"))
.into_iter()
.map(|(member, bytes, _)| (member, bytes))
.collect()
}
fn relay_send_error(message: &'static str) -> UdpSendError {
@@ -90,15 +90,13 @@ pub fn skip_frozen_corpse_dobj(
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CorpseOccupationKey {
pub spawn_time_ms: i32,
pub victim: i32,
pub identity: net::CorpseIdentity,
}
pub fn corpse_occupation_key(es: &entity_iw4::EntityState) -> CorpseOccupationKey {
CorpseOccupationKey {
spawn_time_ms: es.time2,
victim: es.client_num,
}
pub fn corpse_occupation_key(entity: &net::CEntity) -> Option<CorpseOccupationKey> {
entity
.corpse_identity()
.map(|identity| CorpseOccupationKey { identity })
}
pub fn fresh_corpse_clip_time(age_ms: i32, clip_length_ms: i32) -> f32 {
@@ -18,9 +18,8 @@ const MAX_ITERS: i32 = 2;
const CONTENTS_SOLID: u32 = 1;
const CONTENTS_ITEMCLIP: u32 = 0x400;
const WAKE_MASKS: [u32; 2] = [CONTENTS_SOLID | CONTENTS_ITEMCLIP, CONTENTS_SOLID];
const COLLISION_MASK: u32 = CONTENTS_SOLID | CONTENTS_ITEMCLIP;
const WAKE_LIFTS: [f32; 6] = [0.0, CONTACT_SLOP, 0.25, 0.5, 1.0, 2.0];
const WAKE_FOOTPRINTS: [f32; 3] = [1.0, 0.75, 0.5];
const SLEEP_SPEED: f32 = 4.0;
const GROUND_NZ: f32 = 0.7;
const CONTACT_SLOP: f32 = 0.125;
@@ -36,6 +35,11 @@ pub struct DynEntPhysWorld {
}
impl DynEntPhysWorld {
pub fn reset_presentation(&mut self) {
self.bodies.clear();
self.last_time = None;
}
pub fn is_awake(&self, entity: Entity) -> bool {
self.bodies.contains_key(&entity)
}
@@ -105,30 +109,33 @@ pub fn step_phys_world0(
let (mins, maxs) = xmodel_phys_hull(catalog, &inst.current_model);
let add_vel = impulse.impulse / preset.mass;
let phys_clip = clip.0.as_deref();
world
.bodies
.entry(entity)
.and_modify(|body| {
body.vel += add_vel;
body.asleep_ms = 0;
})
.or_insert_with(|| {
let pose = phys_clip
.and_then(|clip| clear_wake_pose(clip, transform.translation, mins, maxs));
let (origin, mins, maxs, mask) =
pose.unwrap_or((transform.translation, mins, maxs, CONTENTS_SOLID));
DynEntPhysBody {
origin,
vel: add_vel,
mins,
maxs,
bounce: preset.bounce,
friction: preset.friction,
mask,
on_ground: false,
asleep_ms: 0,
}
});
if let Some(body) = world.bodies.get_mut(&entity) {
body.vel += add_vel;
body.asleep_ms = 0;
continue;
}
let Some(clip) = phys_clip else {
continue;
};
let RecoveryOutcome::Recovered(origin) =
clear_wake_pose(clip, transform.translation, mins, maxs, COLLISION_MASK)
else {
continue;
};
world.bodies.insert(
entity,
DynEntPhysBody {
origin,
vel: add_vel,
mins,
maxs,
bounce: preset.bounce,
friction: preset.friction,
mask: COLLISION_MASK,
on_ground: false,
asleep_ms: 0,
},
);
}
let now = clock
@@ -198,37 +205,31 @@ pub fn step_phys_world0(
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum RecoveryOutcome {
Recovered(Vec3),
Blocked,
Incomplete,
}
fn clear_wake_pose(
clip: &ClipCollision,
origin: Vec3,
mins: [f32; 3],
maxs: [f32; 3],
) -> Option<(Vec3, [f32; 3], [f32; 3], u32)> {
for mask in WAKE_MASKS {
for scale in WAKE_FOOTPRINTS {
let (mins, maxs) = narrowed_footprint(mins, maxs, scale);
for lift in WAKE_LIFTS {
let at = (origin + Vec3::Z * lift).to_array();
let probe = clip.sweep_box(at, at, mins, maxs, mask);
if !probe.startsolid && !probe.allsolid {
return Some((Vec3::from_array(at), mins, maxs, mask));
}
}
mask: u32,
) -> RecoveryOutcome {
if clip.coverage != asset_world::CollisionCoverage::Complete {
return RecoveryOutcome::Incomplete;
}
for lift in WAKE_LIFTS {
let at = origin + Vec3::Z * lift;
let probe = clip.sweep_box(at.to_array(), at.to_array(), mins, maxs, mask);
if !probe.startsolid && !probe.allsolid {
return RecoveryOutcome::Recovered(at);
}
}
None
}
fn narrowed_footprint(mins: [f32; 3], maxs: [f32; 3], scale: f32) -> ([f32; 3], [f32; 3]) {
let mut lo = mins;
let mut hi = maxs;
for axis in 0..2 {
let mid = (mins[axis] + maxs[axis]) * 0.5;
let half = (maxs[axis] - mins[axis]) * 0.5 * scale;
lo[axis] = mid - half;
hi[axis] = mid + half;
}
(lo, hi)
RecoveryOutcome::Blocked
}
fn integrate_free(body: &mut DynEntPhysBody, dt: f32) {
@@ -238,6 +239,12 @@ fn integrate_free(body: &mut DynEntPhysBody, dt: f32) {
}
fn integrate_body(body: &mut DynEntPhysBody, clip: &ClipCollision, dt: f32) {
if clip.coverage != asset_world::CollisionCoverage::Complete {
body.vel = Vec3::ZERO;
body.on_ground = false;
body.asleep_ms = 0;
return;
}
body.vel.z += GRAVITY_Z * dt;
let start = body.origin;
let end = start + body.vel * dt;
@@ -250,7 +257,8 @@ fn integrate_body(body: &mut DynEntPhysBody, clip: &ClipCollision, dt: f32) {
);
if hit.allsolid || hit.startsolid {
body.vel = Vec3::ZERO;
body.on_ground = true;
body.on_ground = false;
body.asleep_ms = 0;
return;
}
if hit.fraction < 1.0 {
@@ -597,12 +597,15 @@ pub fn tick_fpv_viewmodel(
product.kind = FpvPoseKind::Hide;
return;
}
if presented_is_third_person(
// Outside the killcam the hidden viewmodel keeps animating: its notetracks
// are the player's reload and handling sounds.
let hidden = presented_is_third_person(
&presented,
local.0,
view.in_killcam(),
settings.third_person,
) {
);
if hidden && view.in_killcam() {
product.kind = FpvPoseKind::Hide;
return;
}
@@ -841,6 +844,10 @@ pub fn tick_fpv_viewmodel(
snapshot.view_tick(local.0),
));
}
if hidden {
product.kind = FpvPoseKind::Hide;
return;
}
if let FpvPoseKind::Posed(frame) = &mut kind {
// Nothing downstream of the bones waits for a vertex.
if let Some(bolt) = frame.secondary_bolt.take() {
+1 -11
View File
@@ -20,7 +20,6 @@ pub struct KillcamCamera {
stop: Option<(i32, f32, Vec3, Vec3)>,
entered_at: i32,
last_time: i32,
last_replay_tick: Option<u32>,
last_pose: Option<WorldCameraPose>,
blend_until: i32,
}
@@ -182,18 +181,9 @@ impl KillcamCamera {
clip: Option<&ClipCollision>,
) -> Option<(WorldCameraPose, f32, Option<f32>)> {
let ps = presented.player(viewer)?;
let replay_tick = presented
.snapshot()
.map(|snapshot| snapshot.view_tick(viewer).0);
if !in_killcam
|| now < self.last_time
|| replay_tick
.zip(self.last_replay_tick)
.is_some_and(|(next, old)| next < old)
{
if !in_killcam {
*self = Self::default();
}
self.last_replay_tick = replay_tick;
let previous_time = self.last_time;
self.last_time = now;
if !in_killcam {
@@ -91,6 +91,7 @@ struct RemoteBodyLightingBinds {
#[derive(Hash, PartialEq, Eq)]
struct BodySkinShapeKey {
world_weapon: bool,
product_revision: u64,
namespace: asset_core::AssetNamespace,
name: String,
lod: u8,
@@ -109,9 +110,9 @@ impl BodySkinShapes {
let same_bodies = self
.bodies
.as_ref()
.is_some_and(|old| Arc::ptr_eq(old, &bodies.0));
.is_some_and(|old| old.identity() != 0 && old.identity() == bodies.0.identity());
let same_weapons = match (&self.weapons, weapons) {
(Some(old), Some(new)) => Arc::ptr_eq(old, &new.0),
(Some(old), Some(new)) => old.identity() != 0 && old.identity() == new.0.identity(),
(None, None) => true,
_ => false,
};
@@ -134,8 +135,16 @@ impl BodySkinShapes {
lod: u8,
layout: &crate::anim::xmodel_pose::SkinLayout,
) -> Arc<render_frame::skinning::PackedSkinShape> {
let product_revision = if world_weapon {
self.weapons
.as_ref()
.map_or(0, |weapons| weapons.identity())
} else {
self.bodies.as_ref().map_or(0, |bodies| bodies.identity())
};
let key = BodySkinShapeKey {
world_weapon,
product_revision,
namespace,
name: skel.name.clone(),
lod,
@@ -382,8 +391,22 @@ fn occupy_remote_scene_ents(
let meta = presented
.snapshot()
.and_then(|snap| snap.meta.for_client(client));
let ffa_team = meta.and_then(|m| m.ffa_team);
let client_state_team = meta.map(|m| m.client_state_team).unwrap_or(0);
let corpse = presented.snapshot().and_then(|snapshot| {
snapshot
.meta
.corpses
.slots
.iter()
.find(|slot| slot.occupied && slot.entnum == i32::from(identity.number()))
});
let ffa_team = corpse.map_or_else(
|| meta.and_then(|meta| meta.ffa_team),
|corpse| corpse.ffa_team,
);
let client_state_team = corpse.map_or_else(
|| meta.map_or(0, |meta| meta.client_state_team),
|corpse| corpse.client_state_team,
);
let axis = asset_model::kit_assignment_is_axis(client_state_team, ffa_team);
let Ok(soldier) = soldiers.side(axis) else {
continue;
@@ -524,8 +547,22 @@ fn sync_remote_bodies(
let meta = presented
.snapshot()
.and_then(|snap| snap.meta.for_client(client));
let ffa_team = meta.and_then(|m| m.ffa_team);
let client_state_team = meta.map(|m| m.client_state_team).unwrap_or(0);
let corpse = presented.snapshot().and_then(|snapshot| {
snapshot
.meta
.corpses
.slots
.iter()
.find(|slot| slot.occupied && slot.entnum == i32::from(identity.number()))
});
let ffa_team = corpse.map_or_else(
|| meta.and_then(|meta| meta.ffa_team),
|corpse| corpse.ffa_team,
);
let client_state_team = corpse.map_or_else(
|| meta.map_or(0, |meta| meta.client_state_team),
|corpse| corpse.client_state_team,
);
let Ok(soldier) = soldiers.side(asset_model::kit_assignment_is_axis(
client_state_team,
ffa_team,
@@ -879,13 +916,13 @@ impl<'a> RemotePoseFrame<'a> {
let persist_key = u32::from(identity.number());
let is_corpse = runtime.next_state.e_type == ET_PLAYER_CORPSE
|| runtime.pose_e_type == ET_PLAYER_CORPSE as u8;
let occupation_key = is_corpse.then(|| corpse_occupation_key(&runtime.next_state));
let occupation_key = is_corpse.then(|| corpse_occupation_key(identity)).flatten();
if let Some(key) = occupation_key {
let victim = u32::try_from(runtime.next_state.client_num).unwrap_or(0);
clone_corpse_tree_from_victim(self.trees, persist_key, victim, key);
}
let corpse_age_ms =
occupation_key.map(|key| runtime.pose_time_ms.wrapping_sub(key.spawn_time_ms));
occupation_key.map(|_| runtime.pose_time_ms.wrapping_sub(runtime.next_state.time2));
if runtime.previous_pose.is_none() {
if let Some(slot) = self.trees.get_mut(persist_key) {
@@ -71,7 +71,6 @@ const TURN_SAMPLES: usize = 4;
#[derive(Default)]
struct MissileTurn {
missile: Option<(LifeSequence, ProjectileId)>,
archived: bool,
samples: Vec<(i32, [f32; 3])>,
}
@@ -79,17 +78,11 @@ impl MissileTurn {
fn record(
&mut self,
missile: (LifeSequence, ProjectileId),
archived: bool,
snapshot_ms: i32,
angles: [f32; 3],
) {
// The final killcam of the pilot's own kill replays the same missile
// of the same life from seat snapshots stamped with the live tick,
// later than every sample of the flight, so a switch between live and
// archived presentation starts a fresh history.
if self.missile != Some(missile) || self.archived != archived {
if self.missile != Some(missile) {
self.missile = Some(missile);
self.archived = archived;
self.samples.clear();
}
match self.samples.last_mut() {
@@ -153,12 +146,8 @@ impl RemoteMissileView {
.flatten();
let snapshot_ms = presented.tick().map_or(at_time, sim::level_time_ms);
if let Some(missile) = flying {
self.turn.record(
(life, link.projectile),
presented.is_archived_replay(),
snapshot_ms,
link.angles,
);
self.turn
.record((life, link.projectile), snapshot_ms, link.angles);
let pose = WorldCameraPose {
origin: missile_eye(missile, snapshot_ms, at_time, clip),
angles: self.turn.angles_at(at_time).unwrap_or(link.angles),

Some files were not shown because too many files have changed in this diff Show More