mirror of
https://github.com/vladtrc/iw4L
synced 2026-10-25 09:59:40 -04:00
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:
Generated
+2
-3
@@ -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]]
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)?,
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>) {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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
@@ -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>) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user