diff --git a/crates/engine/src/player.rs b/crates/engine/src/player.rs index a55b92b..757f3ce 100644 --- a/crates/engine/src/player.rs +++ b/crates/engine/src/player.rs @@ -4376,6 +4376,263 @@ mod tests { ); } + /// Обход всей карты в поисках «невидимых стен»: кирпич блокирует шаг, + /// но не достаёт до тела. + /// + /// Старая сетка обхода (`real_map_sweep_for_invisible_walls`) покрывала + /// только центр: `x` от −1500 до 600, `y` от −800 до 600. Запад, юг и + /// восток карты не проверялись вовсе — а жалобы приходят именно оттуда + /// (`-2438 -966 48`). Здесь сетка по **габаритам всей карты**: сначала + /// отбираются точки, где тело ничего не касается (свободный объём), затем + /// в каждой делается шаг в восемь сторон, и ищется кирпич, который + /// отсекает движение, находясь дальше 8 юнитов от тела. Это и есть + /// невидимая стена: настоящая стена стоит вплотную, а такая — в пустоте. + /// + /// Шаг сетки задаётся `SOURCE_RS_SCAN_STEP` (по умолчанию 400). + #[test] + #[ignore = "диагностика: SOURCE_RS_TEST_BSP"] + fn probe_phantom_walls_scan() { + let Some(path) = std::env::var_os("SOURCE_RS_TEST_BSP") else { + eprintln!("SOURCE_RS_TEST_BSP не задан — проверка пропущена"); + return; + }; + let data = std::fs::read(&path).expect("BSP читается"); + let bsp = Bsp::from_bytes(data).expect("BSP разбирается"); + let collision = Collision::from_bsp(&bsp).expect("коллизия строится"); + let world = crate::world::build_world(&bsp, &fs::VirtualFs::new()).expect("мир строится"); + let step: f32 = std::env::var("SOURCE_RS_SCAN_STEP") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(400.0); + + // Расстояние между габаритами: ноль, если они пересекаются или + // соприкасаются. + fn separation(a: &Aabb, b: &Aabb) -> f32 { + let axis = |a_min: f32, a_max: f32, b_min: f32, b_max: f32| -> f32 { + (a_min - b_max).max(b_min - a_max).max(0.0) + }; + Vec3::new( + axis(a.min.x, a.max.x, b.min.x, b.max.x), + axis(a.min.y, a.max.y, b.min.y, b.max.y), + axis(a.min.z, a.max.z, b.min.z, b.max.z), + ) + .length() + } + + let mut samples = 0usize; + let mut free = 0usize; + let mut phantom: Vec<(usize, Vec3, f32)> = Vec::new(); + let mut x = world.bounds_min.x + step * 0.5; + while x < world.bounds_max.x { + let mut y = world.bounds_min.y + step * 0.5; + while y < world.bounds_max.y { + let mut z = world.bounds_max.z - 64.0; + while z > world.bounds_min.z + 64.0 { + samples += 1; + let center = Vec3::new(x, y, z); + let body = Aabb::new(center - STAND_HALF, center + STAND_HALF); + if collision.touching(&body) { + z -= step * 0.5; + continue; + } + free += 1; + // Кирпич считает тело внутри (`penetration`), хотя по + // полупространствам тело вышло за грань: это ложное + // перекрытие, и `resolve_overlap` вытолкнет тело из + // пустоты. Шаг движения тут не нужен вовсе. + for (index, brush) in collision.brushes.iter().enumerate() { + if !brush.bounds.min.is_finite() || brush.planes.len() < 4 { + continue; + } + // Тело вне кирпича, если вышло хотя бы за одну грань. + let outside = brush.planes.iter().any(|(normal, dist)| { + let inflate = normal.x.abs() * STAND_HALF.x + + normal.y.abs() * STAND_HALF.y + + normal.z.abs() * STAND_HALF.z; + normal.dot(center) + inflate > dist + 0.01 + }); + if !outside { + continue; + } + if let Some((depth, direction)) = Collision::penetration(brush, &body) { + let gap = separation(&body, &brush.bounds); + phantom.push((index, center, depth.max(gap))); + let _ = direction; + break; + } + } + z -= step * 0.5; + } + y += step; + } + x += step; + } + + let mut brushes: Vec = phantom.iter().map(|(index, _, _)| *index).collect(); + brushes.sort_unstable(); + brushes.dedup(); + println!( + "ЗАМЕР обход всей карты: точек {samples}, свободных {free}, \ + невидимых стен {} у {} кирпичей", + phantom.len(), + brushes.len() + ); + for (index, center, gap) in phantom.iter().take(12) { + let brush = &collision.brushes[*index]; + println!( + "ЗАМЕР кирпич #{index} (граней {}, габарит {:?}..{:?}) держит тело в \ + {center:?}, зазор {gap:.1}", + brush.planes.len(), + brush.bounds.min, + brush.bounds.max + ); + } + println!("ЗАМЕР кирпичи: {brushes:?}"); + } + + /// Что за кирпичи попадают в коллизию: содержимое и текстуры граней. + /// + /// Жалоба: «невидимые стены, которые можно перепрыгнуть, а запрыгнуть на + /// них нельзя» — так ведёт себя **служебный** браш: клип, триггер, + /// `nodraw`. У `dbrushside_t` есть свой `texinfo`, поэтому текстуру кирпича + /// видно прямо из данных, без сопоставления плоскостей с гранями. + #[test] + #[ignore = "диагностика: SOURCE_RS_TEST_BSP"] + fn probe_collision_contents() { + let Some(path) = std::env::var_os("SOURCE_RS_TEST_BSP") else { + eprintln!("SOURCE_RS_TEST_BSP не задан — проверка пропущена"); + return; + }; + let data = std::fs::read(&path).expect("BSP читается"); + let bsp = Bsp::from_bytes(data).expect("BSP разбирается"); + let geometry = bsp.geometry().expect("геометрия"); + let texdata = bsp.texdata().expect("texdata"); + let names = bsp.texture_names().expect("имена текстур"); + let brush_data = bsp.lump(lump::BRUSHES).expect("кирпичи").into_owned(); + let side_data = bsp.lump(lump::BRUSHSIDES).expect("грани").into_owned(); + let entities = bsp.entities().unwrap_or_default(); + + let texture_of = |texinfo: i16| -> String { + geometry + .texinfos + .get(texinfo.max(0) as usize) + .and_then(|t| texdata.get(t.texdata.max(0) as usize)) + .and_then(|t| names.get(t.name_id.max(0) as usize)) + .cloned() + .unwrap_or_default() + }; + + // Содержимое включённых кирпичей и их текстуры: «служебные» браши + // узнаются по имени текстуры без всяких догадок. + let mut contents_count: std::collections::HashMap = + std::collections::HashMap::new(); + let mut textures_count: std::collections::HashMap = + std::collections::HashMap::new(); + let mut included = 0usize; + for chunk in brush_data.chunks_exact(12) { + let first = i32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0; 4])); + let count = i32::from_le_bytes(chunk[4..8].try_into().unwrap_or([0; 4])); + let contents = i32::from_le_bytes(chunk[8..12].try_into().unwrap_or([0; 4])); + let liquid = contents::is_liquid(contents); + if !liquid && !contents::is_solid(contents) { + continue; + } + included += 1; + *contents_count.entry(contents).or_default() += 1; + if first < 0 || count < 0 || count == 0 { + continue; + } + let mut textures: Vec = Vec::new(); + for s in first as usize..(first + count) as usize { + let Some(side) = side_data.get(s * 8..s * 8 + 8) else { + break; + }; + let texinfo = i16::from_le_bytes([side[2], side[3]]); + let name = texture_of(texinfo); + if !textures.contains(&name) { + textures.push(name); + } + } + for name in textures { + *textures_count.entry(name).or_default() += 1; + } + } + + // Служебные кирпичи поимённо: индекс, содержимое, владелец и текстуры. + // Владелец — модель кирпича; у моделей есть сущность с `model *N`, + // по ней и берём `classname`. + let mut owner_model: std::collections::HashMap = + std::collections::HashMap::new(); + for (number, model) in geometry.models.iter().enumerate() { + for index in Collision::brushes_under(&geometry, model.head_node) { + owner_model.insert(index, number); + } + } + let class_of_model = |number: usize| -> String { + let wanted = format!("*{number}"); + entities + .iter() + .find(|entity| entity.get_str("model") == Some(wanted.as_str())) + .and_then(|entity| entity.get_str("classname")) + .unwrap_or("") + .to_string() + }; + for (index, chunk) in brush_data.chunks_exact(12).enumerate() { + let first = i32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0; 4])); + let count = i32::from_le_bytes(chunk[4..8].try_into().unwrap_or([0; 4])); + let contents = i32::from_le_bytes(chunk[8..12].try_into().unwrap_or([0; 4])); + let liquid = contents::is_liquid(contents); + if first < 0 || count < 0 || (!liquid && !contents::is_solid(contents)) { + continue; + } + let mut textures: Vec = Vec::new(); + for s in first as usize..(first + count) as usize { + let Some(side) = side_data.get(s * 8..s * 8 + 8) else { + break; + }; + let name = texture_of(i16::from_le_bytes([side[2], side[3]])); + if !textures.contains(&name) { + textures.push(name); + } + } + let service = textures.iter().any(|name| { + let upper = name.to_ascii_uppercase(); + upper.starts_with("TOOLS/") && !upper.contains("NODRAW") + }); + if !service { + continue; + } + let model = owner_model.get(&(index as u32)).copied().unwrap_or(0); + println!( + "ЗАМЕР кирпич #{index}: содержимое {contents:#010x}, владелец {}, \ + граней {count}, текстуры {textures:?}", + class_of_model(model) + ); + } + + println!( + "ЗАМЕР коллизия: кирпичей всего {}, в коллизию попало {included}", + brush_data.len() / 12 + ); + let mut contents: Vec<(i32, usize)> = contents_count.into_iter().collect(); + contents.sort_by_key(|(_, count)| std::cmp::Reverse(*count)); + for (value, count) in contents.iter().take(10) { + println!("ЗАМЕР содержимое {value:#010x}: {count}"); + } + // Мусорные и служебные текстуры — то, чего в глазной картинке нет. + let mut textures: Vec<(String, usize)> = textures_count.into_iter().collect(); + textures.sort_by_key(|(_, count)| std::cmp::Reverse(*count)); + for (name, count) in textures.iter().take(25) { + println!("ЗАМЕР текстура {name:?}: {count}"); + } + let tools: Vec<(&String, usize)> = textures + .iter() + .filter(|(name, _)| name.to_ascii_uppercase().contains("TOOLS/")) + .map(|(name, count)| (name, *count)) + .collect(); + println!("ЗАМЕР служебные текстуры (TOOLS/): {tools:?}"); + } + /// Диагностика движения у рампы: рывки и залипания. /// /// Жалоба: «я как будто стою вписанным в невидимый параллелепипед, а не diff --git a/docs/PARITY.md b/docs/PARITY.md index 337ea1e..c0b84bf 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -215,6 +215,26 @@ displacement-геометрия, у которой **не разрешилась В пустых углах около рамп получается ложное перекрытие, и `resolve_overlap` выталкивает игрока из объёма, которого нет. +**Что уже замерено (2026-10-16):** + +- `probe_ramp_movement` в точке `-2438 -966 48`: залипание при движении в −Y и + по диагоналям объясняется **настоящей кирпичной стеной** — кирпич #829 стоит + по `y = -1024`, текстуры бетон/кирпич, стена видна на кадре из этой точки. + Фантомного выталкивания в этой точке нет. +- `probe_phantom_walls_scan` — обход **всей карты** (11200 точек по габаритам, + 10824 свободных) с шагом кадра 8 юнитов: кирпичей, блокирующих шаг издалека, + **ноль**. Ложных «тело внутри, хотя по плоскостям снаружи» — тоже ноль. +- `probe_collision_contents` — состав коллизии: 2118 кирпичей из 2203 сырых; + исключены 7 `func_vehicleclip` (в Source режут только машины), триггеры и + прочие служебные; в коллизии не осталось ни `TOOLSTRIGGER`, ни клипов. +- Причина, по которой старый обход молчал: его сетка покрывала только центр + (`x` −1500..600, `y` −800..600), то есть запад и юг карты не проверялись. + +**Что осталось:** нужен точный адрес «стены на стыке комнат» — на рампе такой +стены нет. Отладочный слой (`SOURCE_RS_DEBUG_COLLISION=1`) покажет это прямо +на экране: если у мнимой стены нет ни красных рёбер, ни жёлтого габарита — +она не из кирпичей вовсе, и это отдельный механизм. + **Приёмка:** игрок в пустом углу рядом с рампой не сдвигается; обход карты с сеткой, покрывающей запад (`x` до −3100), без залипаний.