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:
Niko Marmeladkov 2026-10-11 22:47:09 +03:00
parent ec0750a829
commit dec5b2c48f
29 changed files with 4644 additions and 5 deletions

31
.github/workflows/ci.yml vendored Normal file
View 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

File diff suppressed because it is too large Load diff

40
Cargo.toml Normal file
View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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");

View 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
View 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);
}
}

View file

@ -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. Тестовая стратегия

View file

@ -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
View 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
View 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: никакие входные данные не должны приводить к панике.

View 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);
});