fs pack: минимальный набор ассетов карты + встроенные ассеты в APK

Замер: для 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 не коммитим).

Все сьюты зелёные.
This commit is contained in:
Niko Marmeladkov 2026-10-23 17:03:50 +03:00
parent 35df3442fd
commit d42b581180
7 changed files with 296 additions and 4 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@
/cache/
*.log
*.trace
android/assets/

View file

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

View file

@ -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"

View file

@ -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 {

85
crates/client/src/pack.rs Normal file
View file

@ -0,0 +1,85 @@
//! `fs pack`: собирает минимальный набор ассетов для одной карты.
//!
//! Записывает все файлы, которые реально читаются при сборке мира, загрузке
//! моделей игрока и оружия и автозагрузке Lua, и копирует их в каталог.
//! Результат — самодостаточный корень ассетов: `--assets <out>` для клиента,
//! `assets/` для APK. Для gm_construct это ~60 МБ вместо 6,7 ГБ установки.
use anyhow::Context;
use fs::VirtualFs;
use std::path::{Path, PathBuf};
/// Собирает минимальный набор ассетов карты в `out`.
pub fn pack(assets: Option<PathBuf>, 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(())
}

View file

@ -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()
);
}

View file

@ -44,11 +44,33 @@ fn collect_dir_files(root: &Path, dir: &Path, prefix: &str, out: &mut Vec<String
#[derive(Default)]
pub struct VirtualFs {
mounts: Vec<MountSource>,
/// Записанные пути чтения — нужны, чтобы собрать минимальный набор
/// ассетов (встраивание в APK, перенос карты на другую машину).
read_log: Option<std::sync::Mutex<Vec<String>>>,
}
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<String> {
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());
}
}
}
/// Безопасно собирает путь внутри каталога-источника.