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:
parent
35df3442fd
commit
d42b581180
7 changed files with 296 additions and 4 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,3 +2,4 @@
|
|||
/cache/
|
||||
*.log
|
||||
*.trace
|
||||
android/assets/
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
85
crates/client/src/pack.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Безопасно собирает путь внутри каталога-источника.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue