22 KiB
Molotov Phase 3B: Android Asset Manager & APK Virtual File System Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Implement the Android NDK AAssetManager C-ABI and virtual asset filesystem in molotov-core, enabling libroblox.so to stream game assets, sound effects, textures, and fonts directly from APK directories while transparently overlaying non-destructive user mods (custom ouch.ogg, cursors, fonts) without disk mutation.
Architecture: Provides the Android NDK AAssetManager_*, AAsset_*, and AAssetDir_* C-ABI functions backed by an in-memory/mapped asset reader and directory iterator. Intercepts asset requests and redirects paths matching active user mods in ~/.config/molotov/mods/ to custom files.
Tech Stack: Rust 1.75+, libc, tracing, thiserror.
Spec: docs/superpowers/specs/2026-09-29-molotov-architecture-design.md (Sections 4.3 & 6.2)
Global Constraints
- Language & Edition: Rust 1.75+ (2021 edition), strictly zero Wine or Proton.
- License: GNU General Public License v3.0 or later (GPL-3.0-or-later) with headers in all source files.
- Non-Destructive Modding: Base assets are never modified on disk; modded assets are mapped into the virtual file stream on demand.
- No Faking Success: Missing assets return
NULLwith a diagnostic log rather than dummy data. - Memory Safety: Generational handle table preventing double-free or use-after-free on
AAsset_closeandAAssetDir_close.
Review Focus
- Non-Destructive Mod Redirection: Querying a modded asset (e.g.
content/sounds/ouch.ogg) must return the custom file bytes from~/.config/molotov/mods/, not the base file. - Out-of-Bounds Seeking: Calling
AAsset_seekbeyond file length or negative offsets must return-1and leave the read cursor untouched. - Partial & EOF Buffer Reads:
AAsset_readnear EOF must return the exact number of bytes read without buffer overrun or panics. - Directory Iterator Rewind: Calling
AAssetDir_rewindmust reset the cursor to the first directory entry. - Handle Table Double-Close Safety: Calling
AAsset_closetwice must not trigger double-free or undefined behavior.
Task 1: Asset Types & In-Memory Asset Storage
Files:
- Create:
crates/molotov-core/src/assets/types.rs - Create:
crates/molotov-core/src/assets/asset.rs - Create:
crates/molotov-core/src/assets/mod.rs - Test:
crates/molotov-core/tests/asset_read_test.rs
Interfaces:
-
Consumes: None
-
Produces:
molotov_core::assets::asset::AssetFilemolotov_core::assets::types::{AASSET_MODE_BUFFER, AASSET_MODE_STREAMING}
-
Step 1: Write the failing test for asset reading, seeking, and buffer access
// crates/molotov-core/tests/asset_read_test.rs
use molotov_core::assets::asset::AssetFile;
#[test]
fn test_asset_read_and_seek_flow() {
let data = b"ROBLOX_ASSET_CONTENT_12345".to_vec();
let mut asset = AssetFile::new(data);
assert_eq!(asset.length(), 26);
assert_eq!(asset.remaining_length(), 26);
let mut buf = [0u8; 6];
assert_eq!(asset.read(&mut buf), 6);
assert_eq!(&buf, b"ROBLOX");
assert_eq!(asset.remaining_length(), 20);
// Seek to offset 7 (SEEK_SET = 0)
assert_eq!(asset.seek(7, 0), 7);
assert_eq!(asset.remaining_length(), 19);
let mut next_buf = [0u8; 5];
assert_eq!(asset.read(&mut next_buf), 5);
assert_eq!(&next_buf, b"ASSET");
}
- Step 2: Run test to verify it fails
Run: cargo test -p molotov-core --test asset_read_test
Expected: FAIL with unexported module assets.
- Step 3: Implement AssetFile and types
// crates/molotov-core/src/assets/types.rs
// SPDX-License-Identifier: GPL-3.0-or-later
pub const AASSET_MODE_UNKNOWN: i32 = 0;
pub const AASSET_MODE_RANDOM: i32 = 1;
pub const AASSET_MODE_STREAMING: i32 = 2;
pub const AASSET_MODE_BUFFER: i32 = 3;
pub const SEEK_SET: i32 = 0;
pub const SEEK_CUR: i32 = 1;
pub const SEEK_END: i32 = 2;
// crates/molotov-core/src/assets/asset.rs
// SPDX-License-Identifier: GPL-3.0-or-later
use super::types::{SEEK_CUR, SEEK_END, SEEK_SET};
use std::ffi::c_void;
pub struct AssetFile {
data: Vec<u8>,
cursor: usize,
}
impl AssetFile {
pub fn new(data: Vec<u8>) -> Self {
Self { data, cursor: 0 }
}
pub fn length(&self) -> i64 {
self.data.len() as i64
}
pub fn remaining_length(&self) -> i64 {
(self.data.len().saturating_sub(self.cursor)) as i64
}
pub fn get_buffer(&self) -> *const c_void {
self.data.as_ptr() as *const c_void
}
pub fn read(&mut self, buf: &mut [u8]) -> usize {
let available = self.data.len().saturating_sub(self.cursor);
let to_read = buf.len().min(available);
if to_read > 0 {
buf[..to_read].copy_from_slice(&self.data[self.cursor..self.cursor + to_read]);
self.cursor += to_read;
}
to_read
}
pub fn seek(&mut self, offset: i64, whence: i32) -> i64 {
let new_pos: i64 = match whence {
SEEK_SET => offset,
SEEK_CUR => self.cursor as i64 + offset,
SEEK_END => self.data.len() as i64 + offset,
_ => return -1,
};
if new_pos >= 0 && (new_pos as usize) <= self.data.len() {
self.cursor = new_pos as usize;
self.cursor as i64
} else {
-1
}
}
}
// crates/molotov-core/src/assets/mod.rs
// SPDX-License-Identifier: GPL-3.0-or-later
pub mod asset;
pub mod types;
Update crates/molotov-core/src/lib.rs to expose pub mod assets;.
- Step 4: Run test to verify it passes
Run: cargo test -p molotov-core --test asset_read_test
Expected: PASS.
- Step 5: Commit
git add crates/molotov-core/
git commit -m "feat(assets): implement AssetFile with seeking, buffering, and reading mechanics"
Task 2: Directory Enumeration & Rewind
Files:
- Create:
crates/molotov-core/src/assets/dir.rs - Modify:
crates/molotov-core/src/assets/mod.rs - Test:
crates/molotov-core/tests/asset_dir_test.rs
Interfaces:
-
Consumes: None
-
Produces:
molotov_core::assets::dir::AssetDirmolotov_core::assets::dir::AssetDir::get_next_filename()
-
Step 1: Write the failing test for directory iteration and rewind
// crates/molotov-core/tests/asset_dir_test.rs
use molotov_core::assets::dir::AssetDir;
use std::ffi::CStr;
#[test]
fn test_asset_dir_iteration_and_rewind() {
let files = vec!["font.ttf".to_string(), "cursor.png".to_string(), "sound.ogg".to_string()];
let mut dir = AssetDir::new(files);
unsafe {
let f1 = dir.get_next_filename();
assert!(!f1.is_null());
assert_eq!(CStr::from_ptr(f1).to_str().unwrap(), "font.ttf");
let f2 = dir.get_next_filename();
assert_eq!(CStr::from_ptr(f2).to_str().unwrap(), "cursor.png");
dir.rewind();
let f1_again = dir.get_next_filename();
assert_eq!(CStr::from_ptr(f1_again).to_str().unwrap(), "font.ttf");
}
}
- Step 2: Run test to verify it fails
Run: cargo test -p molotov-core --test asset_dir_test
Expected: FAIL with unexported module dir.
- Step 3: Implement AssetDir
// crates/molotov-core/src/assets/dir.rs
// SPDX-License-Identifier: GPL-3.0-or-later
use std::ffi::CString;
pub struct AssetDir {
entries: Vec<CString>,
cursor: usize,
}
impl AssetDir {
pub fn new(filenames: Vec<String>) -> Self {
let entries = filenames
.into_iter()
.filter_map(|s| CString::new(s).ok())
.collect();
Self { entries, cursor: 0 }
}
pub fn get_next_filename(&mut self) -> *const libc::c_char {
if self.cursor < self.entries.len() {
let ptr = self.entries[self.cursor].as_ptr();
self.cursor += 1;
ptr
} else {
std::ptr::null()
}
}
pub fn rewind(&mut self) {
self.cursor = 0;
}
}
Update crates/molotov-core/src/assets/mod.rs to expose pub mod dir;.
- Step 4: Run test to verify it passes
Run: cargo test -p molotov-core --test asset_dir_test
Expected: PASS.
- Step 5: Commit
git add crates/molotov-core/
git commit -m "feat(assets): implement AssetDir directory iterator with rewind support"
Task 3: Virtual Asset Manager with Mod Overlays
Files:
- Create:
crates/molotov-core/src/assets/manager.rs - Modify:
crates/molotov-core/src/assets/mod.rs - Test:
crates/molotov-core/tests/asset_manager_virtual_test.rs
Interfaces:
-
Consumes:
molotov_core::assets::asset::AssetFile,molotov_core::assets::dir::AssetDir -
Produces:
molotov_core::assets::manager::AssetManagermolotov_core::assets::manager::AssetManager::open()
-
Step 1: Write the failing test for asset manager file loading and mod redirection
// crates/molotov-core/tests/asset_manager_virtual_test.rs
use molotov_core::assets::manager::AssetManager;
#[test]
fn test_asset_manager_open_with_mod_override() {
let mut mgr = AssetManager::new();
mgr.add_asset("content/sounds/ouch.ogg", b"BASE_SOUND".to_vec());
mgr.add_mod_override("content/sounds/ouch.ogg", b"CUSTOM_MOD_SOUND".to_vec());
let mut asset = mgr.open("content/sounds/ouch.ogg").expect("Should open asset");
let mut content = vec![0u8; asset.length() as usize];
asset.read(&mut content);
assert_eq!(&content, b"CUSTOM_MOD_SOUND", "Must redirect to user mod bytes");
}
- Step 2: Run test to verify it fails
Run: cargo test -p molotov-core --test asset_manager_virtual_test
Expected: FAIL with unexported module manager.
- Step 3: Implement AssetManager
// crates/molotov-core/src/assets/manager.rs
// SPDX-License-Identifier: GPL-3.0-or-later
use super::asset::AssetFile;
use super::dir::AssetDir;
use std::collections::HashMap;
pub struct AssetManager {
base_assets: HashMap<String, Vec<u8>>,
mod_overrides: HashMap<String, Vec<u8>>,
}
impl Default for AssetManager {
fn default() -> Self {
Self::new()
}
}
impl AssetManager {
pub fn new() -> Self {
Self {
base_assets: HashMap::new(),
mod_overrides: HashMap::new(),
}
}
pub fn add_asset(&mut self, path: &str, data: Vec<u8>) {
self.base_assets.insert(path.to_string(), data);
}
pub fn add_mod_override(&mut self, path: &str, data: Vec<u8>) {
self.mod_overrides.insert(path.to_string(), data);
}
pub fn open(&self, filename: &str) -> Option<AssetFile> {
// 1. Check user mod overrides first (non-destructive mod engine)
if let Some(data) = self.mod_overrides.get(filename) {
tracing::info!("AssetManager: redirected '{}' to active user mod", filename);
return Some(AssetFile::new(data.clone()));
}
// 2. Fallback to base asset
if let Some(data) = self.base_assets.get(filename) {
return Some(AssetFile::new(data.clone()));
}
tracing::warn!("AssetManager: asset '{}' not found", filename);
None
}
pub fn open_dir(&self, dir_name: &str) -> AssetDir {
let prefix = if dir_name.is_empty() || dir_name == "." {
"".to_string()
} else if !dir_name.ends_with('/') {
format!("{}/", dir_name)
} else {
dir_name.to_string()
};
let mut files = Vec::new();
for key in self.base_assets.keys().chain(self.mod_overrides.keys()) {
if key.starts_with(&prefix) {
let rest = &key[prefix.len()..];
if !rest.contains('/') {
files.push(rest.to_string());
}
}
}
files.sort();
files.dedup();
AssetDir::new(files)
}
}
Update crates/molotov-core/src/assets/mod.rs to expose pub mod manager;.
- Step 4: Run test to verify it passes
Run: cargo test -p molotov-core --test asset_manager_virtual_test
Expected: PASS.
- Step 5: Commit
git add crates/molotov-core/
git commit -m "feat(assets): implement virtual AssetManager with non-destructive mod overlays"
Task 4: Exported AAssetManager_* NDK C-ABI Functions
Files:
- Create:
crates/molotov-core/src/assets/c_api.rs - Modify:
crates/molotov-core/src/assets/mod.rs - Test:
crates/molotov-core/tests/aasset_c_api_test.rs
Interfaces:
-
Consumes:
molotov_core::assets::manager::AssetManager -
Produces: Exported NDK symbols:
AAssetManager_openAAssetManager_openDirAAsset_readAAsset_seekAAsset_getLengthAAsset_getRemainingLengthAAsset_getBufferAAsset_closeAAssetDir_getNextFileNameAAssetDir_rewindAAssetDir_close
-
Step 1: Write the failing test for exported C-ABI functions
// crates/molotov-core/tests/aasset_c_api_test.rs
use molotov_core::assets::c_api::*;
use std::ffi::{CStr, CString};
#[test]
fn test_aasset_c_api_lifecycle() {
let mgr_handle = register_test_asset("sounds/click.ogg", b"TEST_CLICK_AUDIO".to_vec());
unsafe {
let filename = CString::new("sounds/click.ogg").unwrap();
let asset = AAssetManager_open(mgr_handle, filename.as_ptr(), 3);
assert!(!asset.is_null());
assert_eq!(AAsset_getLength(asset), 16);
assert_eq!(AAsset_getRemainingLength(asset), 16);
let mut buf = [0u8; 16];
let read_bytes = AAsset_read(asset, buf.as_mut_ptr() as *mut _, 16);
assert_eq!(read_bytes, 16);
assert_eq!(&buf, b"TEST_CLICK_AUDIO");
AAsset_close(asset);
// Double-close must not panic or abort
AAsset_close(asset);
}
}
- Step 2: Run test to verify it fails
Run: cargo test -p molotov-core --test aasset_c_api_test
Expected: FAIL with unexported module c_api.
- Step 3: Implement exported C-ABI functions with atomic handle table
// crates/molotov-core/src/assets/c_api.rs
// SPDX-License-Identifier: GPL-3.0-or-later
#![allow(non_snake_case)]
use super::asset::AssetFile;
use super::dir::AssetDir;
use super::manager::AssetManager;
use std::collections::HashMap;
use std::ffi::{c_void, CStr};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, RwLock};
static NEXT_ASSET_ID: AtomicUsize = AtomicUsize::new(3001);
lazy_static::lazy_static! {
static ref ASSET_TABLE: RwLock<HashMap<usize, Mutex<AssetFile>>> = RwLock::new(HashMap::new());
static ref DIR_TABLE: RwLock<HashMap<usize, Mutex<AssetDir>>> = RwLock::new(HashMap::new());
pub static ref GLOBAL_ASSET_MANAGER: Arc<RwLock<AssetManager>> = Arc::new(RwLock::new(AssetManager::new()));
}
pub fn register_test_asset(path: &str, data: Vec<u8>) -> *mut () {
if let Ok(mut mgr) = GLOBAL_ASSET_MANAGER.write() {
mgr.add_asset(path, data);
}
1001 as *mut ()
}
#[no_mangle]
pub unsafe extern "C" fn AAssetManager_open(
_mgr: *mut (),
filename: *const libc::c_char,
_mode: i32,
) -> *mut () {
if filename.is_null() {
return std::ptr::null_mut();
}
let name_str = match CStr::from_ptr(filename).to_str() {
Ok(s) => s,
Err(_) => return std::ptr::null_mut(),
};
let mgr = match GLOBAL_ASSET_MANAGER.read() {
Ok(m) => m,
Err(_) => return std::ptr::null_mut(),
};
if let Some(file) = mgr.open(name_str) {
let id = NEXT_ASSET_ID.fetch_add(1, Ordering::Relaxed);
if let Ok(mut table) = ASSET_TABLE.write() {
table.insert(id, Mutex::new(file));
id as *mut ()
} else {
std::ptr::null_mut()
}
} else {
std::ptr::null_mut()
}
}
#[no_mangle]
pub unsafe extern "C" fn AAssetManager_openDir(_mgr: *mut (), dirName: *const libc::c_char) -> *mut () {
let name_str = if !dirName.is_null() {
CStr::from_ptr(dirName).to_str().unwrap_or("")
} else {
""
};
let mgr = match GLOBAL_ASSET_MANAGER.read() {
Ok(m) => m,
Err(_) => return std::ptr::null_mut(),
};
let dir = mgr.open_dir(name_str);
let id = NEXT_ASSET_ID.fetch_add(1, Ordering::Relaxed);
if let Ok(mut table) = DIR_TABLE.write() {
table.insert(id, Mutex::new(dir));
id as *mut ()
} else {
std::ptr::null_mut()
}
}
#[no_mangle]
pub unsafe extern "C" fn AAsset_read(asset: *mut (), buf: *mut c_void, count: usize) -> i32 {
if asset.is_null() || buf.is_null() || count == 0 {
return 0;
}
let id = asset as usize;
if let Ok(table) = ASSET_TABLE.read() {
if let Some(mutex) = table.get(&id) {
if let Ok(mut file) = mutex.lock() {
let slice = std::slice::from_raw_parts_mut(buf as *mut u8, count);
return file.read(slice) as i32;
}
}
}
0
}
#[no_mangle]
pub unsafe extern "C" fn AAsset_seek(asset: *mut (), offset: i64, whence: i32) -> i64 {
if asset.is_null() {
return -1;
}
let id = asset as usize;
if let Ok(table) = ASSET_TABLE.read() {
if let Some(mutex) = table.get(&id) {
if let Ok(mut file) = mutex.lock() {
return file.seek(offset, whence);
}
}
}
-1
}
#[no_mangle]
pub unsafe extern "C" fn AAsset_seek64(asset: *mut (), offset: i64, whence: i32) -> i64 {
AAsset_seek(asset, offset, whence)
}
#[no_mangle]
pub unsafe extern "C" fn AAsset_getLength(asset: *mut ()) -> i64 {
if asset.is_null() {
return 0;
}
let id = asset as usize;
if let Ok(table) = ASSET_TABLE.read() {
if let Some(mutex) = table.get(&id) {
if let Ok(file) = mutex.lock() {
return file.length();
}
}
}
0
}
#[no_mangle]
pub unsafe extern "C" fn AAsset_getLength64(asset: *mut ()) -> i64 {
AAsset_getLength(asset)
}
#[no_mangle]
pub unsafe extern "C" fn AAsset_getRemainingLength(asset: *mut ()) -> i64 {
if asset.is_null() {
return 0;
}
let id = asset as usize;
if let Ok(table) = ASSET_TABLE.read() {
if let Some(mutex) = table.get(&id) {
if let Ok(file) = mutex.lock() {
return file.remaining_length();
}
}
}
0
}
#[no_mangle]
pub unsafe extern "C" fn AAsset_getRemainingLength64(asset: *mut ()) -> i64 {
AAsset_getRemainingLength(asset)
}
#[no_mangle]
pub unsafe extern "C" fn AAsset_getBuffer(asset: *mut ()) -> *const c_void {
if asset.is_null() {
return std::ptr::null();
}
let id = asset as usize;
if let Ok(table) = ASSET_TABLE.read() {
if let Some(mutex) = table.get(&id) {
if let Ok(file) = mutex.lock() {
return file.get_buffer();
}
}
}
std::ptr::null()
}
#[no_mangle]
pub unsafe extern "C" fn AAsset_close(asset: *mut ()) {
if !asset.is_null() {
let id = asset as usize;
if let Ok(mut table) = ASSET_TABLE.write() {
table.remove(&id);
}
}
}
#[no_mangle]
pub unsafe extern "C" fn AAssetDir_getNextFileName(assetDir: *mut ()) -> *const libc::c_char {
if assetDir.is_null() {
return std::ptr::null();
}
let id = assetDir as usize;
if let Ok(table) = DIR_TABLE.read() {
if let Some(mutex) = table.get(&id) {
if let Ok(mut dir) = mutex.lock() {
return dir.get_next_filename();
}
}
}
std::ptr::null()
}
#[no_mangle]
pub unsafe extern "C" fn AAssetDir_rewind(assetDir: *mut ()) {
if !assetDir.is_null() {
let id = assetDir as usize;
if let Ok(table) = DIR_TABLE.read() {
if let Some(mutex) = table.get(&id) {
if let Ok(mut dir) = mutex.lock() {
dir.rewind();
}
}
}
}
}
#[no_mangle]
pub unsafe extern "C" fn AAssetDir_close(assetDir: *mut ()) {
if !assetDir.is_null() {
let id = assetDir as usize;
if let Ok(mut table) = DIR_TABLE.write() {
table.remove(&id);
}
}
}
Update crates/molotov-core/src/assets/mod.rs to expose pub mod c_api;.
- Step 4: Run test to verify it passes
Run: cargo test -p molotov-core --test aasset_c_api_test
Expected: PASS.
- Step 5: Commit
git add crates/molotov-core/
git commit -m "feat(assets): implement exported AAssetManager C-ABI functions and handle table"
Task 5: End-to-End Asset Manager & Mod Redirection Integration Test
Files:
- Create:
crates/molotov-core/tests/asset_manager_test.rs - Modify:
crates/molotov-core/src/assets/mod.rs
Interfaces:
-
Consumes: All
molotov_core::assets::*modules -
Produces: Verified end-to-end asset opening, streaming, and mod redirection
-
Step 1: Write the end-to-end integration test
// crates/molotov-core/tests/asset_manager_test.rs
use molotov_core::assets::c_api::*;
use std::ffi::CString;
#[test]
fn test_end_to_end_asset_and_mod_overlay_streaming() {
// Register base asset and mod override
let mgr_handle = register_test_asset("content/fonts/arial.ttf", b"DEFAULT_FONT_DATA".to_vec());
if let Ok(mut mgr) = GLOBAL_ASSET_MANAGER.write() {
mgr.add_mod_override("content/fonts/arial.ttf", b"CUSTOM_COMIC_SANS_MOD".to_vec());
}
unsafe {
let path = CString::new("content/fonts/arial.ttf").unwrap();
let asset = AAssetManager_open(mgr_handle, path.as_ptr(), 1);
assert!(!asset.is_null());
let len = AAsset_getLength(asset);
assert_eq!(len, 21);
let mut out = vec![0u8; len as usize];
let read = AAsset_read(asset, out.as_mut_ptr() as *mut _, len as usize);
assert_eq!(read, 21);
assert_eq!(&out, b"CUSTOM_COMIC_SANS_MOD", "Must load custom user mod");
AAsset_close(asset);
}
}
- Step 2: Run test to verify it passes
Run: cargo test -p molotov-core --test asset_manager_test
Expected: PASS.
- Step 3: Commit
git add crates/molotov-core/tests/asset_manager_test.rs
git commit -m "test(assets): implement end-to-end asset manager and mod overlay streaming test"