From 436be80d30be1a8fafcf4b12a589271dc3efa7d3 Mon Sep 17 00:00:00 2001 From: oiupoyt Date: Thu, 1 Oct 2026 06:26:36 +0530 Subject: [PATCH] docs: add Phase 3B Asset Manager implementation plan --- ...026-09-29-molotov-phase3b-asset-manager.md | 763 ++++++++++++++++++ 1 file changed, 763 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-29-molotov-phase3b-asset-manager.md diff --git a/docs/superpowers/plans/2026-09-29-molotov-phase3b-asset-manager.md b/docs/superpowers/plans/2026-09-29-molotov-phase3b-asset-manager.md new file mode 100644 index 0000000..e342a68 --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-molotov-phase3b-asset-manager.md @@ -0,0 +1,763 @@ +# 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** + +```rust +// 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** + +```rust +// 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; +``` + +```rust +// 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, + cursor: usize, +} + +impl AssetFile { + pub fn new(data: Vec) -> 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 + } + } +} +``` + +```rust +// 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** + +```bash +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** + +```rust +// 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** + +```rust +// crates/molotov-core/src/assets/dir.rs +// SPDX-License-Identifier: GPL-3.0-or-later +use std::ffi::CString; + +pub struct AssetDir { + entries: Vec, + cursor: usize, +} + +impl AssetDir { + pub fn new(filenames: Vec) -> 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** + +```bash +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** + +```rust +// 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** + +```rust +// 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>, + mod_overrides: HashMap>, +} + +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) { + self.base_assets.insert(path.to_string(), data); + } + + pub fn add_mod_override(&mut self, path: &str, data: Vec) { + self.mod_overrides.insert(path.to_string(), data); + } + + pub fn open(&self, filename: &str) -> Option { + // 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** + +```bash +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** + +```rust +// 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** + +```rust +// 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>> = RwLock::new(HashMap::new()); + static ref DIR_TABLE: RwLock>> = RwLock::new(HashMap::new()); + pub static ref GLOBAL_ASSET_MANAGER: Arc> = Arc::new(RwLock::new(AssetManager::new())); +} + +pub fn register_test_asset(path: &str, data: Vec) -> *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** + +```bash +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** + +```rust +// 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** + +```bash +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" +``` + +---