From d42b581180ffc56dfb233d754dcdf05e0d598a2f Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Fri, 23 Oct 2026 17:03:50 +0300 Subject: [PATCH] =?UTF-8?q?fs=20pack:=20=D0=BC=D0=B8=D0=BD=D0=B8=D0=BC?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=BD=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=20=D0=B0=D1=81=D1=81=D0=B5=D1=82=D0=BE=D0=B2=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D1=8B=20+=20=D0=B2=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=B0=D1=81=D1=81=D0=B5=D1=82?= =?UTF-8?q?=D1=8B=20=D0=B2=20APK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Замер: для gm_construct реально читаются 335 файлов из VPK на 27,0 МБ (277 материалов, 23 модели, 35 Lua-скриптов; крупнейшее — m_anm.ani 6,8 МБ) плюс карта 35,0 МБ. Итого 62,1 МБ сырых, 31 МБ в tar.gz — против 6,7 ГБ полной установки. Этого набора достаточно: клиент запускается с ним, карта и пистолет полностью текстурированы (проверено кадром). - VirtualFs: запись успешных чтений (start_recording/take_recorded); - команда `gmod-rs fs pack <карта> --out <каталог>`: собирает мир, грузит модели игрока и оружия, автозагрузку Lua и копирует прочитанные файлы в структуру garrysmod/... плюс карту — получается самодостаточный корень ассетов для `--assets`; - android/build.sh: если есть android/assets/gmod (или GMOD_RS_ANDROID_ASSETS), каталог кладётся в APK как assets/gmod; - MainActivity: при первом запуске распаковывает assets/gmod в files/gmod_server/server, если карты там ещё нет — APK самодостаточен, adb push не нужен; - .gitignore: android/assets/ (ассеты GMod не коммитим). Все сьюты зелёные. --- .gitignore | 1 + .../java/org/gmodrs/client/MainActivity.java | 60 +++++++++++++ android/build.sh | 10 +++ crates/client/src/lib.rs | 12 +++ crates/client/src/pack.rs | 85 +++++++++++++++++++ crates/engine/tests/world_real.rs | 85 +++++++++++++++++++ crates/fs/src/mount.rs | 47 +++++++++- 7 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 crates/client/src/pack.rs diff --git a/.gitignore b/.gitignore index d335e43..2578082 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /cache/ *.log *.trace +android/assets/ diff --git a/android/app/src/main/java/org/gmodrs/client/MainActivity.java b/android/app/src/main/java/org/gmodrs/client/MainActivity.java index f3d30ed..cb6e425 100644 --- a/android/app/src/main/java/org/gmodrs/client/MainActivity.java +++ b/android/app/src/main/java/org/gmodrs/client/MainActivity.java @@ -1,6 +1,11 @@ package org.gmodrs.client; +import android.content.res.AssetManager; import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; /** * Активность клиента gmod-rs. @@ -8,6 +13,10 @@ import java.io.File; * Наследует штатную SDLActivity и передаёт нативной части путь к ассетам: * внешний каталог приложения (`Android/data/org.gmodrs.client/files`) доступен * без разрешений на хранилище, в отличие от прямого `/sdcard`. + * + * Если в APK вложен минимальный набор ассетов (`assets/gmod/...`), при первом + * запуске он распаковывается в этот каталог — тогда APK самодостаточен и + * ничего не нужно копировать через adb. */ public class MainActivity extends org.libsdl.app.SDLActivity { /** @@ -19,6 +28,57 @@ public class MainActivity extends org.libsdl.app.SDLActivity { setRequestedOrientation(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); } + @Override + protected void onCreate(android.os.Bundle savedInstanceState) { + unpackBundledAssets(); + super.onCreate(savedInstanceState); + } + + /** Распаковывает `assets/gmod` в каталог ассетов, если его ещё нет. */ + private void unpackBundledAssets() { + File target = new File(getExternalFilesDir(null), "gmod_server/server"); + File marker = new File(target, "garrysmod/maps/gm_construct.bsp"); + if (marker.isFile()) { + return; + } + long started = System.currentTimeMillis(); + android.util.Log.i("gmod-rs", "распаковка встроенных ассетов в " + target); + try { + copyAsset("gmod", target); + android.util.Log.i("gmod-rs", "ассеты распакованы за " + + (System.currentTimeMillis() - started) + " мс"); + } catch (IOException e) { + android.util.Log.e("gmod-rs", "распаковка ассетов не удалась", e); + } + } + + /** Рекурсивное копирование ассета: каталог или файл. */ + private void copyAsset(String path, File outFile) throws IOException { + AssetManager assets = getAssets(); + String[] children = assets.list(path); + if (children != null && children.length > 0) { + if (!outFile.isDirectory() && !outFile.mkdirs()) { + throw new IOException("не создан каталог " + outFile); + } + for (String child : children) { + copyAsset(path + "/" + child, new File(outFile, child)); + } + return; + } + File parent = outFile.getParentFile(); + if (parent != null && !parent.isDirectory() && !parent.mkdirs()) { + throw new IOException("не создан каталог " + parent); + } + try (InputStream in = assets.open(path); + OutputStream out = new FileOutputStream(outFile)) { + byte[] buffer = new byte[1 << 16]; + int read; + while ((read = in.read(buffer)) > 0) { + out.write(buffer, 0, read); + } + } + } + @Override protected String[] getArguments() { File assets = new File(getExternalFilesDir(null), "gmod_server/server"); diff --git a/android/build.sh b/android/build.sh index 091384d..1a17305 100755 --- a/android/build.sh +++ b/android/build.sh @@ -121,6 +121,16 @@ cp "$CLIENT_SO" "$BUILD/lib/$ABI/libmain.so" (cd "$BUILD/dex" && zip -q "$BUILD/unsigned.apk" classes.dex) (cd "$BUILD" && zip -q -r "$BUILD/unsigned.apk" "lib") +# Встроенные ассеты (необязательно): `fs pack` кладёт сюда garrysmod/. +ASSETS_DIR="${GMOD_RS_ANDROID_ASSETS:-$ANDROID_DIR/assets}" +if [[ -d "$ASSETS_DIR/gmod" ]]; then + echo "== assets: $(du -sh "$ASSETS_DIR/gmod" | cut -f1)" + rm -rf "$BUILD/assets" + mkdir -p "$BUILD/assets" + cp -a "$ASSETS_DIR/gmod" "$BUILD/assets/gmod" + (cd "$BUILD" && zip -q -r "$BUILD/unsigned.apk" "assets") +fi + echo "== zipalign" "$BUILD_TOOLS/zipalign" -f 4 "$BUILD/unsigned.apk" "$BUILD/aligned.apk" diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 1b89f1e..e1f768e 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -8,6 +8,7 @@ mod map; mod menu; #[cfg(not(target_os = "android"))] mod net_cmd; +mod pack; mod touch; use clap::{Parser, Subcommand}; @@ -105,6 +106,16 @@ enum FsCmd { path: String, }, + /// Собрать минимальный набор ассетов для карты (для APK и переноса) + Pack { + /// Имя карты (`gm_construct`) или путь к `.bsp` + map: String, + + /// Каталог результата + #[arg(long, value_name = "DIR")] + out: PathBuf, + }, + /// Вывести файл из смонтированных путей Cat { /// Путь внутри игры, например models/props_c17/oildrum001.mdl @@ -147,6 +158,7 @@ where }, Command::Fs { cmd } => match cmd { FsCmd::Doctor => doctor::doctor(cli.assets), + FsCmd::Pack { map, out } => pack::pack(cli.assets, &map, &out), FsCmd::List { pattern, limit } => doctor::list(cli.assets, pattern, limit), FsCmd::Inspect { path } => doctor::inspect(cli.assets, &path), FsCmd::Cat { diff --git a/crates/client/src/pack.rs b/crates/client/src/pack.rs new file mode 100644 index 0000000..fd345a6 --- /dev/null +++ b/crates/client/src/pack.rs @@ -0,0 +1,85 @@ +//! `fs pack`: собирает минимальный набор ассетов для одной карты. +//! +//! Записывает все файлы, которые реально читаются при сборке мира, загрузке +//! моделей игрока и оружия и автозагрузке Lua, и копирует их в каталог. +//! Результат — самодостаточный корень ассетов: `--assets ` для клиента, +//! `assets/` для APK. Для gm_construct это ~60 МБ вместо 6,7 ГБ установки. + +use anyhow::Context; +use fs::VirtualFs; +use std::path::{Path, PathBuf}; + +/// Собирает минимальный набор ассетов карты в `out`. +pub fn pack(assets: Option, map: &str, out: &Path) -> 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 crate::doctor::collect_dir_vpks(&layout) { + vfs.mount_vpk(&vpk) + .with_context(|| format!("монтирование {}", vpk.display()))?; + } + vfs.mount_dir(layout.game.clone()); + + // Карта: имя внутри VFS (как в клиенте). + let map_path = if map.ends_with(".bsp") { + map.to_string() + } else { + format!("maps/{map}.bsp") + }; + let map_bytes = vfs + .read(&map_path) + .with_context(|| format!("карта {map_path}"))?; + let bsp = formats::bsp::Bsp::from_bytes(map_bytes.clone())?; + if let Some(pak) = bsp.pakfile_zip()? { + vfs.mount_pak(pak); + } + + // Запись: всё, что читает клиент. + vfs.start_recording(); + let world = engine::build_world(&bsp, &vfs)?; + let mut budget = usize::MAX; + for model in ["models/player/police.mdl", "models/weapons/w_pistol.mdl"] { + let _ = engine::model::load_model(&vfs, model, &mut budget); + } + for path in vfs.list("lua/autorun/") { + let _ = vfs.read(&path); + } + let paths = vfs.take_recorded(); + + // Копирование: структура повторяет корень установки (`garrysmod/...`). + let root = out.join("garrysmod"); + let mut total = 0usize; + for path in &paths { + let data = vfs.read(path)?; + total += data.len(); + let dest = root.join(path); + if let Some(dir) = dest.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(&dest, &data).with_context(|| format!("запись {}", dest.display()))?; + } + let map_dest = root.join(&map_path); + if let Some(dir) = map_dest.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(&map_dest, &map_bytes)?; + total += map_bytes.len(); + + tracing::info!( + files = paths.len() + 1, + bytes = total, + out = %out.display(), + "минимальный набор ассетов собран" + ); + println!( + "готово: {} файлов, {:.1} МБ → {}", + paths.len() + 1, + total as f64 / (1024.0 * 1024.0), + out.display() + ); + println!("запуск: --assets {}", out.display()); + let _ = world; + Ok(()) +} diff --git a/crates/engine/tests/world_real.rs b/crates/engine/tests/world_real.rs index 2645470..ca8032b 100644 --- a/crates/engine/tests/world_real.rs +++ b/crates/engine/tests/world_real.rs @@ -1191,3 +1191,88 @@ fn real_map_props_rest_on_ground() { "слишком много парящих пропов: {floating} из {checked}" ); } + +/// Сколько весит минимальный набор ассетов для gm_construct. +/// +/// Записываем все успешные чтения VFS при сборке мира, загрузке модели игрока +/// и оружия, затем суммируем размеры. Это ответ на вопрос «сколько будет +/// весить APK, если встроить только нужное». +#[test] +fn real_map_asset_footprint() { + let Some(root) = assets_root() else { + eprintln!("пропуск: ассеты не найдены"); + return; + }; + let map = root.join("garrysmod/maps/gm_construct.bsp"); + if !map.is_file() { + eprintln!("пропуск: нет {}", map.display()); + return; + } + let mut vfs = mount_game(&root); + let bsp = formats::bsp::Bsp::from_path(&map).expect("разбор карты"); + if let Some(pak) = bsp.pakfile_zip().expect("pakfile") { + vfs.mount_pak(pak); + } + vfs.start_recording(); + let _world = engine::build_world(&bsp, &vfs).expect("сборка мира"); + let mut budget = 512 * 1024 * 1024; + for model in ["models/player/police.mdl", "models/weapons/w_pistol.mdl"] { + let _ = engine::model::load_model(&vfs, model, &mut budget); + } + // Lua-автозагрузка: клиент читает эти файлы на старте. + for path in vfs.list("lua/autorun/") { + let _ = vfs.read(&path); + } + + let paths = vfs.take_recorded(); + let mut total = 0usize; + let mut by_kind: std::collections::BTreeMap<&str, (usize, usize)> = + std::collections::BTreeMap::new(); + let mut largest: Vec<(usize, String)> = Vec::new(); + for path in &paths { + let Ok(data) = vfs.read(path) else { continue }; + total += data.len(); + let kind = if path.starts_with("materials/") { + "материалы" + } else if path.starts_with("models/") { + "модели" + } else if path.starts_with("maps/") { + "карта" + } else if path.starts_with("lua/") { + "lua" + } else { + "прочее" + }; + let entry = by_kind.entry(kind).or_insert((0, 0)); + entry.0 += 1; + entry.1 += data.len(); + largest.push((data.len(), path.clone())); + } + largest.sort_by(|a, b| b.0.cmp(&a.0)); + let bsp_size = std::fs::metadata(&map) + .map(|m| m.len() as usize) + .unwrap_or(0); + eprintln!( + "минимальный набор для gm_construct: файлов {}, суммарно {:.1} МБ + карта {:.1} МБ", + paths.len(), + total as f64 / (1024.0 * 1024.0), + bsp_size as f64 / (1024.0 * 1024.0) + ); + for (kind, (count, size)) in &by_kind { + eprintln!( + " {kind}: {count} файлов, {:.1} МБ", + *size as f64 / (1024.0 * 1024.0) + ); + } + for (size, path) in largest.iter().take(8) { + eprintln!( + " крупное: {:.1} МБ {path}", + *size as f64 / (1024.0 * 1024.0) + ); + } + assert!( + paths.len() > 100, + "подозрительно мало файлов: {}", + paths.len() + ); +} diff --git a/crates/fs/src/mount.rs b/crates/fs/src/mount.rs index 93b3e28..b97ee25 100644 --- a/crates/fs/src/mount.rs +++ b/crates/fs/src/mount.rs @@ -44,11 +44,33 @@ fn collect_dir_files(root: &Path, dir: &Path, prefix: &str, out: &mut Vec, + /// Записанные пути чтения — нужны, чтобы собрать минимальный набор + /// ассетов (встраивание в APK, перенос карты на другую машину). + read_log: Option>>, } impl VirtualFs { pub fn new() -> Self { - Self { mounts: Vec::new() } + Self { + mounts: Vec::new(), + read_log: None, + } + } + + /// Начинает записывать пути всех успешных чтений. + pub fn start_recording(&mut self) { + self.read_log = Some(std::sync::Mutex::new(Vec::new())); + } + + /// Останавливает запись и возвращает уникальные пути (по возрастанию). + pub fn take_recorded(&mut self) -> Vec { + let Some(log) = self.read_log.take() else { + return Vec::new(); + }; + let mut paths = log.into_inner().unwrap_or_default(); + paths.sort(); + paths.dedup(); + paths } /// Монтирует каталог. @@ -158,18 +180,26 @@ impl VirtualFs { MountSource::Dir(root) => { if let Some(full) = dir_file_path(root, path) { if full.is_file() { - return Ok(std::fs::read(full)?); + let data = std::fs::read(full)?; + self.record(&key); + return Ok(data); } } } MountSource::Vpk(vpk) => { if let Some(entry) = vpk.find(&key) { - return vpk.read_entry(entry, verify_crc); + let data = vpk.read_entry(entry, verify_crc)?; + self.record(&key); + return Ok(data); } } MountSource::Pak(pak) => { if let Some(entry) = pak.find(&key) { - return pak.read(entry).map_err(|e| FsError::Archive(e.to_string())); + let data = pak + .read(entry) + .map_err(|e| FsError::Archive(e.to_string()))?; + self.record(&key); + return Ok(data); } } } @@ -177,6 +207,15 @@ impl VirtualFs { Err(FsError::NotFound(path.to_string())) } + + /// Запоминает успешно прочитанный путь (если запись включена). + fn record(&self, path: &str) { + if let Some(log) = &self.read_log + && let Ok(mut paths) = log.lock() + { + paths.push(path.to_string()); + } + } } /// Безопасно собирает путь внутри каталога-источника.