Замер коллизий по всей карте: мнимых стен в кирпичах нет
Some checks are pending
CI / checks (push) Waiting to run

Три диагностики, чтобы перестать гадать по жалобам про «невидимые стены».

`probe_ramp_movement` — движение у рампы из точки пользователя
(`-2438 -966 48`): роняет игрока на опору, гоняет восемь сторон и на каждом
кадре сравнивает сдвиг с ожидаемым. Рывков нет, залипания есть, и виновник
найден: кирпич #829 по `y = -1024` с настоящими текстурами бетона и кирпича —
стена видна на кадре из этой точки. Фантомного выталкивания в точке нет.

`probe_phantom_walls_scan` — обход всей карты (11200 точек, 10824 свободных)
с шагом кадра 8 юнитов ищет кирпич, который отсекает шаг, находясь дальше
8 юнитов от тела, и отдельно — ложное «тело внутри, хотя по плоскостям
снаружи». Оба счётчика нулевые. Это объясняет, почему старый обход молчал:
его сетка покрывала только центр (x −1500..600, y −800..600), запад и юг
карты не проверялись вовсе.

`probe_collision_contents` — состав коллизии по данным BSP: 2118 кирпичей из
2203, и служебных среди них нет. 7 кирпичей `func_vehicleclip` (в Source режут
только машины) исключены владельцем, триггеры — тоже; ни TOOLSTRIGGER, ни
клипов в коллизии не осталось. Текстуры кирпичей читаются прямо из
`dbrushside.texinfo`, а не подбором по плоскостям.

Вывод: у «невидимых стен» вокруг рамп и стыков источник не в наборе кирпичей.
Нужен точный адрес места, где это чувствуется: отладочный слой из прошлого
коммита покажет, есть ли у мнимой стены рёбра кирпича вообще.

В docs/PARITY.md A9 дописаны все три замера и остаток вопроса.
This commit is contained in:
Niko Marmeladkov 2026-10-16 19:04:18 +03:00
parent 86a6cb63f9
commit 5380184dfe
2 changed files with 277 additions and 0 deletions

View file

@ -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<usize> = 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<i32, usize> =
std::collections::HashMap::new();
let mut textures_count: std::collections::HashMap<String, usize> =
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<String> = 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<u32, usize> =
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("<world>")
.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<String> = 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:?}");
}
/// Диагностика движения у рампы: рывки и залипания.
///
/// Жалоба: «я как будто стою вписанным в невидимый параллелепипед, а не

View file

@ -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), без залипаний.