This repository has been archived on 2026-10-10. You can view files and clone it, but cannot push or open issues or pull requests.
molotov/docs/superpowers/plans/2026-09-29-molotov-phase3b-asset-manager.md

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 NULL with a diagnostic log rather than dummy data.
  • Memory Safety: Generational handle table preventing double-free or use-after-free on AAsset_close and AAssetDir_close.

Review Focus

  1. 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.
  2. Out-of-Bounds Seeking: Calling AAsset_seek beyond file length or negative offsets must return -1 and leave the read cursor untouched.
  3. Partial & EOF Buffer Reads: AAsset_read near EOF must return the exact number of bytes read without buffer overrun or panics.
  4. Directory Iterator Rewind: Calling AAssetDir_rewind must reset the cursor to the first directory entry.
  5. Handle Table Double-Close Safety: Calling AAsset_close twice 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::AssetFile
    • molotov_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::AssetDir
    • molotov_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::AssetManager
    • molotov_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_open
    • AAssetManager_openDir
    • AAsset_read
    • AAsset_seek
    • AAsset_getLength
    • AAsset_getRemainingLength
    • AAsset_getBuffer
    • AAsset_close
    • AAssetDir_getNextFileName
    • AAssetDir_rewind
    • AAssetDir_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"