sprint 0: workspace, tier/fs/math, VPK v1/v2 с CRC, окно SDL3+wgpu на Vulkan
- crates/tier: tracing-логи, конвары с реестром, конфиг TOML, авто-детект ассетов - crates/fs: bounded-парсер VPK v1/v2 (preload, встроенные данные, внешние архивы), VirtualFs с приоритетами и защитой от traversal, CRC-проверка - crates/math: Vec3/Angle/Aabb - crates/client (gmod-rs): fs doctor / fs cat, окно SDL3+wgpu 30, FPS в заголовке и логе - тесты на реальных VPK (~/gmod_server/server): 9 контейнеров, 77 933 записи, 0 ошибок CRC - CI-скелет, fuzz-таргет vpk_parse, config.example.toml, обновление docs
This commit is contained in:
parent
ec0750a829
commit
dec5b2c48f
29 changed files with 4644 additions and 5 deletions
31
.github/workflows/ci.yml
vendored
Normal file
31
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Форматирование
|
||||
run: cargo fmt --all --check
|
||||
|
||||
# Крейт client не собирается в CI: SDL3 собирается из исходников и требует
|
||||
# системных X11/Wayland-заголовков. Появится отдельный job позже.
|
||||
- name: Clippy (tier, math, fs)
|
||||
run: cargo clippy -p tier -p math -p fs --all-targets -- -D warnings
|
||||
|
||||
- name: Тесты (tier, math, fs)
|
||||
run: cargo test -p tier -p math -p fs --all-targets
|
||||
1772
Cargo.lock
generated
Normal file
1772
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
40
Cargo.toml
Normal file
40
Cargo.toml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/tier",
|
||||
"crates/math",
|
||||
"crates/fs",
|
||||
"crates/client",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
|
||||
[workspace.dependencies]
|
||||
# внутренние крейты
|
||||
tier = { path = "crates/tier" }
|
||||
math = { path = "crates/math" }
|
||||
fs = { path = "crates/fs" }
|
||||
|
||||
# внешние
|
||||
anyhow = "1"
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
crc32fast = "1"
|
||||
parking_lot = "0.12"
|
||||
pollster = "0.4"
|
||||
rustc-hash = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
sdl3 = { version = "0.20", features = ["raw-window-handle", "build-from-source"] }
|
||||
thiserror = "2"
|
||||
toml = "0.9"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
wgpu = "30"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
13
config.example.toml
Normal file
13
config.example.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Пример конфигурации Source RS.
|
||||
# Скопируйте в ~/.config/source-rs/config.toml и поправьте пути.
|
||||
|
||||
# Корень установки Garry's Mod (папка с garrysmod/ и sourceengine/).
|
||||
# Можно не указывать — тогда используется авто-определение
|
||||
# (~/gmod_server/server, затем установки Steam).
|
||||
# assets = "/home/user/gmod_server/server"
|
||||
|
||||
# Каталог кэшей (индексы VPK, обработанные ассеты и т.п.).
|
||||
# cache_dir = "/home/user/.cache/source-rs"
|
||||
|
||||
# Сервер по умолчанию.
|
||||
server = "194.147.90.27:24215"
|
||||
21
crates/client/Cargo.toml
Normal file
21
crates/client/Cargo.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
[package]
|
||||
name = "client"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "gmod-rs"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
fs.workspace = true
|
||||
tier.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
crc32fast.workspace = true
|
||||
pollster.workspace = true
|
||||
sdl3.workspace = true
|
||||
tracing.workspace = true
|
||||
wgpu.workspace = true
|
||||
26
crates/client/src/convars.rs
Normal file
26
crates/client/src/convars.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
//! Конвары клиента (регистрируются при старте).
|
||||
|
||||
use tier::convars::ConVar;
|
||||
|
||||
/// Ограничение FPS (0 — без ограничения).
|
||||
pub static FPS_MAX: ConVar =
|
||||
ConVar::new_int("fps_max", 0, "Максимальный FPS (0 — без ограничения)");
|
||||
|
||||
/// Красная компонента цвета фона окна.
|
||||
pub static R_CLEAR_R: ConVar =
|
||||
ConVar::new_float("r_clear_r", 0.02, "Красная компонента цвета фона");
|
||||
|
||||
/// Зелёная компонента цвета фона окна.
|
||||
pub static R_CLEAR_G: ConVar =
|
||||
ConVar::new_float("r_clear_g", 0.03, "Зелёная компонента цвета фона");
|
||||
|
||||
/// Синяя компонента цвета фона окна.
|
||||
pub static R_CLEAR_B: ConVar = ConVar::new_float("r_clear_b", 0.08, "Синяя компонента цвета фона");
|
||||
|
||||
/// Регистрирует все конвары клиента.
|
||||
pub fn register_all() {
|
||||
tier::convars::register(&FPS_MAX);
|
||||
tier::convars::register(&R_CLEAR_R);
|
||||
tier::convars::register(&R_CLEAR_G);
|
||||
tier::convars::register(&R_CLEAR_B);
|
||||
}
|
||||
259
crates/client/src/doctor.rs
Normal file
259
crates/client/src/doctor.rs
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
//! Команды `fs doctor` и `fs cat` — диагностика ассетов и чтение файлов.
|
||||
|
||||
use anyhow::Context;
|
||||
use fs::{VirtualFs, vpk::Vpk};
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tier::paths::AssetLayout;
|
||||
|
||||
/// `fs doctor`: найти ассеты, разобрать все VPK, проверить выборку CRC.
|
||||
pub fn doctor(assets: Option<PathBuf>) -> anyhow::Result<()> {
|
||||
let config = tier::config::Config::load(assets, None);
|
||||
println!("Source RS fs doctor");
|
||||
println!("конфиг: {}", config.describe());
|
||||
|
||||
let Some(layout) = tier::paths::detect(config.assets.as_deref()) else {
|
||||
println!("ассеты Garry's Mod не найдены. Укажите --assets <корень установки>.");
|
||||
print_convars();
|
||||
return Ok(());
|
||||
};
|
||||
println!("ассеты: {}", layout.describe());
|
||||
|
||||
let dir_vpks = collect_dir_vpks(&layout);
|
||||
if dir_vpks.is_empty() {
|
||||
println!("VPK не найдены в {}", layout.root.display());
|
||||
}
|
||||
|
||||
let mut total_entries = 0usize;
|
||||
let mut total_archives = 0usize;
|
||||
let mut failed = 0usize;
|
||||
|
||||
for path in &dir_vpks {
|
||||
match inspect_vpk(path) {
|
||||
Ok((entries, archives_seen, ok, sampled)) => {
|
||||
total_entries += entries;
|
||||
total_archives += archives_seen;
|
||||
failed += sampled.saturating_sub(ok);
|
||||
}
|
||||
Err(err) => {
|
||||
failed += 1;
|
||||
println!(" ОШИБКА {}: {err:#}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!(
|
||||
"итого: {} VPK, {} записей, {} архивов, ошибок: {}",
|
||||
dir_vpks.len(),
|
||||
total_entries,
|
||||
total_archives,
|
||||
failed
|
||||
);
|
||||
|
||||
print_convars();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `fs cat`: прочитать файл через смонтированные пути.
|
||||
pub fn cat(
|
||||
assets: Option<PathBuf>,
|
||||
path: &str,
|
||||
unverified: bool,
|
||||
out: Option<PathBuf>,
|
||||
) -> anyhow::Result<()> {
|
||||
let config = tier::config::Config::load(assets, None);
|
||||
let layout = tier::paths::detect(config.assets.as_deref()).ok_or_else(|| {
|
||||
anyhow::anyhow!("ассеты Garry's Mod не найдены; укажите --assets <корень>")
|
||||
})?;
|
||||
|
||||
let mut vfs = VirtualFs::new();
|
||||
for vpk in collect_dir_vpks(&layout) {
|
||||
vfs.mount_vpk(&vpk)
|
||||
.with_context(|| format!("монтирование {}", vpk.display()))?;
|
||||
}
|
||||
vfs.mount_dir(layout.game.clone());
|
||||
if let Some(p) = &layout.source_engine {
|
||||
vfs.mount_dir(p.clone());
|
||||
}
|
||||
if let Some(p) = &layout.platform {
|
||||
vfs.mount_dir(p.clone());
|
||||
}
|
||||
|
||||
for line in vfs.describe() {
|
||||
tracing::debug!(source = %line, "источник");
|
||||
}
|
||||
|
||||
let data = if unverified {
|
||||
vfs.read_unverified(path)
|
||||
} else {
|
||||
vfs.read(path)
|
||||
}
|
||||
.with_context(|| format!("чтение {path}"))?;
|
||||
|
||||
println!(
|
||||
"{path}: {} байт, CRC32 {:08x}",
|
||||
data.len(),
|
||||
crc32fast::hash(&data)
|
||||
);
|
||||
|
||||
match out {
|
||||
Some(dest) => {
|
||||
std::fs::write(&dest, &data).with_context(|| format!("запись {}", dest.display()))?;
|
||||
println!("сохранено: {}", dest.display());
|
||||
}
|
||||
None => preview(path, &data),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Собирает `*_dir.vpk` в порядке приоритета монтирования.
|
||||
pub fn collect_dir_vpks(layout: &AssetLayout) -> Vec<PathBuf> {
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
|
||||
let push = |path: PathBuf, out: &mut Vec<PathBuf>| {
|
||||
if path.is_file() && !out.contains(&path) {
|
||||
out.push(path);
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Основные VPK игры (garrysmod важнее fallbacks).
|
||||
push(layout.game.join("garrysmod_dir.vpk"), &mut out);
|
||||
push(layout.game.join("fallbacks_dir.vpk"), &mut out);
|
||||
|
||||
// 2. Остальные VPK в каталогах игры/движка/платформы (по алфавиту).
|
||||
let dirs = [
|
||||
Some(layout.game.clone()),
|
||||
layout.source_engine.clone(),
|
||||
layout.platform.clone(),
|
||||
];
|
||||
for dir in dirs.into_iter().flatten() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
let mut found: Vec<PathBuf> = entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
p.file_name()
|
||||
.map(|n| n.to_string_lossy().ends_with("_dir.vpk"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
found.sort();
|
||||
for path in found {
|
||||
push(path, &mut out);
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn inspect_vpk(path: &Path) -> anyhow::Result<(usize, usize, usize, usize)> {
|
||||
let started = std::time::Instant::now();
|
||||
let vpk = Vpk::parse(path)?;
|
||||
let info = vpk.info();
|
||||
|
||||
let mut archives: BTreeSet<u16> = BTreeSet::new();
|
||||
for entry in vpk.entries() {
|
||||
if entry.length > 0 && !entry.is_embedded() {
|
||||
archives.insert(entry.archive_index);
|
||||
}
|
||||
}
|
||||
let present = archives
|
||||
.iter()
|
||||
.filter(|index| vpk.archive_path(**index).is_file())
|
||||
.count();
|
||||
|
||||
let readable: Vec<_> = vpk
|
||||
.entries()
|
||||
.filter(|e| {
|
||||
e.length > 0 && (e.is_embedded() || vpk.archive_path(e.archive_index).is_file())
|
||||
})
|
||||
.collect();
|
||||
let step = (readable.len() / 32).max(1);
|
||||
let mut sampled = 0usize;
|
||||
let mut ok = 0usize;
|
||||
let mut bytes = 0u64;
|
||||
for entry in readable.iter().step_by(step).take(32) {
|
||||
sampled += 1;
|
||||
match vpk.read_entry(entry, true) {
|
||||
Ok(data) => {
|
||||
ok += 1;
|
||||
bytes += data.len() as u64;
|
||||
}
|
||||
Err(err) => println!(" CRC/чтение: {} — {err}", entry.path),
|
||||
}
|
||||
}
|
||||
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
println!(
|
||||
" {name:<28} v{} записей {:>7} дерево {:>6} КиБ архивов {}/{} CRC {}/{} ({:.1} МиБ) за {:?}",
|
||||
info.version,
|
||||
info.entry_count,
|
||||
info.tree_size / 1024,
|
||||
present,
|
||||
archives.len(),
|
||||
ok,
|
||||
sampled,
|
||||
bytes as f64 / 1_048_576.0,
|
||||
started.elapsed()
|
||||
);
|
||||
|
||||
if ok != sampled {
|
||||
anyhow::bail!("{}/{} записей не прошли проверку", sampled - ok, sampled);
|
||||
}
|
||||
Ok((info.entry_count, archives.len(), ok, sampled))
|
||||
}
|
||||
|
||||
fn print_convars() {
|
||||
let convars = tier::convars::list();
|
||||
println!("конвары ({}):", convars.len());
|
||||
for cv in convars {
|
||||
println!(
|
||||
" {:<16} {:<6} = {:<8} (по умолчанию {}) — {}",
|
||||
cv.name(),
|
||||
cv.type_name(),
|
||||
cv.value_string(),
|
||||
cv.default_string(),
|
||||
cv.description()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn preview(path: &str, data: &[u8]) {
|
||||
if data.is_empty() {
|
||||
println!("<пустой файл>");
|
||||
return;
|
||||
}
|
||||
|
||||
if data.len() <= 64 * 1024 {
|
||||
if let Ok(text) = std::str::from_utf8(data) {
|
||||
let printable = text
|
||||
.chars()
|
||||
.all(|c| !c.is_control() || matches!(c, '\n' | '\r' | '\t'));
|
||||
if printable {
|
||||
println!("{text}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("<бинарь {path}: первые {} байт>", data.len().min(256));
|
||||
for (i, chunk) in data[..data.len().min(256)].chunks(16).enumerate() {
|
||||
let hex: Vec<String> = chunk.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let ascii: String = chunk
|
||||
.iter()
|
||||
.map(|&b| {
|
||||
if (0x20..0x7f).contains(&b) {
|
||||
b as char
|
||||
} else {
|
||||
'.'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
println!(" {:08x} {:<47} {}", i * 16, hex.join(" "), ascii);
|
||||
}
|
||||
}
|
||||
182
crates/client/src/gfx.rs
Normal file
182
crates/client/src/gfx.rs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
//! Инициализация wgpu поверх окна SDL3.
|
||||
|
||||
use anyhow::Context;
|
||||
use sdl3::video::Window;
|
||||
use wgpu::rwh::{HasDisplayHandle, HasWindowHandle};
|
||||
|
||||
/// Обёртка для `create_surface`: wgpu требует `Send + Sync` от оконного хэндла,
|
||||
/// SDL3-окно таковым не помечено. Доступ к окну идёт только с главного потока,
|
||||
/// поэтому это безопасно (ограничено данным модулем).
|
||||
struct WindowHandle<'a>(&'a Window);
|
||||
|
||||
unsafe impl Send for WindowHandle<'_> {}
|
||||
unsafe impl Sync for WindowHandle<'_> {}
|
||||
|
||||
impl HasWindowHandle for WindowHandle<'_> {
|
||||
fn window_handle(&self) -> Result<wgpu::rwh::WindowHandle<'_>, wgpu::rwh::HandleError> {
|
||||
self.0.window_handle()
|
||||
}
|
||||
}
|
||||
|
||||
impl HasDisplayHandle for WindowHandle<'_> {
|
||||
fn display_handle(&self) -> Result<wgpu::rwh::DisplayHandle<'_>, wgpu::rwh::HandleError> {
|
||||
self.0.display_handle()
|
||||
}
|
||||
}
|
||||
|
||||
/// GPU-контекст: поверхность, устройство, очередь.
|
||||
pub struct Gfx<'win> {
|
||||
surface: wgpu::Surface<'win>,
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
config: wgpu::SurfaceConfiguration,
|
||||
adapter_info: wgpu::AdapterInfo,
|
||||
}
|
||||
|
||||
impl<'win> Gfx<'win> {
|
||||
/// Создаёт контекст для окна.
|
||||
pub fn new(window: &'win Window) -> anyhow::Result<Self> {
|
||||
let instance =
|
||||
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
|
||||
|
||||
let surface = instance
|
||||
.create_surface(WindowHandle(window))
|
||||
.context("create_surface")?;
|
||||
|
||||
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
force_fallback_adapter: false,
|
||||
compatible_surface: Some(&surface),
|
||||
apply_limit_buckets: false,
|
||||
}))
|
||||
.context("request_adapter")?;
|
||||
|
||||
let adapter_info = adapter.get_info();
|
||||
tracing::debug!(?adapter_info, "выбран GPU-адаптер");
|
||||
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("source-rs device"),
|
||||
required_features: wgpu::Features::empty(),
|
||||
required_limits: wgpu::Limits::default(),
|
||||
memory_hints: wgpu::MemoryHints::Performance,
|
||||
experimental_features: wgpu::ExperimentalFeatures::disabled(),
|
||||
trace: wgpu::Trace::Off,
|
||||
}))
|
||||
.context("request_device")?;
|
||||
|
||||
let caps = surface.get_capabilities(&adapter);
|
||||
let Some(format) = caps
|
||||
.formats
|
||||
.iter()
|
||||
.copied()
|
||||
.find(wgpu::TextureFormat::is_srgb)
|
||||
.or_else(|| caps.formats.first().copied())
|
||||
else {
|
||||
anyhow::bail!("поверхность не поддерживает ни одного формата");
|
||||
};
|
||||
|
||||
let present_mode = if caps.present_modes.contains(&wgpu::PresentMode::Fifo) {
|
||||
wgpu::PresentMode::Fifo
|
||||
} else {
|
||||
caps.present_modes
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(wgpu::PresentMode::Fifo)
|
||||
};
|
||||
|
||||
let (width, height) = window.size();
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
format,
|
||||
color_space: wgpu::SurfaceColorSpace::Auto,
|
||||
width: width.max(1),
|
||||
height: height.max(1),
|
||||
present_mode,
|
||||
alpha_mode: caps
|
||||
.alpha_modes
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(wgpu::CompositeAlphaMode::Auto),
|
||||
desired_maximum_frame_latency: 2,
|
||||
view_formats: vec![],
|
||||
};
|
||||
surface.configure(&device, &config);
|
||||
|
||||
Ok(Self {
|
||||
surface,
|
||||
device,
|
||||
queue,
|
||||
config,
|
||||
adapter_info,
|
||||
})
|
||||
}
|
||||
|
||||
/// Информация о выбранном адаптере.
|
||||
pub fn adapter_info(&self) -> &wgpu::AdapterInfo {
|
||||
&self.adapter_info
|
||||
}
|
||||
|
||||
/// Текущий размер поверхности.
|
||||
pub fn size(&self) -> (u32, u32) {
|
||||
(self.config.width, self.config.height)
|
||||
}
|
||||
|
||||
/// Пересоздаёт поверхность под новый размер.
|
||||
pub fn resize(&mut self, width: u32, height: u32) {
|
||||
if width == 0 || height == 0 {
|
||||
return;
|
||||
}
|
||||
if width == self.config.width && height == self.config.height {
|
||||
return;
|
||||
}
|
||||
self.config.width = width;
|
||||
self.config.height = height;
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
tracing::debug!(width, height, "поверхность переконфигурирована");
|
||||
}
|
||||
|
||||
/// Очищает кадр заданным цветом и презентует.
|
||||
pub fn render_clear(&mut self, color: wgpu::Color) -> anyhow::Result<()> {
|
||||
use wgpu::CurrentSurfaceTexture as Cst;
|
||||
|
||||
let frame = match self.surface.get_current_texture() {
|
||||
Cst::Success(frame) => frame,
|
||||
Cst::Timeout | Cst::Occluded => return Ok(()),
|
||||
Cst::Outdated | Cst::Lost => {
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
return Ok(());
|
||||
}
|
||||
other => anyhow::bail!("поверхность недоступна: {other:?}"),
|
||||
};
|
||||
|
||||
let view = frame
|
||||
.texture
|
||||
.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
let mut encoder = self
|
||||
.device
|
||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("frame"),
|
||||
});
|
||||
{
|
||||
let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("clear"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(color),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
}
|
||||
self.queue.submit(std::iter::once(encoder.finish()));
|
||||
self.queue.present(frame);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
78
crates/client/src/main.rs
Normal file
78
crates/client/src/main.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
//! Source RS — клиент Garry's Mod, переписанный на Rust.
|
||||
|
||||
mod convars;
|
||||
mod doctor;
|
||||
mod gfx;
|
||||
mod run;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "gmod-rs",
|
||||
version,
|
||||
about = "Source RS — клиент Garry's Mod на Rust"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Корень установки Garry's Mod (например, ~/gmod_server/server)
|
||||
#[arg(long, global = true, value_name = "PATH")]
|
||||
assets: Option<PathBuf>,
|
||||
|
||||
/// Подробность логов: -v — debug, -vv — trace
|
||||
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
|
||||
verbose: u8,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
/// Запустить окно клиента (действие по умолчанию)
|
||||
Run(run::RunArgs),
|
||||
|
||||
/// Операции с игровой файловой системой
|
||||
Fs {
|
||||
#[command(subcommand)]
|
||||
cmd: FsCmd,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum FsCmd {
|
||||
/// Найти ассеты, разобрать VPK, проверить выборку CRC
|
||||
Doctor,
|
||||
|
||||
/// Вывести файл из смонтированных путей
|
||||
Cat {
|
||||
/// Путь внутри игры, например models/props_c17/oildrum001.mdl
|
||||
path: String,
|
||||
|
||||
/// Не проверять CRC
|
||||
#[arg(long)]
|
||||
unverified: bool,
|
||||
|
||||
/// Сохранить в файл вместо вывода
|
||||
#[arg(long, value_name = "PATH")]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
tier::logging::init(cli.verbose);
|
||||
convars::register_all();
|
||||
|
||||
match cli.command.unwrap_or(Command::Run(run::RunArgs::default())) {
|
||||
Command::Run(args) => run::run(cli.assets, args),
|
||||
Command::Fs { cmd } => match cmd {
|
||||
FsCmd::Doctor => doctor::doctor(cli.assets),
|
||||
FsCmd::Cat {
|
||||
path,
|
||||
unverified,
|
||||
out,
|
||||
} => doctor::cat(cli.assets, &path, unverified, out),
|
||||
},
|
||||
}
|
||||
}
|
||||
153
crates/client/src/run.rs
Normal file
153
crates/client/src/run.rs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
//! Запуск окна клиента: SDL3 + wgpu, замер FPS, аккуратное завершение.
|
||||
|
||||
use crate::convars;
|
||||
use crate::gfx::Gfx;
|
||||
use anyhow::Context;
|
||||
use sdl3::event::{Event, WindowEvent};
|
||||
use sdl3::keyboard::Keycode;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Аргументы команды `run`.
|
||||
#[derive(Debug, Default, clap::Args)]
|
||||
pub struct RunArgs {
|
||||
/// Выйти после N кадров (для тестов и CI)
|
||||
#[arg(long)]
|
||||
pub frames: Option<u64>,
|
||||
|
||||
/// Создать скрытое окно (удобно для автотестов)
|
||||
#[arg(long)]
|
||||
pub hidden: bool,
|
||||
}
|
||||
|
||||
/// Открывает окно и крутит цикл кадров до выхода пользователя
|
||||
/// (или до `--frames N` кадров).
|
||||
pub fn run(assets: Option<PathBuf>, args: RunArgs) -> anyhow::Result<()> {
|
||||
info!("Source RS {} — запуск", tier::VERSION);
|
||||
|
||||
let config = tier::config::Config::load(assets, None);
|
||||
info!("{}", config.describe());
|
||||
match tier::paths::detect(config.assets.as_deref()) {
|
||||
Some(layout) => info!("ассеты: {}", layout.describe()),
|
||||
None => warn!("ассеты Garry's Mod не найдены — окно всё равно откроется"),
|
||||
}
|
||||
|
||||
let sdl = sdl3::init().map_err(|e| anyhow::anyhow!("SDL3 init: {e}"))?;
|
||||
let video = sdl
|
||||
.video()
|
||||
.map_err(|e| anyhow::anyhow!("SDL3 video: {e}"))?;
|
||||
|
||||
let mut builder = video.window("Source RS", 1280, 720);
|
||||
builder.position_centered().resizable();
|
||||
if args.hidden {
|
||||
builder.hidden();
|
||||
}
|
||||
let window = builder
|
||||
.build()
|
||||
.map_err(|e| anyhow::anyhow!("SDL3 window: {e}"))?;
|
||||
|
||||
let (width, height) = window.size();
|
||||
info!("окно SDL3: {width}x{height}");
|
||||
|
||||
let mut gfx = Gfx::new(&window).context("инициализация wgpu")?;
|
||||
let (surface_w, surface_h) = gfx.size();
|
||||
info!("поверхность wgpu: {surface_w}x{surface_h}");
|
||||
let adapter = gfx.adapter_info();
|
||||
info!(
|
||||
"GPU: {} ({:?}, драйвер {}, тип {:?})",
|
||||
adapter.name, adapter.backend, adapter.driver, adapter.device_type
|
||||
);
|
||||
|
||||
let mut events = sdl
|
||||
.event_pump()
|
||||
.map_err(|e| anyhow::anyhow!("SDL3 event pump: {e}"))?;
|
||||
|
||||
let start = Instant::now();
|
||||
let mut last_frame = Instant::now();
|
||||
let mut frames_drawn: u64 = 0;
|
||||
let mut fps_window_start = Instant::now();
|
||||
let mut frames_in_window: u64 = 0;
|
||||
let mut fps: f32 = 0.0;
|
||||
|
||||
'running: loop {
|
||||
for event in events.poll_iter() {
|
||||
match event {
|
||||
Event::Quit { .. } => break 'running,
|
||||
Event::KeyDown {
|
||||
keycode: Some(Keycode::Escape),
|
||||
..
|
||||
} => break 'running,
|
||||
Event::Window {
|
||||
win_event: WindowEvent::PixelSizeChanged(w, h) | WindowEvent::Resized(w, h),
|
||||
..
|
||||
} => {
|
||||
gfx.resize(w.max(1) as u32, h.max(1) as u32);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(max_frames) = args.frames {
|
||||
if frames_drawn >= max_frames {
|
||||
break 'running;
|
||||
}
|
||||
}
|
||||
|
||||
// Пульсирующий фон — просто чтобы видеть, что кадры идут.
|
||||
let t = start.elapsed().as_secs_f64();
|
||||
let pulse = 0.5 + 0.5 * (t * 0.8).sin();
|
||||
let color = wgpu::Color {
|
||||
r: (convars::R_CLEAR_R.get_float() + 0.02 * pulse).clamp(0.0, 1.0),
|
||||
g: convars::R_CLEAR_G.get_float().clamp(0.0, 1.0),
|
||||
b: (convars::R_CLEAR_B.get_float() + 0.05 * pulse).clamp(0.0, 1.0),
|
||||
a: 1.0,
|
||||
};
|
||||
if let Err(err) = gfx.render_clear(color) {
|
||||
warn!("кадр не отрисован: {err:#}");
|
||||
}
|
||||
|
||||
frames_drawn += 1;
|
||||
frames_in_window += 1;
|
||||
|
||||
// Ограничение FPS (fps_max), если задано.
|
||||
let fps_cap = convars::FPS_MAX.get_int();
|
||||
if fps_cap > 0 {
|
||||
let target = Duration::from_secs_f64(1.0 / fps_cap as f64);
|
||||
if let Some(remaining) = target.checked_sub(last_frame.elapsed()) {
|
||||
std::thread::sleep(remaining);
|
||||
}
|
||||
}
|
||||
last_frame = Instant::now();
|
||||
|
||||
// Раз в секунду — FPS в заголовок окна и в лог.
|
||||
let elapsed = fps_window_start.elapsed();
|
||||
if elapsed >= Duration::from_secs(1) {
|
||||
fps = frames_in_window as f32 / elapsed.as_secs_f32();
|
||||
frames_in_window = 0;
|
||||
fps_window_start = Instant::now();
|
||||
if !args.hidden {
|
||||
set_window_title(&window, &format!("Source RS — {fps:.0} FPS"));
|
||||
}
|
||||
info!(frames = frames_drawn, fps = format!("{fps:.1}"), "кадры");
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"завершение: {frames_drawn} кадров за {:.2} с (последний FPS {fps:.1})",
|
||||
start.elapsed().as_secs_f32()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Устанавливает заголовок окна через C-API: метод `Window::set_title` требует
|
||||
/// `&mut Window`, а окно уже занято неизменяемой ссылкой внутри поверхности wgpu.
|
||||
fn set_window_title(window: &sdl3::video::Window, title: &str) {
|
||||
let Ok(cstr) = std::ffi::CString::new(title) else {
|
||||
return;
|
||||
};
|
||||
// Безопасность: raw-указатель валиден, пока живо окно; вызов на главном потоке.
|
||||
unsafe {
|
||||
sdl3::sys::video::SDL_SetWindowTitle(window.raw(), cstr.as_ptr());
|
||||
}
|
||||
}
|
||||
11
crates/fs/Cargo.toml
Normal file
11
crates/fs/Cargo.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[package]
|
||||
name = "fs"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
crc32fast.workspace = true
|
||||
rustc-hash.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
38
crates/fs/src/error.rs
Normal file
38
crates/fs/src/error.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
//! Ошибки файловой системы.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Ошибка чтения/разбора игровых данных.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FsError {
|
||||
#[error("файл не найден: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("ошибка ввода-вывода: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("не VPK: магия 0x{found:08x}, ожидалось 0x55aa1234")]
|
||||
VpkBadMagic { found: u32 },
|
||||
|
||||
#[error("неподдерживаемая версия VPK: {0}")]
|
||||
VpkBadVersion(u32),
|
||||
|
||||
#[error("повреждённый VPK: {0}")]
|
||||
VpkCorrupt(String),
|
||||
|
||||
#[error("некорректный UTF-8 в индексе VPK: {0}")]
|
||||
VpkUtf8(String),
|
||||
|
||||
#[error("файл архива VPK не найден: {0}")]
|
||||
MissingArchive(PathBuf),
|
||||
|
||||
#[error("недопустимый путь: {0}")]
|
||||
InvalidPath(String),
|
||||
|
||||
#[error("CRC не совпал для {path}: ожидалось {expected:08x}, получилось {actual:08x}")]
|
||||
CrcMismatch {
|
||||
path: String,
|
||||
expected: u32,
|
||||
actual: u32,
|
||||
},
|
||||
}
|
||||
14
crates/fs/src/lib.rs
Normal file
14
crates/fs/src/lib.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
//! `fs` — игровая файловая система: поисковые пути, VPK (v1/v2), позже GMA/BSP-pakfile.
|
||||
//!
|
||||
//! Принципы:
|
||||
//! - любые внешние данные (файлы) разбираются bounded-парсером с явными лимитами;
|
||||
//! - никаких паник на некорректных данных — только типизированные ошибки;
|
||||
//! - пути нормализуются, обход каталогов (`..`, абсолютные пути) запрещён.
|
||||
|
||||
pub mod error;
|
||||
pub mod mount;
|
||||
pub mod vpk;
|
||||
|
||||
pub use error::FsError;
|
||||
pub use mount::{MountSource, VirtualFs};
|
||||
pub use vpk::Vpk;
|
||||
164
crates/fs/src/mount.rs
Normal file
164
crates/fs/src/mount.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
//! Поисковые пути и виртуальная файловая система.
|
||||
//!
|
||||
//! Порядок монтирования определяет приоритет: первый смонтированный источник
|
||||
//! находит файл первым. Позже (M1) сюда добавятся GMA (workshop), BSP-pakfile
|
||||
//! и кэш скачанных с сервера файлов.
|
||||
|
||||
use crate::error::FsError;
|
||||
use crate::vpk::{Vpk, normalize_lookup};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Источник файлов.
|
||||
pub enum MountSource {
|
||||
/// Каталог на диске.
|
||||
Dir(PathBuf),
|
||||
/// VPK-контейнер.
|
||||
Vpk(Arc<Vpk>),
|
||||
}
|
||||
|
||||
/// Виртуальная файловая система с упорядоченными источниками.
|
||||
#[derive(Default)]
|
||||
pub struct VirtualFs {
|
||||
mounts: Vec<MountSource>,
|
||||
}
|
||||
|
||||
impl VirtualFs {
|
||||
pub fn new() -> Self {
|
||||
Self { mounts: Vec::new() }
|
||||
}
|
||||
|
||||
/// Монтирует каталог.
|
||||
pub fn mount_dir(&mut self, path: impl Into<PathBuf>) -> &mut Self {
|
||||
self.mounts.push(MountSource::Dir(path.into()));
|
||||
self
|
||||
}
|
||||
|
||||
/// Разбирает и монтирует `*_dir.vpk`.
|
||||
pub fn mount_vpk(&mut self, dir_vpk: impl AsRef<Path>) -> Result<&mut Self, FsError> {
|
||||
let vpk = Vpk::parse(dir_vpk.as_ref())?;
|
||||
self.mounts.push(MountSource::Vpk(Arc::new(vpk)));
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Монтирует уже разобранный VPK.
|
||||
pub fn mount_vpk_parsed(&mut self, vpk: Arc<Vpk>) -> &mut Self {
|
||||
self.mounts.push(MountSource::Vpk(vpk));
|
||||
self
|
||||
}
|
||||
|
||||
/// Список источников (для диагностики).
|
||||
pub fn describe(&self) -> Vec<String> {
|
||||
self.mounts
|
||||
.iter()
|
||||
.map(|m| match m {
|
||||
MountSource::Dir(p) => format!("dir {}", p.display()),
|
||||
MountSource::Vpk(v) => format!(
|
||||
"vpk {} ({} записей)",
|
||||
v.dir_path().display(),
|
||||
v.entry_count()
|
||||
),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Читает файл (с проверкой CRC для VPK).
|
||||
pub fn read(&self, path: &str) -> Result<Vec<u8>, FsError> {
|
||||
self.read_inner(path, true)
|
||||
}
|
||||
|
||||
/// Читает файл без проверки CRC.
|
||||
pub fn read_unverified(&self, path: &str) -> Result<Vec<u8>, FsError> {
|
||||
self.read_inner(path, false)
|
||||
}
|
||||
|
||||
/// Есть ли файл.
|
||||
pub fn exists(&self, path: &str) -> bool {
|
||||
let key = match normalize_lookup(path) {
|
||||
Some(k) => k,
|
||||
None => return false,
|
||||
};
|
||||
self.mounts.iter().any(|m| match m {
|
||||
MountSource::Dir(root) => dir_file_path(root, path).is_some_and(|p| p.is_file()),
|
||||
MountSource::Vpk(vpk) => vpk.find(&key).is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_inner(&self, path: &str, verify_crc: bool) -> Result<Vec<u8>, FsError> {
|
||||
let key = normalize_lookup(path).ok_or_else(|| FsError::InvalidPath(path.to_string()))?;
|
||||
|
||||
for mount in &self.mounts {
|
||||
match mount {
|
||||
MountSource::Dir(root) => {
|
||||
if let Some(full) = dir_file_path(root, path) {
|
||||
if full.is_file() {
|
||||
return Ok(std::fs::read(full)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
MountSource::Vpk(vpk) => {
|
||||
if let Some(entry) = vpk.find(&key) {
|
||||
return vpk.read_entry(entry, verify_crc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(FsError::NotFound(path.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Безопасно собирает путь внутри каталога-источника.
|
||||
/// `normalize_lookup` уже отсекает `..`, поэтому join не выйдет наружу.
|
||||
fn dir_file_path(root: &Path, path: &str) -> Option<PathBuf> {
|
||||
normalize_lookup(path)?;
|
||||
let relative = path.replace('\\', "/");
|
||||
let relative = relative.trim_start_matches('/');
|
||||
Some(root.join(relative))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dir_mount_reads_and_rejects_traversal() {
|
||||
let dir = std::env::temp_dir().join(format!("source-rs-fs-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
std::fs::write(dir.join("hello.txt"), b"world").expect("write");
|
||||
|
||||
let mut vfs = VirtualFs::new();
|
||||
vfs.mount_dir(&dir);
|
||||
assert_eq!(vfs.read("hello.txt").expect("read"), b"world");
|
||||
assert!(
|
||||
matches!(vfs.read("Hello.TXT"), Err(FsError::NotFound(_))),
|
||||
"дисковые пути регистрозависимы"
|
||||
);
|
||||
assert!(vfs.exists("hello.txt"));
|
||||
assert!(!vfs.exists("../hello.txt"));
|
||||
assert!(matches!(
|
||||
vfs.read("../hello.txt"),
|
||||
Err(FsError::InvalidPath(_))
|
||||
));
|
||||
assert!(matches!(vfs.read("nope.txt"), Err(FsError::NotFound(_))));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_mount_wins() {
|
||||
let base = std::env::temp_dir().join(format!("source-rs-fs-prio-{}", std::process::id()));
|
||||
let a = base.join("a");
|
||||
let b = base.join("b");
|
||||
std::fs::create_dir_all(&a).expect("a");
|
||||
std::fs::create_dir_all(&b).expect("b");
|
||||
std::fs::write(a.join("f.txt"), b"A").expect("write a");
|
||||
std::fs::write(b.join("f.txt"), b"B").expect("write b");
|
||||
|
||||
let mut vfs = VirtualFs::new();
|
||||
vfs.mount_dir(&a).mount_dir(&b);
|
||||
assert_eq!(vfs.read("f.txt").expect("read"), b"A");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
}
|
||||
654
crates/fs/src/vpk.rs
Normal file
654
crates/fs/src/vpk.rs
Normal file
|
|
@ -0,0 +1,654 @@
|
|||
//! Разбор и чтение контейнеров VPK (Valve Pak), версии 1 и 2.
|
||||
//!
|
||||
//! Структура (упрощённо):
|
||||
//! ```text
|
||||
//! header (12 байт v1 / 28 байт v2)
|
||||
//! tree (дерево расширений/каталогов/файлов, нуль-термированные строки)
|
||||
//! [v2] file data section (встроенные данные)
|
||||
//! [v2] archive MD5 section (28 байт на запись)
|
||||
//! [v2] other MD5 section (48 байт)
|
||||
//! [v2] signature section
|
||||
//! ```
|
||||
//! Файлы с `archive_index == 0x7FFF` лежат в самом `_dir.vpk` (смещение
|
||||
//! отсчитывается от конца дерева), остальные — во внешних архивах
|
||||
//! `<stem>_NNN.vpk` (stem — имя dir-файла без `_dir.vpk`).
|
||||
|
||||
use crate::error::FsError;
|
||||
use rustc_hash::FxHashMap;
|
||||
use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const VPK_MAGIC: u32 = 0x55aa_1234;
|
||||
/// Терминатор записи каталога.
|
||||
pub const VPK_DIRECTORY_ENTRY_TERMINATOR: u16 = 0xffff;
|
||||
/// Данные хранятся в dir-файле, а не во внешнем архиве.
|
||||
pub const VPK_EMBEDDED_ARCHIVE_INDEX: u16 = 0x7fff;
|
||||
|
||||
const HEADER_SIZE_V1: u64 = 12;
|
||||
const HEADER_SIZE_V2: u64 = 28;
|
||||
|
||||
// --- Лимиты (bounded-парсер) -------------------------------------------------
|
||||
/// Максимальный размер дерева.
|
||||
const MAX_TREE_SIZE: u64 = 1 << 30;
|
||||
/// Максимальное число записей.
|
||||
const MAX_ENTRIES: usize = 8 * 1024 * 1024;
|
||||
/// Максимальная длина строки в дереве.
|
||||
const MAX_STRING_LEN: usize = 4096;
|
||||
/// Максимальный размер dir-файла, который мы готовы прочитать целиком.
|
||||
const MAX_DIR_FILE: u64 = 256 * 1024 * 1024;
|
||||
|
||||
/// Запись индекса VPK.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VpkEntry {
|
||||
/// Путь в исходном регистре (с `/`).
|
||||
pub path: String,
|
||||
/// CRC32 содержимого (preload + данные архива).
|
||||
pub crc32: u32,
|
||||
/// Размер preload-части (хранится прямо в дереве).
|
||||
pub preload_size: u16,
|
||||
/// Индекс архива (`0x7FFF` — данные в dir-файле).
|
||||
pub archive_index: u16,
|
||||
/// Смещение данных в архиве (или в data-секции dir-файла).
|
||||
pub offset: u32,
|
||||
/// Длина данных в архиве.
|
||||
pub length: u32,
|
||||
/// Preload-байты (обычно пусто).
|
||||
pub preload: Vec<u8>,
|
||||
}
|
||||
|
||||
impl VpkEntry {
|
||||
/// Полная длина содержимого.
|
||||
pub fn total_length(&self) -> usize {
|
||||
self.preload.len() + self.length as usize
|
||||
}
|
||||
|
||||
/// Данные лежат в dir-файле.
|
||||
pub fn is_embedded(&self) -> bool {
|
||||
self.archive_index == VPK_EMBEDDED_ARCHIVE_INDEX
|
||||
}
|
||||
}
|
||||
|
||||
/// Информация о контейнере (для диагностики).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VpkInfo {
|
||||
pub version: u32,
|
||||
pub entry_count: usize,
|
||||
pub header_size: u64,
|
||||
pub tree_size: u32,
|
||||
pub file_data_section_size: u32,
|
||||
pub archive_md5_section_size: u32,
|
||||
pub other_md5_section_size: u32,
|
||||
pub signature_section_size: u32,
|
||||
}
|
||||
|
||||
/// Разобранный VPK-контейнер.
|
||||
pub struct Vpk {
|
||||
dir_path: PathBuf,
|
||||
stem: String,
|
||||
header_size: u64,
|
||||
tree_size: u32,
|
||||
version: u32,
|
||||
file_data_section_size: u32,
|
||||
archive_md5_section_size: u32,
|
||||
other_md5_section_size: u32,
|
||||
signature_section_size: u32,
|
||||
entries: FxHashMap<String, VpkEntry>,
|
||||
}
|
||||
|
||||
impl Vpk {
|
||||
/// Читает и разбирает `*_dir.vpk`.
|
||||
pub fn parse(dir_path: &Path) -> Result<Vpk, FsError> {
|
||||
let meta = std::fs::metadata(dir_path)?;
|
||||
if meta.len() > MAX_DIR_FILE {
|
||||
return Err(FsError::VpkCorrupt(format!(
|
||||
"{}: dir-файл больше {} МиБ — потоковый разбор появится в M1",
|
||||
dir_path.display(),
|
||||
MAX_DIR_FILE / (1024 * 1024)
|
||||
)));
|
||||
}
|
||||
let data = std::fs::read(dir_path)?;
|
||||
Self::parse_bytes(dir_path, &data)
|
||||
}
|
||||
|
||||
/// Разбирает VPK из буфера (используется тестами и fuzzing'ом).
|
||||
pub fn parse_bytes(dir_path: &Path, data: &[u8]) -> Result<Vpk, FsError> {
|
||||
if data.len() < HEADER_SIZE_V1 as usize {
|
||||
return Err(FsError::VpkCorrupt("файл короче заголовка".to_string()));
|
||||
}
|
||||
|
||||
let magic = read_u32(data, 0)?;
|
||||
if magic != VPK_MAGIC {
|
||||
return Err(FsError::VpkBadMagic { found: magic });
|
||||
}
|
||||
let version = read_u32(data, 4)?;
|
||||
if version != 1 && version != 2 {
|
||||
return Err(FsError::VpkBadVersion(version));
|
||||
}
|
||||
let tree_size = read_u32(data, 8)?;
|
||||
|
||||
let header_size = if version == 2 {
|
||||
HEADER_SIZE_V2
|
||||
} else {
|
||||
HEADER_SIZE_V1
|
||||
};
|
||||
let mut file_data_section_size = 0;
|
||||
let mut archive_md5_section_size = 0;
|
||||
let mut other_md5_section_size = 0;
|
||||
let mut signature_section_size = 0;
|
||||
|
||||
if version == 2 {
|
||||
file_data_section_size = read_u32(data, 12)?;
|
||||
archive_md5_section_size = read_u32(data, 16)?;
|
||||
other_md5_section_size = read_u32(data, 20)?;
|
||||
signature_section_size = read_u32(data, 24)?;
|
||||
}
|
||||
|
||||
if tree_size as u64 > MAX_TREE_SIZE {
|
||||
return Err(FsError::VpkCorrupt(format!(
|
||||
"дерево слишком большое: {tree_size}"
|
||||
)));
|
||||
}
|
||||
|
||||
let header_end = header_size;
|
||||
let tree_end = header_end
|
||||
.checked_add(tree_size as u64)
|
||||
.ok_or_else(|| FsError::VpkCorrupt("переполнение размера дерева".to_string()))?;
|
||||
if tree_end > data.len() as u64 {
|
||||
return Err(FsError::VpkCorrupt(format!(
|
||||
"дерево выходит за пределы файла: {} > {}",
|
||||
tree_end,
|
||||
data.len()
|
||||
)));
|
||||
}
|
||||
|
||||
// Суммарная длина секций не должна выходить за файл (мягкая проверка:
|
||||
// подписанные/обрезанные файлы встречаются, поэтому только предупреждаем).
|
||||
let sections_end = tree_end
|
||||
.checked_add(file_data_section_size as u64)
|
||||
.and_then(|v| v.checked_add(archive_md5_section_size as u64))
|
||||
.and_then(|v| v.checked_add(other_md5_section_size as u64))
|
||||
.and_then(|v| v.checked_add(signature_section_size as u64));
|
||||
if sections_end.is_none_or(|end| end > data.len() as u64) {
|
||||
tracing::debug!(path = %dir_path.display(), "VPK: секции выходят за пределы файла (файл обрезан?)");
|
||||
}
|
||||
|
||||
let tree = &data[header_end as usize..tree_end as usize];
|
||||
let entries = parse_tree(tree)?;
|
||||
|
||||
let stem = archive_stem(dir_path);
|
||||
Ok(Vpk {
|
||||
dir_path: dir_path.to_path_buf(),
|
||||
stem,
|
||||
header_size,
|
||||
tree_size,
|
||||
version,
|
||||
file_data_section_size,
|
||||
archive_md5_section_size,
|
||||
other_md5_section_size,
|
||||
signature_section_size,
|
||||
entries,
|
||||
})
|
||||
}
|
||||
|
||||
/// Путь к dir-файлу.
|
||||
pub fn dir_path(&self) -> &Path {
|
||||
&self.dir_path
|
||||
}
|
||||
|
||||
/// Базовое имя для внешних архивов (без `_dir.vpk`).
|
||||
pub fn stem(&self) -> &str {
|
||||
&self.stem
|
||||
}
|
||||
|
||||
/// Диагностическая информация.
|
||||
pub fn info(&self) -> VpkInfo {
|
||||
VpkInfo {
|
||||
version: self.version,
|
||||
entry_count: self.entries.len(),
|
||||
header_size: self.header_size,
|
||||
tree_size: self.tree_size,
|
||||
file_data_section_size: self.file_data_section_size,
|
||||
archive_md5_section_size: self.archive_md5_section_size,
|
||||
other_md5_section_size: self.other_md5_section_size,
|
||||
signature_section_size: self.signature_section_size,
|
||||
}
|
||||
}
|
||||
|
||||
/// Число записей.
|
||||
pub fn entry_count(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// Поиск записи (регистронезависимо, `/`-разделители).
|
||||
pub fn find(&self, path: &str) -> Option<&VpkEntry> {
|
||||
let key = normalize_lookup(path)?;
|
||||
self.entries.get(&key)
|
||||
}
|
||||
|
||||
/// Все записи.
|
||||
pub fn entries(&self) -> impl Iterator<Item = &VpkEntry> {
|
||||
self.entries.values()
|
||||
}
|
||||
|
||||
/// Путь к внешнему архиву `NNN`.
|
||||
pub fn archive_path(&self, index: u16) -> PathBuf {
|
||||
let parent = self.dir_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
parent.join(format!("{}_{:03}.vpk", self.stem, index))
|
||||
}
|
||||
|
||||
/// Читает файл по пути (с опциональной проверкой CRC).
|
||||
pub fn read(&self, path: &str, verify_crc: bool) -> Result<Vec<u8>, FsError> {
|
||||
let entry = self
|
||||
.find(path)
|
||||
.ok_or_else(|| FsError::NotFound(path.to_string()))?;
|
||||
self.read_entry(entry, verify_crc)
|
||||
}
|
||||
|
||||
/// Читает запись (с опциональной проверкой CRC).
|
||||
pub fn read_entry(&self, entry: &VpkEntry, verify_crc: bool) -> Result<Vec<u8>, FsError> {
|
||||
let total = entry.total_length();
|
||||
let mut out = Vec::with_capacity(total);
|
||||
out.extend_from_slice(&entry.preload);
|
||||
|
||||
if entry.length > 0 {
|
||||
let (source_path, base_offset) = if entry.is_embedded() {
|
||||
// Данные в dir-файле сразу после дерева.
|
||||
(
|
||||
self.dir_path.clone(),
|
||||
self.header_size + self.tree_size as u64,
|
||||
)
|
||||
} else {
|
||||
(self.archive_path(entry.archive_index), 0)
|
||||
};
|
||||
|
||||
if !source_path.is_file() {
|
||||
return Err(FsError::MissingArchive(source_path));
|
||||
}
|
||||
let offset = base_offset
|
||||
.checked_add(entry.offset as u64)
|
||||
.ok_or_else(|| FsError::VpkCorrupt("переполнение смещения".to_string()))?;
|
||||
let chunk = read_range(&source_path, offset, entry.length as usize)?;
|
||||
out.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
if verify_crc {
|
||||
let actual = crc32fast::hash(&out);
|
||||
if actual != entry.crc32 {
|
||||
return Err(FsError::CrcMismatch {
|
||||
path: entry.path.clone(),
|
||||
expected: entry.crc32,
|
||||
actual,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Разбор дерева
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct TreeReader<'a> {
|
||||
data: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> TreeReader<'a> {
|
||||
fn remaining(&self) -> usize {
|
||||
self.data.len() - self.pos
|
||||
}
|
||||
|
||||
fn read_cstr(&mut self) -> Result<&'a str, FsError> {
|
||||
if self.pos >= self.data.len() {
|
||||
return Err(FsError::VpkCorrupt("неожиданный конец дерева".to_string()));
|
||||
}
|
||||
let rest = &self.data[self.pos..];
|
||||
let end = rest
|
||||
.iter()
|
||||
.position(|&b| b == 0)
|
||||
.ok_or_else(|| FsError::VpkCorrupt("нет нуль-терминатора строки".to_string()))?;
|
||||
if end > MAX_STRING_LEN {
|
||||
return Err(FsError::VpkCorrupt(format!(
|
||||
"строка длиной {end} превышает лимит"
|
||||
)));
|
||||
}
|
||||
let s = std::str::from_utf8(&rest[..end]).map_err(|e| FsError::VpkUtf8(e.to_string()))?;
|
||||
self.pos += end + 1;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn take(&mut self, n: usize) -> Result<&'a [u8], FsError> {
|
||||
if self.remaining() < n {
|
||||
return Err(FsError::VpkCorrupt(
|
||||
"preload выходит за границы дерева".to_string(),
|
||||
));
|
||||
}
|
||||
let slice = &self.data[self.pos..self.pos + n];
|
||||
self.pos += n;
|
||||
Ok(slice)
|
||||
}
|
||||
|
||||
fn read_u16(&mut self) -> Result<u16, FsError> {
|
||||
let bytes = self.take(2)?;
|
||||
Ok(u16::from_le_bytes([bytes[0], bytes[1]]))
|
||||
}
|
||||
|
||||
fn read_u32(&mut self) -> Result<u32, FsError> {
|
||||
let bytes = self.take(4)?;
|
||||
Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_tree(tree: &[u8]) -> Result<FxHashMap<String, VpkEntry>, FsError> {
|
||||
let mut rd = TreeReader { data: tree, pos: 0 };
|
||||
let mut entries: FxHashMap<String, VpkEntry> = FxHashMap::default();
|
||||
|
||||
// Дерево может состоять из нескольких чанков; каждый оканчивается пустым расширением.
|
||||
while rd.remaining() > 0 {
|
||||
loop {
|
||||
let ext = rd.read_cstr()?;
|
||||
if ext.is_empty() {
|
||||
break;
|
||||
}
|
||||
loop {
|
||||
let dir = rd.read_cstr()?;
|
||||
if dir.is_empty() {
|
||||
break;
|
||||
}
|
||||
loop {
|
||||
let name = rd.read_cstr()?;
|
||||
if name.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let crc32 = rd.read_u32()?;
|
||||
let preload_size = rd.read_u16()?;
|
||||
let archive_index = rd.read_u16()?;
|
||||
let offset = rd.read_u32()?;
|
||||
let length = rd.read_u32()?;
|
||||
let terminator = rd.read_u16()?;
|
||||
if terminator != VPK_DIRECTORY_ENTRY_TERMINATOR {
|
||||
return Err(FsError::VpkCorrupt(format!(
|
||||
"неверный терминатор записи: 0x{terminator:04x}"
|
||||
)));
|
||||
}
|
||||
let preload = rd.take(preload_size as usize)?.to_vec();
|
||||
|
||||
if entries.len() >= MAX_ENTRIES {
|
||||
return Err(FsError::VpkCorrupt(format!(
|
||||
"записей больше лимита ({MAX_ENTRIES})"
|
||||
)));
|
||||
}
|
||||
|
||||
let path = build_path(dir, name, ext);
|
||||
let key = path.to_ascii_lowercase();
|
||||
if entries.contains_key(&key) {
|
||||
tracing::debug!(path = %path, "VPK: дубликат пути, оставляем последний");
|
||||
}
|
||||
entries.insert(
|
||||
key,
|
||||
VpkEntry {
|
||||
path,
|
||||
crc32,
|
||||
preload_size,
|
||||
archive_index,
|
||||
offset,
|
||||
length,
|
||||
preload,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn build_path(dir: &str, name: &str, ext: &str) -> String {
|
||||
// Пустое расширение-строка " " означает «без расширения» (соглашение VPK).
|
||||
let dir = if dir == " " { "" } else { dir };
|
||||
let mut path = String::with_capacity(dir.len() + name.len() + ext.len() + 2);
|
||||
if !dir.is_empty() {
|
||||
path.push_str(dir);
|
||||
path.push('/');
|
||||
}
|
||||
path.push_str(name);
|
||||
if !ext.is_empty() && ext != " " {
|
||||
path.push('.');
|
||||
path.push_str(ext);
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// Нормализует путь для поиска: `\` → `/`, отбрасывает ведущие `/`,
|
||||
/// запрещает `.`/`..`/пустые компоненты и NUL. Возвращает ключ в нижнем регистре.
|
||||
pub fn normalize_lookup(path: &str) -> Option<String> {
|
||||
let replaced = path.replace('\\', "/");
|
||||
let trimmed = replaced.trim_start_matches('/');
|
||||
if trimmed.is_empty() || trimmed.contains('\0') {
|
||||
return None;
|
||||
}
|
||||
for component in trimmed.split('/') {
|
||||
if component.is_empty() || component == "." || component == ".." {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(trimmed.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn archive_stem(dir_path: &Path) -> String {
|
||||
let file_name = dir_path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
if let Some(stem) = file_name.strip_suffix("_dir.vpk") {
|
||||
return stem.to_string();
|
||||
}
|
||||
if let Some(stem) = file_name.strip_suffix(".vpk") {
|
||||
return stem.to_string();
|
||||
}
|
||||
file_name
|
||||
}
|
||||
|
||||
fn read_u32(data: &[u8], offset: usize) -> Result<u32, FsError> {
|
||||
let slice = data
|
||||
.get(offset..offset + 4)
|
||||
.ok_or_else(|| FsError::VpkCorrupt("неожиданный конец файла".to_string()))?;
|
||||
Ok(u32::from_le_bytes([slice[0], slice[1], slice[2], slice[3]]))
|
||||
}
|
||||
|
||||
fn read_range(path: &Path, offset: u64, len: usize) -> Result<Vec<u8>, FsError> {
|
||||
let file = File::open(path)?;
|
||||
let file_len = file.metadata()?.len();
|
||||
let end = offset
|
||||
.checked_add(len as u64)
|
||||
.ok_or_else(|| FsError::VpkCorrupt("переполнение диапазона чтения".to_string()))?;
|
||||
if end > file_len {
|
||||
return Err(FsError::VpkCorrupt(format!(
|
||||
"{}: диапазон {offset}..{end} выходит за размер файла {file_len}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let mut buf = vec![0u8; len];
|
||||
read_exact_at(&file, &mut buf, offset)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn read_exact_at(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result<()> {
|
||||
use std::os::unix::fs::FileExt;
|
||||
file.read_exact_at(buf, offset)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn read_exact_at(file: &File, mut buf: &mut [u8], mut offset: u64) -> std::io::Result<()> {
|
||||
use std::os::windows::fs::FileExt;
|
||||
while !buf.is_empty() {
|
||||
match file.seek_read(buf, offset) {
|
||||
Ok(0) => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::UnexpectedEof,
|
||||
"неожиданный конец файла",
|
||||
));
|
||||
}
|
||||
Ok(n) => {
|
||||
buf = &mut buf[n..];
|
||||
offset += n as u64;
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn read_exact_at(file: &File, buf: &mut [u8], offset: u64) -> std::io::Result<()> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let mut reader = std::io::BufReader::new(file);
|
||||
reader.seek(SeekFrom::Start(offset))?;
|
||||
reader.read_exact(buf)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Тесты
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Собирает минимальный VPK v2 с одной встроенной записью и одной пустой.
|
||||
fn build_test_vpk() -> Vec<u8> {
|
||||
let payload = b"hello vpk";
|
||||
let crc = crc32fast::hash(payload);
|
||||
|
||||
// дерево: ext "txt", dir "test", name "greeting" -> запись; затем пустые строки.
|
||||
let mut tree = Vec::new();
|
||||
tree.extend_from_slice(b"txt\0test\0greeting\0");
|
||||
tree.extend_from_slice(&crc.to_le_bytes());
|
||||
tree.extend_from_slice(&0u16.to_le_bytes()); // preload
|
||||
tree.extend_from_slice(&VPK_EMBEDDED_ARCHIVE_INDEX.to_le_bytes());
|
||||
tree.extend_from_slice(&0u32.to_le_bytes()); // offset в data-секции
|
||||
tree.extend_from_slice(&(payload.len() as u32).to_le_bytes());
|
||||
tree.extend_from_slice(&VPK_DIRECTORY_ENTRY_TERMINATOR.to_le_bytes());
|
||||
tree.extend_from_slice(b"\0\0\0"); // конец файлов/каталогов/расширений
|
||||
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&VPK_MAGIC.to_le_bytes());
|
||||
out.extend_from_slice(&2u32.to_le_bytes());
|
||||
out.extend_from_slice(&(tree.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(payload.len() as u32).to_le_bytes()); // file data section
|
||||
out.extend_from_slice(&0u32.to_le_bytes()); // archive md5 section
|
||||
out.extend_from_slice(&48u32.to_le_bytes()); // other md5 section
|
||||
out.extend_from_slice(&0u32.to_le_bytes()); // signature section
|
||||
out.extend_from_slice(&tree);
|
||||
out.extend_from_slice(payload);
|
||||
out.extend_from_slice(&[0u8; 48]); // other md5
|
||||
out
|
||||
}
|
||||
|
||||
fn temp_dir_vpk(name: &str, data: &[u8]) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join("source-rs-vpk-tests");
|
||||
std::fs::create_dir_all(&dir).expect("временный каталог");
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, data).expect("запись временного VPK");
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_embedded_entry() {
|
||||
let data = build_test_vpk();
|
||||
let path = temp_dir_vpk("embedded_dir.vpk", &data);
|
||||
let vpk = Vpk::parse(&path).expect("должен разобраться");
|
||||
assert_eq!(vpk.info().version, 2);
|
||||
assert_eq!(vpk.entry_count(), 1);
|
||||
let entry = vpk
|
||||
.find("TEST/Greeting.TXT")
|
||||
.expect("поиск регистронезависим");
|
||||
assert_eq!(entry.total_length(), 9);
|
||||
let content = vpk.read_entry(entry, true).expect("чтение с CRC");
|
||||
assert_eq!(content, b"hello vpk");
|
||||
assert!(vpk.read("test/greeting.txt", true).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_magic_is_error() {
|
||||
let data = [0u8; 64];
|
||||
let result = Vpk::parse_bytes(Path::new("x_dir.vpk"), &data);
|
||||
assert!(matches!(result, Err(FsError::VpkBadMagic { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_tree_is_error() {
|
||||
let data = build_test_vpk();
|
||||
for cut in [12usize, 20, 30, 40, 60] {
|
||||
let part = &data[..cut.min(data.len())];
|
||||
let res = Vpk::parse_bytes(Path::new("x_dir.vpk"), part);
|
||||
assert!(res.is_err(), "обрезка до {cut} байт должна давать ошибку");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_never_panics() {
|
||||
// Детерминированный псевдослучайный мусор.
|
||||
let mut state: u32 = 0x1234_5678;
|
||||
for len in [0usize, 1, 4, 12, 28, 64, 256, 1024] {
|
||||
let mut data = Vec::with_capacity(len);
|
||||
for _ in 0..len {
|
||||
state = state.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
data.push((state >> 24) as u8);
|
||||
}
|
||||
// Иногда подставляем магию, чтобы пройти дальше заголовка.
|
||||
if len >= 12 && len % 2 == 0 {
|
||||
data[0..4].copy_from_slice(&VPK_MAGIC.to_le_bytes());
|
||||
data[4..8].copy_from_slice(&2u32.to_le_bytes());
|
||||
}
|
||||
let _ = Vpk::parse_bytes(Path::new("fuzz_dir.vpk"), &data);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_archive_reported() {
|
||||
// Запись во внешнем архиве, которого нет.
|
||||
let mut tree = Vec::new();
|
||||
tree.extend_from_slice(b"bin\0model\0x\0");
|
||||
tree.extend_from_slice(&0u32.to_le_bytes()); // crc
|
||||
tree.extend_from_slice(&0u16.to_le_bytes()); // preload
|
||||
tree.extend_from_slice(&0u16.to_le_bytes()); // archive 0
|
||||
tree.extend_from_slice(&0u32.to_le_bytes()); // offset
|
||||
tree.extend_from_slice(&16u32.to_le_bytes()); // length
|
||||
tree.extend_from_slice(&VPK_DIRECTORY_ENTRY_TERMINATOR.to_le_bytes());
|
||||
tree.extend_from_slice(b"\0\0\0");
|
||||
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&VPK_MAGIC.to_le_bytes());
|
||||
out.extend_from_slice(&2u32.to_le_bytes());
|
||||
out.extend_from_slice(&(tree.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&0u32.to_le_bytes());
|
||||
out.extend_from_slice(&0u32.to_le_bytes());
|
||||
out.extend_from_slice(&0u32.to_le_bytes());
|
||||
out.extend_from_slice(&0u32.to_le_bytes());
|
||||
out.extend_from_slice(&tree);
|
||||
|
||||
let vpk = Vpk::parse_bytes(Path::new("/tmp/nonexistent_dir.vpk"), &out).expect("разбор");
|
||||
let err = vpk.read("model/x.bin", true).unwrap_err();
|
||||
assert!(matches!(err, FsError::MissingArchive(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_rejects_traversal() {
|
||||
assert!(normalize_lookup("../etc/passwd").is_none());
|
||||
assert!(normalize_lookup("a/../../b").is_none());
|
||||
assert!(normalize_lookup("/abs/path").is_some());
|
||||
assert!(normalize_lookup("").is_none());
|
||||
assert_eq!(
|
||||
normalize_lookup("Models\\Props\\x.mdl").as_deref(),
|
||||
Some("models/props/x.mdl")
|
||||
);
|
||||
}
|
||||
}
|
||||
139
crates/fs/tests/real_vpk.rs
Normal file
139
crates/fs/tests/real_vpk.rs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
//! Интеграционные тесты на реальных VPK из локальной установки Garry's Mod.
|
||||
//!
|
||||
//! Если ассетов нет (например, в CI) — тест пропускается с сообщением.
|
||||
//! Путь можно задать переменной окружения `SOURCE_RS_ASSETS`.
|
||||
|
||||
use fs::vpk::Vpk;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn assets_root() -> Option<PathBuf> {
|
||||
if let Some(path) = std::env::var_os("SOURCE_RS_ASSETS") {
|
||||
let path = PathBuf::from(path);
|
||||
if path.is_dir() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
let home = std::env::var_os("HOME")?;
|
||||
let path = PathBuf::from(home).join("gmod_server/server");
|
||||
path.is_dir().then_some(path)
|
||||
}
|
||||
|
||||
fn collect_dir_vpks(root: &Path) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
for sub in [".", "garrysmod", "sourceengine", "platform"] {
|
||||
let dir = root.join(sub);
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let is_dir_vpk = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().ends_with("_dir.vpk"))
|
||||
.unwrap_or(false);
|
||||
if is_dir_vpk {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_vpks_parse_and_sample_reads() {
|
||||
let Some(root) = assets_root() else {
|
||||
eprintln!("пропуск: ассеты Garry's Mod не найдены (задайте SOURCE_RS_ASSETS)");
|
||||
return;
|
||||
};
|
||||
|
||||
let vpks = collect_dir_vpks(&root);
|
||||
assert!(
|
||||
!vpks.is_empty(),
|
||||
"в {root:?} не найдено ни одного *_dir.vpk"
|
||||
);
|
||||
|
||||
let mut total_entries = 0usize;
|
||||
let mut checked = 0usize;
|
||||
let mut total_bytes = 0u64;
|
||||
let mut skipped_archives = 0usize;
|
||||
|
||||
for path in &vpks {
|
||||
let vpk = Vpk::parse(path).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
let info = vpk.info();
|
||||
assert!(info.entry_count > 0, "{}: VPK без записей", path.display());
|
||||
total_entries += info.entry_count;
|
||||
|
||||
let with_data: Vec<_> = vpk.entries().filter(|e| e.length > 0).collect();
|
||||
let step = (with_data.len() / 32).max(1);
|
||||
for entry in with_data.iter().step_by(step).take(32) {
|
||||
let archive_available =
|
||||
entry.is_embedded() || vpk.archive_path(entry.archive_index).is_file();
|
||||
if !archive_available {
|
||||
skipped_archives += 1;
|
||||
continue;
|
||||
}
|
||||
let data = vpk
|
||||
.read_entry(entry, true)
|
||||
.unwrap_or_else(|err| panic!("{} {:?}: {err}", path.display(), entry.path));
|
||||
assert_eq!(data.len(), entry.total_length());
|
||||
checked += 1;
|
||||
total_bytes += data.len() as u64;
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"VPK: {} шт, записей {}, проверено {} ({} МиБ), пропущено архивов {}",
|
||||
vpks.len(),
|
||||
total_entries,
|
||||
checked,
|
||||
total_bytes / (1024 * 1024),
|
||||
skipped_archives
|
||||
);
|
||||
assert!(checked > 0, "не удалось проверить ни одной записи");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garrysmod_dir_vpk_is_v2_and_rich() {
|
||||
let Some(root) = assets_root() else {
|
||||
eprintln!("пропуск: ассеты Garry's Mod не найдены");
|
||||
return;
|
||||
};
|
||||
let dir_vpk = root.join("garrysmod/garrysmod_dir.vpk");
|
||||
if !dir_vpk.is_file() {
|
||||
eprintln!("пропуск: {} отсутствует", dir_vpk.display());
|
||||
return;
|
||||
}
|
||||
|
||||
let vpk = Vpk::parse(&dir_vpk).expect("garrysmod_dir.vpk должен разбираться");
|
||||
let info = vpk.info();
|
||||
assert_eq!(info.version, 2, "ожидается VPK v2");
|
||||
assert!(
|
||||
info.entry_count > 10_000,
|
||||
"ожидается много записей, получено {}",
|
||||
info.entry_count
|
||||
);
|
||||
|
||||
// В пакете заведомо есть модели (проверяем, что пути читаются и имеют расширения).
|
||||
assert!(
|
||||
vpk.entries().any(|e| e.path.ends_with(".mdl")),
|
||||
"ожидались модели (.mdl) в garrysmod_dir.vpk"
|
||||
);
|
||||
|
||||
// Все внешние архивы, на которые ссылается индекс, обязаны существовать.
|
||||
let mut missing: Vec<u16> = Vec::new();
|
||||
for entry in vpk.entries() {
|
||||
if entry.length > 0
|
||||
&& !entry.is_embedded()
|
||||
&& !vpk.archive_path(entry.archive_index).is_file()
|
||||
&& !missing.contains(&entry.archive_index)
|
||||
{
|
||||
missing.push(entry.archive_index);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"нет файлов архивов для индексов {missing:?}"
|
||||
);
|
||||
}
|
||||
7
crates/math/Cargo.toml
Normal file
7
crates/math/Cargo.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[package]
|
||||
name = "math"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
218
crates/math/src/lib.rs
Normal file
218
crates/math/src/lib.rs
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
//! Source-совместимая математика. Углы — в градусах (как QAngle в Source).
|
||||
//!
|
||||
//! Пока минимум, необходимый клиенту: [`Vec3`], [`Angle`], [`Aabb`].
|
||||
//! Расширяется в M1–M3 (матрицы, кватернионы, преобразования Source).
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::ops::{Add, AddAssign, Div, Mul, Neg, Sub, SubAssign};
|
||||
|
||||
/// Вектор из трёх `f32` (система Source: X — вперёд, Y — влево, Z — вверх).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||
pub struct Vec3 {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub z: f32,
|
||||
}
|
||||
|
||||
impl Vec3 {
|
||||
pub const ZERO: Vec3 = Vec3::new(0.0, 0.0, 0.0);
|
||||
|
||||
pub const fn new(x: f32, y: f32, z: f32) -> Self {
|
||||
Self { x, y, z }
|
||||
}
|
||||
|
||||
pub fn splat(v: f32) -> Self {
|
||||
Self::new(v, v, v)
|
||||
}
|
||||
|
||||
pub fn dot(self, other: Self) -> f32 {
|
||||
self.x * other.x + self.y * other.y + self.z * other.z
|
||||
}
|
||||
|
||||
pub fn cross(self, other: Self) -> Self {
|
||||
Self::new(
|
||||
self.y * other.z - self.z * other.y,
|
||||
self.z * other.x - self.x * other.z,
|
||||
self.x * other.y - self.y * other.x,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn length_squared(self) -> f32 {
|
||||
self.dot(self)
|
||||
}
|
||||
|
||||
pub fn length(self) -> f32 {
|
||||
self.length_squared().sqrt()
|
||||
}
|
||||
|
||||
pub fn distance(self, other: Self) -> f32 {
|
||||
(self - other).length()
|
||||
}
|
||||
|
||||
pub fn normalized(self) -> Self {
|
||||
let len = self.length();
|
||||
if len > 0.0 { self / len } else { Self::ZERO }
|
||||
}
|
||||
|
||||
pub fn is_finite(self) -> bool {
|
||||
self.x.is_finite() && self.y.is_finite() && self.z.is_finite()
|
||||
}
|
||||
|
||||
pub fn lerp(self, other: Self, t: f32) -> Self {
|
||||
self + (other - self) * t
|
||||
}
|
||||
}
|
||||
|
||||
impl Add for Vec3 {
|
||||
type Output = Vec3;
|
||||
fn add(self, rhs: Self) -> Self::Output {
|
||||
Vec3::new(self.x + rhs.x, self.y + rhs.y, self.z + rhs.z)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sub for Vec3 {
|
||||
type Output = Vec3;
|
||||
fn sub(self, rhs: Self) -> Self::Output {
|
||||
Vec3::new(self.x - rhs.x, self.y - rhs.y, self.z - rhs.z)
|
||||
}
|
||||
}
|
||||
|
||||
impl Neg for Vec3 {
|
||||
type Output = Vec3;
|
||||
fn neg(self) -> Self::Output {
|
||||
Vec3::new(-self.x, -self.y, -self.z)
|
||||
}
|
||||
}
|
||||
|
||||
impl Mul<f32> for Vec3 {
|
||||
type Output = Vec3;
|
||||
fn mul(self, rhs: f32) -> Self::Output {
|
||||
Vec3::new(self.x * rhs, self.y * rhs, self.z * rhs)
|
||||
}
|
||||
}
|
||||
|
||||
impl Div<f32> for Vec3 {
|
||||
type Output = Vec3;
|
||||
fn div(self, rhs: f32) -> Self::Output {
|
||||
Vec3::new(self.x / rhs, self.y / rhs, self.z / rhs)
|
||||
}
|
||||
}
|
||||
|
||||
impl AddAssign for Vec3 {
|
||||
fn add_assign(&mut self, rhs: Self) {
|
||||
*self = *self + rhs;
|
||||
}
|
||||
}
|
||||
|
||||
impl SubAssign for Vec3 {
|
||||
fn sub_assign(&mut self, rhs: Self) {
|
||||
*self = *self - rhs;
|
||||
}
|
||||
}
|
||||
|
||||
/// Углы Эйлера в градусах (порядок Source: pitch — вниз+, yaw — против часовой,
|
||||
/// roll — крен). Диапазоны нормализуются отдельными функциями при необходимости.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||
pub struct Angle {
|
||||
pub pitch: f32,
|
||||
pub yaw: f32,
|
||||
pub roll: f32,
|
||||
}
|
||||
|
||||
impl Angle {
|
||||
pub const ZERO: Angle = Angle::new(0.0, 0.0, 0.0);
|
||||
|
||||
pub const fn new(pitch: f32, yaw: f32, roll: f32) -> Self {
|
||||
Self { pitch, yaw, roll }
|
||||
}
|
||||
|
||||
pub fn is_finite(self) -> bool {
|
||||
self.pitch.is_finite() && self.yaw.is_finite() && self.roll.is_finite()
|
||||
}
|
||||
}
|
||||
|
||||
/// Ограничивающий параллелепипед.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Aabb {
|
||||
pub min: Vec3,
|
||||
pub max: Vec3,
|
||||
}
|
||||
|
||||
impl Aabb {
|
||||
pub const fn new(min: Vec3, max: Vec3) -> Self {
|
||||
Self { min, max }
|
||||
}
|
||||
|
||||
pub fn center(self) -> Vec3 {
|
||||
(self.min + self.max) * 0.5
|
||||
}
|
||||
|
||||
pub fn size(self) -> Vec3 {
|
||||
self.max - self.min
|
||||
}
|
||||
|
||||
pub fn contains(self, point: Vec3) -> bool {
|
||||
point.x >= self.min.x
|
||||
&& point.x <= self.max.x
|
||||
&& point.y >= self.min.y
|
||||
&& point.y <= self.max.y
|
||||
&& point.z >= self.min.z
|
||||
&& point.z <= self.max.z
|
||||
}
|
||||
|
||||
pub fn union(self, other: Self) -> Self {
|
||||
Self::new(
|
||||
Vec3::new(
|
||||
self.min.x.min(other.min.x),
|
||||
self.min.y.min(other.min.y),
|
||||
self.min.z.min(other.min.z),
|
||||
),
|
||||
Vec3::new(
|
||||
self.max.x.max(other.max.x),
|
||||
self.max.y.max(other.max.y),
|
||||
self.max.z.max(other.max.z),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_finite(self) -> bool {
|
||||
self.min.is_finite() && self.max.is_finite()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn vec_ops() {
|
||||
let a = Vec3::new(1.0, 0.0, 0.0);
|
||||
let b = Vec3::new(0.0, 1.0, 0.0);
|
||||
assert_eq!(a.dot(b), 0.0);
|
||||
assert_eq!(a.cross(b), Vec3::new(0.0, 0.0, 1.0));
|
||||
assert_eq!(Vec3::new(3.0, 4.0, 0.0).length(), 5.0);
|
||||
assert!(
|
||||
(Vec3::new(0.0, 0.0, 2.0).normalized() - Vec3::new(0.0, 0.0, 1.0)).length()
|
||||
< f32::EPSILON
|
||||
);
|
||||
assert_eq!(Vec3::ZERO.normalized(), Vec3::ZERO);
|
||||
assert!((a.lerp(b, 0.5) - Vec3::new(0.5, 0.5, 0.0)).length() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aabb_basics() {
|
||||
let bb = Aabb::new(Vec3::new(-1.0, -1.0, -1.0), Vec3::new(1.0, 1.0, 1.0));
|
||||
assert_eq!(bb.center(), Vec3::ZERO);
|
||||
assert_eq!(bb.size(), Vec3::splat(2.0));
|
||||
assert!(bb.contains(Vec3::ZERO));
|
||||
assert!(!bb.contains(Vec3::splat(2.0)));
|
||||
let bb2 = Aabb::new(Vec3::splat(1.0), Vec3::splat(3.0));
|
||||
assert_eq!(bb.union(bb2).max, Vec3::splat(3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angles_finite() {
|
||||
assert!(Angle::new(45.0, -90.0, 0.0).is_finite());
|
||||
assert!(!Angle::new(f32::NAN, 0.0, 0.0).is_finite());
|
||||
}
|
||||
}
|
||||
13
crates/tier/Cargo.toml
Normal file
13
crates/tier/Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[package]
|
||||
name = "tier"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
parking_lot.workspace = true
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
148
crates/tier/src/config.rs
Normal file
148
crates/tier/src/config.rs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
//! Конфигурация Source RS: пути к ассетам, кэшу, сервер по умолчанию.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Содержимое `config.toml` (все поля опциональны).
|
||||
#[derive(Debug, Default, serde::Deserialize)]
|
||||
pub struct ConfigFile {
|
||||
/// Корень установки Garry's Mod (папка, содержащая `garrysmod/` и `sourceengine/`).
|
||||
pub assets: Option<PathBuf>,
|
||||
/// Каталог кэшей (индексы VPK, обработанные ассеты и т.п.).
|
||||
pub cache_dir: Option<PathBuf>,
|
||||
/// Сервер по умолчанию (`host:port`).
|
||||
pub server: Option<String>,
|
||||
}
|
||||
|
||||
/// Итоговая конфигурация процесса.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub assets: Option<PathBuf>,
|
||||
pub cache_dir: PathBuf,
|
||||
pub server: Option<String>,
|
||||
pub config_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Ошибка чтения конфига.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("не удалось прочитать {path}: {source}")]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("не удалось разобрать {path}: {source}")]
|
||||
Parse {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: toml::de::Error,
|
||||
},
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Загружает конфиг из `~/.config/source-rs/config.toml` (если есть),
|
||||
/// затем применяет переопределения из CLI (они приоритетнее).
|
||||
pub fn load(cli_assets: Option<PathBuf>, cli_cache: Option<PathBuf>) -> Config {
|
||||
let config_path = default_config_path();
|
||||
let file = match &config_path {
|
||||
Some(path) if path.is_file() => match load_file(path) {
|
||||
Ok(file) => file,
|
||||
Err(err) => {
|
||||
tracing::warn!("{err}");
|
||||
ConfigFile::default()
|
||||
}
|
||||
},
|
||||
_ => ConfigFile::default(),
|
||||
};
|
||||
|
||||
Config {
|
||||
assets: cli_assets.or(file.assets),
|
||||
cache_dir: cli_cache
|
||||
.or(file.cache_dir)
|
||||
.unwrap_or_else(default_cache_dir),
|
||||
server: file.server,
|
||||
config_path,
|
||||
}
|
||||
}
|
||||
|
||||
/// Короткое описание для логов.
|
||||
pub fn describe(&self) -> String {
|
||||
let assets = self
|
||||
.assets
|
||||
.as_ref()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|| "<авто-определение>".to_string());
|
||||
let cfg = self
|
||||
.config_path
|
||||
.as_ref()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|| "<нет>".to_string());
|
||||
format!(
|
||||
"assets={assets} cache={} config={cfg}",
|
||||
self.cache_dir.display()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Читает и разбирает файл конфигурации.
|
||||
pub fn load_file(path: &Path) -> Result<ConfigFile, ConfigError> {
|
||||
let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
toml::from_str(&text).map_err(|source| ConfigError::Parse {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
fn home_dir() -> Option<PathBuf> {
|
||||
std::env::var_os("HOME").map(PathBuf::from)
|
||||
}
|
||||
|
||||
fn default_config_path() -> Option<PathBuf> {
|
||||
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
|
||||
return Some(PathBuf::from(xdg).join("source-rs/config.toml"));
|
||||
}
|
||||
home_dir().map(|home| home.join(".config/source-rs/config.toml"))
|
||||
}
|
||||
|
||||
fn default_cache_dir() -> PathBuf {
|
||||
if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
|
||||
return PathBuf::from(xdg).join("source-rs");
|
||||
}
|
||||
home_dir()
|
||||
.map(|home| home.join(".cache/source-rs"))
|
||||
.unwrap_or_else(|| PathBuf::from("cache"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_config_file() {
|
||||
let file: ConfigFile =
|
||||
toml::from_str("assets = \"/games/gmod\"\nserver = \"127.0.0.1:27015\"\n")
|
||||
.expect("toml должен разобраться");
|
||||
assert_eq!(file.assets.as_deref(), Some(Path::new("/games/gmod")));
|
||||
assert_eq!(file.server.as_deref(), Some("127.0.0.1:27015"));
|
||||
assert!(file.cache_dir.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_overrides_file() {
|
||||
let file: ConfigFile = toml::from_str("assets = \"/from/file\"\n").expect("toml");
|
||||
let cli = Some(PathBuf::from("/from/cli"));
|
||||
let assets = cli.or(file.assets);
|
||||
assert_eq!(assets.as_deref(), Some(Path::new("/from/cli")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_are_absolute_when_home_present() {
|
||||
if std::env::var_os("HOME").is_some() {
|
||||
let dir = default_cache_dir();
|
||||
assert!(dir.is_absolute(), "кэш должен быть абсолютным: {dir:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
398
crates/tier/src/convars.rs
Normal file
398
crates/tier/src/convars.rs
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
//! Конвары — именованные настройки с типизированным доступом (аналог ConVar из Source).
|
||||
//!
|
||||
//! Использование:
|
||||
//! ```ignore
|
||||
//! static R_FOO: ConVar = ConVar::new_float("r_foo", 1.0, "Описание");
|
||||
//! tier::convars::register(&R_FOO);
|
||||
//! let v = R_FOO.get_float();
|
||||
//! ```
|
||||
|
||||
use parking_lot::RwLock;
|
||||
use std::fmt;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
/// Значение конвара (динамическое представление).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ConValue {
|
||||
Bool(bool),
|
||||
Int(i64),
|
||||
Float(f64),
|
||||
Str(Arc<str>),
|
||||
}
|
||||
|
||||
impl ConValue {
|
||||
/// Строковое представление (формат для консоли).
|
||||
pub fn as_string(&self) -> String {
|
||||
match self {
|
||||
ConValue::Bool(v) => {
|
||||
if *v {
|
||||
"1".to_string()
|
||||
} else {
|
||||
"0".to_string()
|
||||
}
|
||||
}
|
||||
ConValue::Int(v) => v.to_string(),
|
||||
ConValue::Float(v) => format!("{v}"),
|
||||
ConValue::Str(v) => v.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ошибка установки конвара.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConVarError {
|
||||
/// Не удалось разобрать значение.
|
||||
Parse,
|
||||
/// Тип значения не совпадает с типом конвара.
|
||||
TypeMismatch,
|
||||
}
|
||||
|
||||
impl fmt::Display for ConVarError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ConVarError::Parse => write!(f, "не удалось разобрать значение"),
|
||||
ConVarError::TypeMismatch => write!(f, "несовпадение типа значения"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ConVarError {}
|
||||
|
||||
/// Type-erased интерфейс конвара для реестра/консоли.
|
||||
pub trait ConVarAccessor: Send + Sync + 'static {
|
||||
fn name(&self) -> &'static str;
|
||||
fn description(&self) -> &'static str;
|
||||
/// "bool", "int", "float" или "string".
|
||||
fn type_name(&self) -> &'static str;
|
||||
fn value_string(&self) -> String;
|
||||
fn default_string(&self) -> String;
|
||||
fn set_string(&self, value: &str) -> Result<(), ConVarError>;
|
||||
}
|
||||
|
||||
enum Storage {
|
||||
Bool(AtomicBool),
|
||||
Int(AtomicI64),
|
||||
Float(AtomicU64),
|
||||
Str(RwLock<Arc<str>>),
|
||||
}
|
||||
|
||||
/// Конвар. Создаётся в `static` и регистрируется через [`register`].
|
||||
pub struct ConVar {
|
||||
name: &'static str,
|
||||
description: &'static str,
|
||||
default: ConValue,
|
||||
storage: Storage,
|
||||
}
|
||||
|
||||
#[cold]
|
||||
fn on_type_mismatch(name: &str, expected: &str) {
|
||||
debug_assert!(false, "ConVar `{name}`: обращение как к `{expected}`");
|
||||
tracing::error!(name, expected, "convar: несовпадение типа при обращении");
|
||||
}
|
||||
|
||||
impl ConVar {
|
||||
pub const fn new_bool(name: &'static str, default: bool, description: &'static str) -> Self {
|
||||
Self {
|
||||
name,
|
||||
description,
|
||||
default: ConValue::Bool(default),
|
||||
storage: Storage::Bool(AtomicBool::new(default)),
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn new_int(name: &'static str, default: i64, description: &'static str) -> Self {
|
||||
Self {
|
||||
name,
|
||||
description,
|
||||
default: ConValue::Int(default),
|
||||
storage: Storage::Int(AtomicI64::new(default)),
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn new_float(name: &'static str, default: f64, description: &'static str) -> Self {
|
||||
Self {
|
||||
name,
|
||||
description,
|
||||
default: ConValue::Float(default),
|
||||
storage: Storage::Float(AtomicU64::new(default.to_bits())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_str(name: &'static str, default: &str, description: &'static str) -> Self {
|
||||
Self {
|
||||
name,
|
||||
description,
|
||||
default: ConValue::Str(Arc::from(default)),
|
||||
storage: Storage::Str(RwLock::new(Arc::from(default))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_bool(&self) -> bool {
|
||||
match &self.storage {
|
||||
Storage::Bool(a) => a.load(Ordering::Relaxed),
|
||||
_ => {
|
||||
on_type_mismatch(self.name, "bool");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_int(&self) -> i64 {
|
||||
match &self.storage {
|
||||
Storage::Int(a) => a.load(Ordering::Relaxed),
|
||||
_ => {
|
||||
on_type_mismatch(self.name, "int");
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_float(&self) -> f64 {
|
||||
match &self.storage {
|
||||
Storage::Float(a) => f64::from_bits(a.load(Ordering::Relaxed)),
|
||||
_ => {
|
||||
on_type_mismatch(self.name, "float");
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_str(&self) -> Arc<str> {
|
||||
match &self.storage {
|
||||
Storage::Str(l) => l.read().clone(),
|
||||
_ => {
|
||||
on_type_mismatch(self.name, "string");
|
||||
Arc::from("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_bool(&self, value: bool) {
|
||||
match &self.storage {
|
||||
Storage::Bool(a) => a.store(value, Ordering::Relaxed),
|
||||
_ => on_type_mismatch(self.name, "bool"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_int(&self, value: i64) {
|
||||
match &self.storage {
|
||||
Storage::Int(a) => a.store(value, Ordering::Relaxed),
|
||||
_ => on_type_mismatch(self.name, "int"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_float(&self, value: f64) {
|
||||
match &self.storage {
|
||||
Storage::Float(a) => a.store(value.to_bits(), Ordering::Relaxed),
|
||||
_ => on_type_mismatch(self.name, "float"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_str(&self, value: &str) {
|
||||
match &self.storage {
|
||||
Storage::Str(l) => *l.write() = Arc::from(value),
|
||||
_ => on_type_mismatch(self.name, "string"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConVarAccessor for ConVar {
|
||||
fn name(&self) -> &'static str {
|
||||
self.name
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
self.description
|
||||
}
|
||||
|
||||
fn type_name(&self) -> &'static str {
|
||||
match &self.storage {
|
||||
Storage::Bool(_) => "bool",
|
||||
Storage::Int(_) => "int",
|
||||
Storage::Float(_) => "float",
|
||||
Storage::Str(_) => "string",
|
||||
}
|
||||
}
|
||||
|
||||
fn value_string(&self) -> String {
|
||||
match &self.storage {
|
||||
Storage::Bool(a) => (if a.load(Ordering::Relaxed) { "1" } else { "0" }).to_string(),
|
||||
Storage::Int(a) => a.load(Ordering::Relaxed).to_string(),
|
||||
Storage::Float(a) => format!("{}", f64::from_bits(a.load(Ordering::Relaxed))),
|
||||
Storage::Str(l) => l.read().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_string(&self) -> String {
|
||||
self.default.as_string()
|
||||
}
|
||||
|
||||
fn set_string(&self, value: &str) -> Result<(), ConVarError> {
|
||||
match &self.storage {
|
||||
Storage::Bool(a) => {
|
||||
a.store(parse_bool(value)?, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
Storage::Int(a) => {
|
||||
a.store(
|
||||
value
|
||||
.trim()
|
||||
.parse::<i64>()
|
||||
.map_err(|_| ConVarError::Parse)?,
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Storage::Float(a) => {
|
||||
a.store(
|
||||
value
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.map_err(|_| ConVarError::Parse)?
|
||||
.to_bits(),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Storage::Str(l) => {
|
||||
*l.write() = Arc::from(value);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_bool(value: &str) -> Result<bool, ConVarError> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => Ok(true),
|
||||
"0" | "false" | "no" | "off" => Ok(false),
|
||||
other => other
|
||||
.parse::<i64>()
|
||||
.map(|v| v != 0)
|
||||
.map_err(|_| ConVarError::Parse),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Реестр
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static REGISTRY: OnceLock<RwLock<Vec<&'static dyn ConVarAccessor>>> = OnceLock::new();
|
||||
|
||||
fn registry() -> &'static RwLock<Vec<&'static dyn ConVarAccessor>> {
|
||||
REGISTRY.get_or_init(|| RwLock::new(Vec::new()))
|
||||
}
|
||||
|
||||
/// Регистрирует конвар в глобальном реестре (идемпотентно по имени).
|
||||
pub fn register(cv: &'static dyn ConVarAccessor) {
|
||||
let mut list = registry().write();
|
||||
if list.iter().any(|c| c.name() == cv.name()) {
|
||||
return;
|
||||
}
|
||||
let pos = list.partition_point(|c| c.name() < cv.name());
|
||||
list.insert(pos, cv);
|
||||
}
|
||||
|
||||
/// Все зарегистрированные конвары (отсортированы по имени).
|
||||
pub fn list() -> Vec<&'static dyn ConVarAccessor> {
|
||||
registry().read().clone()
|
||||
}
|
||||
|
||||
/// Находит конвар по имени.
|
||||
pub fn find(name: &str) -> Option<&'static dyn ConVarAccessor> {
|
||||
registry().read().iter().copied().find(|c| c.name() == name)
|
||||
}
|
||||
|
||||
/// Ошибка установки конвара по имени.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SetConVarError {
|
||||
#[error("неизвестный конвар: {0}")]
|
||||
Unknown(String),
|
||||
#[error("неверное значение: {0}")]
|
||||
Value(#[from] ConVarError),
|
||||
}
|
||||
|
||||
/// Устанавливает конвар по имени (интерфейс консоли).
|
||||
pub fn set_by_name(name: &str, value: &str) -> Result<(), SetConVarError> {
|
||||
let cv = find(name).ok_or_else(|| SetConVarError::Unknown(name.to_string()))?;
|
||||
cv.set_string(value).map_err(SetConVarError::Value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
static TEST_BOOL: ConVar = ConVar::new_bool("test_convars_bool", true, "тест");
|
||||
static TEST_INT: ConVar = ConVar::new_int("test_convars_int", 42, "тест");
|
||||
static TEST_FLOAT: ConVar = ConVar::new_float("test_convars_float", 1.5, "тест");
|
||||
// Строковые конвары не могут быть const-инициализированы (Arc), поэтому LazyLock.
|
||||
static TEST_STR: std::sync::LazyLock<ConVar> =
|
||||
std::sync::LazyLock::new(|| ConVar::new_str("test_convars_str", "hello", "тест"));
|
||||
|
||||
fn register_all() {
|
||||
register(&TEST_BOOL);
|
||||
register(&TEST_INT);
|
||||
register(&TEST_FLOAT);
|
||||
register(&*TEST_STR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_access() {
|
||||
register_all();
|
||||
assert!(TEST_BOOL.get_bool());
|
||||
TEST_BOOL.set_bool(false);
|
||||
assert!(!TEST_BOOL.get_bool());
|
||||
TEST_BOOL.set_bool(true);
|
||||
|
||||
assert_eq!(TEST_INT.get_int(), 42);
|
||||
TEST_FLOAT.set_float(2.25);
|
||||
assert!((TEST_FLOAT.get_float() - 2.25).abs() < f64::EPSILON);
|
||||
TEST_FLOAT.set_float(1.5);
|
||||
|
||||
TEST_STR.set_str("world");
|
||||
assert_eq!(&*TEST_STR.get_str(), "world");
|
||||
TEST_STR.set_str("hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_interface() {
|
||||
register_all();
|
||||
assert!(TEST_BOOL.set_string("0").is_ok());
|
||||
assert!(!TEST_BOOL.get_bool());
|
||||
assert!(TEST_BOOL.set_string("true").is_ok());
|
||||
assert!(TEST_BOOL.get_bool());
|
||||
|
||||
assert!(TEST_INT.set_string("-7").is_ok());
|
||||
assert_eq!(TEST_INT.get_int(), -7);
|
||||
assert!(TEST_INT.set_string("abc").is_err());
|
||||
TEST_INT.set_int(42);
|
||||
|
||||
assert!(TEST_FLOAT.set_string("0.5").is_ok());
|
||||
assert!((TEST_FLOAT.get_float() - 0.5).abs() < f64::EPSILON);
|
||||
TEST_FLOAT.set_float(1.5);
|
||||
|
||||
assert_eq!(TEST_STR.value_string(), "hello");
|
||||
assert_eq!(TEST_STR.default_string(), "hello");
|
||||
assert_eq!(TEST_BOOL.type_name(), "bool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_and_set_by_name() {
|
||||
register_all();
|
||||
assert!(find("test_convars_int").is_some());
|
||||
assert!(list().iter().any(|c| c.name() == "test_convars_float"));
|
||||
assert!(set_by_name("test_convars_int", "11").is_ok());
|
||||
assert_eq!(TEST_INT.get_int(), 11);
|
||||
TEST_INT.set_int(42);
|
||||
assert!(set_by_name("нет_такого", "1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bool_forms() {
|
||||
assert_eq!(parse_bool("1"), Ok(true));
|
||||
assert_eq!(parse_bool("off"), Ok(false));
|
||||
assert_eq!(parse_bool("2"), Ok(true));
|
||||
assert_eq!(parse_bool("nope"), Err(ConVarError::Parse));
|
||||
}
|
||||
}
|
||||
12
crates/tier/src/lib.rs
Normal file
12
crates/tier/src/lib.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
//! `tier` — базовый слой движка Source RS (аналог tier0/tier1 в Source):
|
||||
//! логирование, конвары, конфигурация, определение путей к ассетам.
|
||||
|
||||
pub mod config;
|
||||
pub mod convars;
|
||||
pub mod logging;
|
||||
pub mod paths;
|
||||
|
||||
pub use config::Config;
|
||||
|
||||
/// Версия Source RS.
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
35
crates/tier/src/logging.rs
Normal file
35
crates/tier/src/logging.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
//! Инициализация логирования (tracing).
|
||||
|
||||
use std::io::IsTerminal;
|
||||
use std::sync::Once;
|
||||
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
/// Инициализирует глобальный логгер ровно один раз.
|
||||
///
|
||||
/// `verbosity`: 0 — info, 1 — debug, 2 и больше — trace.
|
||||
/// Переменная окружения `SOURCE_RS_LOG` (или `RUST_LOG`) имеет приоритет,
|
||||
/// например: `SOURCE_RS_LOG=wgpu=warn,fs=debug`.
|
||||
pub fn init(verbosity: u8) {
|
||||
INIT.call_once(|| {
|
||||
let filter = EnvFilter::try_from_env("SOURCE_RS_LOG")
|
||||
.or_else(|_| EnvFilter::try_from_env("RUST_LOG"))
|
||||
.unwrap_or_else(|_| {
|
||||
let level = match verbosity {
|
||||
0 => "info",
|
||||
1 => "debug",
|
||||
_ => "trace",
|
||||
};
|
||||
EnvFilter::new(level)
|
||||
});
|
||||
|
||||
let ansi = std::io::stdout().is_terminal();
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(true)
|
||||
.with_ansi(ansi)
|
||||
.try_init();
|
||||
});
|
||||
}
|
||||
152
crates/tier/src/paths.rs
Normal file
152
crates/tier/src/paths.rs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
//! Поиск установок Garry's Mod и раскладки ассетов.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Раскладка установки Garry's Mod.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AssetLayout {
|
||||
/// Корень установки (например, `~/gmod_server/server`).
|
||||
pub root: PathBuf,
|
||||
/// Каталог игры (`root/garrysmod`).
|
||||
pub game: PathBuf,
|
||||
/// `root/sourceengine` — базовый контент (hl2_* vpk).
|
||||
pub source_engine: Option<PathBuf>,
|
||||
/// `root/platform`.
|
||||
pub platform: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl AssetLayout {
|
||||
/// `garrysmod_dir.vpk`.
|
||||
pub fn dir_vpk(&self) -> PathBuf {
|
||||
self.game.join("garrysmod_dir.vpk")
|
||||
}
|
||||
|
||||
/// `gameinfo.txt`.
|
||||
pub fn gameinfo(&self) -> PathBuf {
|
||||
self.game.join("gameinfo.txt")
|
||||
}
|
||||
|
||||
/// Похоже ли на настоящую установку.
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.dir_vpk().is_file() || self.gameinfo().is_file()
|
||||
}
|
||||
|
||||
/// Описание для логов/CLI.
|
||||
pub fn describe(&self) -> String {
|
||||
let mut s = format!("root={} game={}", self.root.display(), self.game.display());
|
||||
if let Some(p) = &self.source_engine {
|
||||
s.push_str(&format!(" sourceengine={}", p.display()));
|
||||
}
|
||||
if let Some(p) = &self.platform {
|
||||
s.push_str(&format!(" platform={}", p.display()));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Строит раскладку по любому пути внутри установки
|
||||
/// (корень или сам каталог `garrysmod`).
|
||||
pub fn build(root: &Path) -> AssetLayout {
|
||||
let (root, game) = if root.join("garrysmod").is_dir() {
|
||||
(root.to_path_buf(), root.join("garrysmod"))
|
||||
} else {
|
||||
let parent = root
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| root.to_path_buf());
|
||||
(parent, root.to_path_buf())
|
||||
};
|
||||
|
||||
let source_engine = {
|
||||
let p = root.join("sourceengine");
|
||||
p.is_dir().then_some(p)
|
||||
};
|
||||
let platform = {
|
||||
let p = root.join("platform");
|
||||
p.is_dir().then_some(p)
|
||||
};
|
||||
|
||||
AssetLayout {
|
||||
root,
|
||||
game,
|
||||
source_engine,
|
||||
platform,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ищет установку: явный путь из CLI/конфига, затем известные места
|
||||
/// (локальный DS-сервер, установки Steam).
|
||||
pub fn detect(explicit: Option<&Path>) -> Option<AssetLayout> {
|
||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||
|
||||
if let Some(path) = explicit {
|
||||
candidates.push(expand_home(path));
|
||||
}
|
||||
|
||||
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
|
||||
candidates.push(home.join("gmod_server/server"));
|
||||
candidates.push(home.join(".steam/steam/steamapps/common/GarrysMod"));
|
||||
candidates.push(home.join(".local/share/Steam/steamapps/common/GarrysMod"));
|
||||
}
|
||||
|
||||
candidates.into_iter().find_map(|candidate| {
|
||||
let layout = build(&candidate);
|
||||
layout.is_valid().then_some(layout)
|
||||
})
|
||||
}
|
||||
|
||||
/// Раскрывает `~` и `~/...` в пути.
|
||||
pub fn expand_home(path: &Path) -> PathBuf {
|
||||
let as_str = path.to_string_lossy();
|
||||
if as_str == "~" {
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
return PathBuf::from(home);
|
||||
}
|
||||
} else if let Some(rest) = as_str.strip_prefix("~/") {
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
return PathBuf::from(home).join(rest);
|
||||
}
|
||||
}
|
||||
path.to_path_buf()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn expand_home_works() {
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
let expanded = expand_home(Path::new("~/example"));
|
||||
assert_eq!(expanded, PathBuf::from(home).join("example"));
|
||||
}
|
||||
// без ~ путь не трогаем
|
||||
assert_eq!(
|
||||
expand_home(Path::new("/abs/path")),
|
||||
PathBuf::from("/abs/path")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_never_panics() {
|
||||
if let Some(layout) = detect(None) {
|
||||
assert!(layout.is_valid(), "detect вернул невалидную раскладку");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_from_game_dir() {
|
||||
let home = match std::env::var_os("HOME") {
|
||||
Some(h) => PathBuf::from(h),
|
||||
None => return,
|
||||
};
|
||||
let root = home.join("gmod_server/server");
|
||||
if !root.join("garrysmod").is_dir() {
|
||||
return; // нет ассетов — тест неактуален
|
||||
}
|
||||
let from_root = build(&root);
|
||||
let from_game = build(&root.join("garrysmod"));
|
||||
assert_eq!(from_root.game, from_game.game);
|
||||
assert_eq!(from_root.root, from_game.root);
|
||||
}
|
||||
}
|
||||
14
docs/PLAN.md
14
docs/PLAN.md
|
|
@ -193,11 +193,15 @@ Source RS — клиент Garry's Mod, написанный с нуля на Ru
|
|||
|
||||
## 7. Спринт 0
|
||||
|
||||
- [ ] `git init` + `.gitignore` + README + docs (этот коммит)
|
||||
- [ ] Cargo workspace `~/garrysmod-rs`: `crates/tier` (логи/конвары/конфиг/CLI), `crates/fs` (VPK v1/v2, листинг)
|
||||
- [ ] `client`: окно SDL3 + wgpu clear + FPS-оверлей
|
||||
- [ ] Тест: листинг `garrysmod_dir.vpk` + чтение произвольного файла из него
|
||||
- [ ] CI-скелет (fmt/clippy/test), fuzz-заглушки
|
||||
- [x] `git init` + `.gitignore` + README + docs (первый коммит)
|
||||
- [x] Cargo workspace `~/garrysmod-rs`: `crates/tier` (логи/конвары/конфиг/CLI), `crates/fs` (VPK v1/v2, листинг, CRC-проверка)
|
||||
- [x] `crates/math` (базовый Vec3/Angle/Aabb)
|
||||
- [x] `client`: окно SDL3 + wgpu clear + FPS в заголовке и логе (проверено: NVIDIA RTX 3060, Vulkan, ~58–63 FPS, чистое завершение)
|
||||
- [x] Тесты: реальные VPK из `~/gmod_server/server` парсятся, CRC-выборка проходит; мусор не роняет парсер
|
||||
- [x] CI-скелет (fmt/clippy/test для tier/math/fs), fuzz-заглушка (`fuzz/fuzz_targets/vpk_parse.rs`)
|
||||
|
||||
**Спринт 0 завершён (2026-10-11):** `fs doctor` — 9 VPK, 77 933 записи, 41 архив, 0 ошибок CRC;
|
||||
`fs cat` читает MDL/текст из VPK; окно открывается на Vulkan.
|
||||
|
||||
## 8. Тестовая стратегия
|
||||
|
||||
|
|
|
|||
|
|
@ -80,3 +80,16 @@
|
|||
| Packet dump/replay | Воспроизведение прод-нагрузки без сервера |
|
||||
| ClassicBox (live) | Финальные проверки (36–46 игроков), аккуратно и по делу |
|
||||
| Fuzz-стенды | vpk/bsp/mdl/vtf/pcf/сеть — cargo-fuzz |
|
||||
|
||||
## 10. Заметки спринта 0 (обновляется по ходу)
|
||||
|
||||
- VPK в установке: 9 `*_dir.vpk` (77 933 записи, 41 архив) — garrysmod (v2, 15 324 записи),
|
||||
fallbacks, hl2_{textures,misc,sound_misc,sound_vo_english}, content_hl2, content_cstrike,
|
||||
platform_misc. CS:S-контент присутствует (важно для gm_mobenix).
|
||||
- Формат VPK сверен с ValvePak (VRF): терминаторы, preload, встроенные данные,
|
||||
внешние архивы `<stem>_NNN.vpk`; парсер bounded и fuzz-устойчив.
|
||||
- `fs doctor`: 0 ошибок CRC на выборке; `fs cat` читает MDL (IDST) и текстовые файлы из VPK.
|
||||
- Клиент: SDL3 (собран из исходников, feature `build-from-source`) + wgpu 30.
|
||||
Запуск проверен: NVIDIA RTX 3060, Vulkan, ~58–63 FPS (vsync), чистое завершение по `--frames`.
|
||||
- wgpu 30: API проверено по примеру sdl3-rs (`raw-window-handle-with-wgpu`);
|
||||
заголовок окна обновляется через `sdl3::sys` (метод крейта требует `&mut Window`).
|
||||
|
|
|
|||
22
fuzz/Cargo.toml
Normal file
22
fuzz/Cargo.toml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
[package]
|
||||
name = "source-rs-fuzz"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2021"
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
fs = { path = ".." }
|
||||
|
||||
# Отдельный workspace: не тянется в обычные сборки/тесты.
|
||||
[workspace]
|
||||
|
||||
[[bin]]
|
||||
name = "vpk_parse"
|
||||
path = "fuzz_targets/vpk_parse.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
13
fuzz/README.md
Normal file
13
fuzz/README.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Fuzzing Source RS
|
||||
|
||||
Таргеты для `cargo-fuzz` (требуется nightly-тулчейн и его установка):
|
||||
|
||||
```bash
|
||||
cargo install cargo-fuzz
|
||||
cd fuzz
|
||||
cargo +nightly fuzz run vpk_parse
|
||||
```
|
||||
|
||||
Таргеты:
|
||||
|
||||
- `vpk_parse` — разбор VPK: никакие входные данные не должны приводить к панике.
|
||||
9
fuzz/fuzz_targets/vpk_parse.rs
Normal file
9
fuzz/fuzz_targets/vpk_parse.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#![no_main]
|
||||
//! Fuzz-таргет: разбор VPK не должен паниковать ни на каких данных.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use std::path::Path;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let _ = fs::vpk::Vpk::parse_bytes(Path::new("fuzz_dir.vpk"), data);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue