asset manager load by asset id and slot index

This commit is contained in:
bQUARKz 2026-03-27 15:05:37 +00:00
parent e6978397ab
commit 201226b892
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
13 changed files with 154 additions and 186 deletions

View File

@ -110,7 +110,6 @@ impl<T> BankPolicy<T> {
pub struct AssetManager {
assets: Arc<RwLock<HashMap<AssetId, AssetEntry>>>,
name_to_id: Arc<RwLock<HashMap<String, AssetId>>>,
handles: Arc<RwLock<HashMap<HandleId, LoadHandleInfo>>>,
next_handle_id: Mutex<HandleId>,
assets_data: Arc<RwLock<AssetsPayloadSource>>,
@ -153,8 +152,8 @@ impl AssetBridge for AssetManager {
) {
self.initialize_for_cartridge(assets, preload, assets_data)
}
fn load(&self, asset_name: &str, slot: SlotRef) -> Result<HandleId, AssetLoadError> {
self.load(asset_name, slot)
fn load(&self, asset_id: AssetId, slot_index: usize) -> Result<HandleId, AssetLoadError> {
self.load(asset_id, slot_index)
}
fn status(&self, handle: HandleId) -> LoadStatus {
self.status(handle)
@ -268,15 +267,12 @@ impl AssetManager {
sound_installer: Arc<dyn SoundBankPoolInstaller>,
) -> Self {
let mut asset_map = HashMap::new();
let mut name_to_id = HashMap::new();
for entry in assets {
name_to_id.insert(entry.asset_name.clone(), entry.asset_id);
asset_map.insert(entry.asset_id, entry);
}
Self {
assets: Arc::new(RwLock::new(asset_map)),
name_to_id: Arc::new(RwLock::new(name_to_id)),
gfx_installer,
sound_installer,
gfx_slots: Arc::new(RwLock::new(std::array::from_fn(|_| None))),
@ -299,11 +295,8 @@ impl AssetManager {
self.shutdown();
{
let mut asset_map = self.assets.write().unwrap();
let mut name_to_id = self.name_to_id.write().unwrap();
asset_map.clear();
name_to_id.clear();
for entry in assets.iter() {
name_to_id.insert(entry.asset_name.clone(), entry.asset_id);
asset_map.insert(entry.asset_id, entry.clone());
}
}
@ -381,21 +374,18 @@ impl AssetManager {
}
}
pub fn load(&self, asset_name: &str, slot: SlotRef) -> Result<HandleId, AssetLoadError> {
if slot.index >= 16 {
pub fn load(&self, asset_id: AssetId, slot_index: usize) -> Result<HandleId, AssetLoadError> {
if slot_index >= 16 {
return Err(AssetLoadError::SlotIndexInvalid);
}
let entry = {
let assets = self.assets.read().unwrap();
let name_to_id = self.name_to_id.read().unwrap();
let id = name_to_id.get(asset_name).ok_or(AssetLoadError::AssetNotFound)?;
assets.get(id).ok_or(AssetLoadError::BackendError)?.clone()
assets.get(&asset_id).ok_or(AssetLoadError::AssetNotFound)?.clone()
};
let slot = match entry.bank_type {
BankType::TILES => SlotRef::gfx(slot_index),
BankType::SOUNDS => SlotRef::audio(slot_index),
};
let asset_id = entry.asset_id;
if slot.asset_type != entry.bank_type {
return Err(AssetLoadError::SlotKindMismatch);
}
let mut next_id = self.next_handle_id.lock().unwrap();
let handle_id = *next_id;
@ -1021,9 +1011,7 @@ mod tests {
gfx_installer,
sound_installer,
);
let slot = SlotRef::gfx(0);
let handle = am.load("test_tiles", slot).expect("Should start loading");
let handle = am.load(0, 0).expect("Should start loading");
let mut status = am.status(handle);
let start = Instant::now();
@ -1062,13 +1050,13 @@ mod tests {
sound_installer,
);
let handle1 = am.load("test_tiles", SlotRef::gfx(0)).unwrap();
let handle1 = am.load(0, 0).unwrap();
let start = Instant::now();
while am.status(handle1) != LoadStatus::READY && start.elapsed().as_secs() < 5 {
thread::sleep(std::time::Duration::from_millis(10));
}
let handle2 = am.load("test_tiles", SlotRef::gfx(1)).unwrap();
let handle2 = am.load(0, 1).unwrap();
assert_eq!(am.status(handle2), LoadStatus::READY);
let staging = am.gfx_policy.staging.read().unwrap();
@ -1105,9 +1093,7 @@ mod tests {
gfx_installer,
sound_installer,
);
let slot = SlotRef::audio(0);
let handle = am.load("test_sound", slot).expect("Should start loading");
let handle = am.load(1, 0).expect("Should start loading");
let start = Instant::now();
while am.status(handle) != LoadStatus::READY && start.elapsed().as_secs() < 5 {
@ -1171,7 +1157,7 @@ mod tests {
let am =
AssetManager::new(vec![], AssetsPayloadSource::empty(), gfx_installer, sound_installer);
let result = am.load("missing", SlotRef::gfx(0));
let result = am.load(999, 0);
assert_eq!(result, Err(AssetLoadError::AssetNotFound));
}
@ -1189,29 +1175,11 @@ mod tests {
sound_installer,
);
let result = am.load("test_tiles", SlotRef::gfx(16));
let result = am.load(0, 16);
assert_eq!(result, Err(AssetLoadError::SlotIndexInvalid));
}
#[test]
fn test_load_returns_slot_kind_mismatch() {
let banks = Arc::new(MemoryBanks::new());
let gfx_installer = Arc::clone(&banks) as Arc<dyn TileBankPoolInstaller>;
let sound_installer = Arc::clone(&banks) as Arc<dyn SoundBankPoolInstaller>;
let data = test_tile_asset_data();
let am = AssetManager::new(
vec![test_tile_asset_entry("test_tiles", 16, 16)],
AssetsPayloadSource::from_bytes(data),
gfx_installer,
sound_installer,
);
let result = am.load("test_tiles", SlotRef::audio(0));
assert_eq!(result, Err(AssetLoadError::SlotKindMismatch));
}
#[test]
fn test_status_returns_unknown_handle() {
let banks = Arc::new(MemoryBanks::new());
@ -1239,7 +1207,7 @@ mod tests {
assert_eq!(am.commit(999), AssetOpStatus::UnknownHandle);
assert_eq!(am.cancel(999), AssetOpStatus::UnknownHandle);
let handle = am.load("test_tiles", SlotRef::gfx(0)).expect("load must allocate handle");
let handle = am.load(0, 0).expect("load must allocate handle");
let start = Instant::now();
while am.status(handle) != LoadStatus::READY && start.elapsed().as_secs() < 5 {
thread::sleep(std::time::Duration::from_millis(10));

View File

@ -1,5 +1,5 @@
use crate::asset::{
AssetEntry, AssetLoadError, AssetOpStatus, BankStats, BankType, HandleId, LoadStatus,
AssetEntry, AssetId, AssetLoadError, AssetOpStatus, BankStats, BankType, HandleId, LoadStatus,
PreloadEntry, SlotRef, SlotStats,
};
use crate::cartridge::AssetsPayloadSource;
@ -11,7 +11,7 @@ pub trait AssetBridge {
preload: Vec<PreloadEntry>,
assets_data: AssetsPayloadSource,
);
fn load(&self, asset_name: &str, slot: SlotRef) -> Result<HandleId, AssetLoadError>;
fn load(&self, asset_id: AssetId, slot_index: usize) -> Result<HandleId, AssetLoadError>;
fn status(&self, handle: HandleId) -> LoadStatus;
fn commit(&self, handle: HandleId) -> AssetOpStatus;
fn cancel(&self, handle: HandleId) -> AssetOpStatus;

View File

@ -7,7 +7,7 @@ pub(crate) const ENTRIES: &[SyscallRegistryEntry] = &[
"asset",
"load",
1,
3,
2,
2,
caps::ASSET,
Determinism::NonDeterministic,

View File

@ -215,7 +215,7 @@ fn status_first_syscall_signatures_are_pinned() {
assert_eq!(audio_play.ret_slots, 1);
let asset_load = meta_for(Syscall::AssetLoad);
assert_eq!(asset_load.arg_slots, 3);
assert_eq!(asset_load.arg_slots, 2);
assert_eq!(asset_load.ret_slots, 2);
let asset_commit = meta_for(Syscall::AssetCommit);
@ -256,7 +256,7 @@ fn declared_resolver_rejects_legacy_status_first_signatures() {
name: "load".into(),
version: 1,
arg_slots: 3,
ret_slots: 1,
ret_slots: 2,
},
prometeu_bytecode::SyscallDecl {
module: "asset".into(),
@ -292,8 +292,10 @@ fn declared_resolver_rejects_legacy_status_first_signatures() {
assert_eq!(version, decl.version);
assert_eq!(declared_arg_slots, decl.arg_slots);
assert_eq!(declared_ret_slots, decl.ret_slots);
assert_eq!(expected_arg_slots, declared_arg_slots);
assert_ne!(expected_ret_slots, declared_ret_slots);
assert!(
expected_arg_slots != declared_arg_slots
|| expected_ret_slots != declared_ret_slots
);
}
other => panic!("expected AbiMismatch, got {:?}", other),
}
@ -321,7 +323,7 @@ fn declared_resolver_accepts_mixed_status_first_surface_as_a_single_module() {
module: "asset".into(),
name: "load".into(),
version: 1,
arg_slots: 3,
arg_slots: 2,
ret_slots: 2,
},
prometeu_bytecode::SyscallDecl {
@ -333,8 +335,9 @@ fn declared_resolver_accepts_mixed_status_first_surface_as_a_single_module() {
},
];
let resolved = resolve_declared_program_syscalls(&declared, caps::GFX | caps::AUDIO | caps::ASSET)
.expect("mixed status-first surface must resolve together");
let resolved =
resolve_declared_program_syscalls(&declared, caps::GFX | caps::AUDIO | caps::ASSET)
.expect("mixed status-first surface must resolve together");
assert_eq!(resolved.len(), declared.len());
assert_eq!(resolved[0].meta.ret_slots, 1);

View File

@ -1,7 +1,7 @@
use super::*;
use prometeu_bytecode::{TRAP_INVALID_SYSCALL, TRAP_OOB, TRAP_TYPE, Value};
use crate::services::memcard::{MemcardSlotState, MemcardStatus};
use prometeu_hal::asset::{AssetOpStatus, BankType, SlotRef};
use prometeu_bytecode::{TRAP_INVALID_SYSCALL, TRAP_OOB, TRAP_TYPE, Value};
use prometeu_hal::asset::{AssetId, AssetOpStatus, BankType, SlotRef};
use prometeu_hal::cartridge::AppMode;
use prometeu_hal::color::Color;
use prometeu_hal::log::{LogLevel, LogSource};
@ -446,15 +446,13 @@ impl NativeInterface for VirtualMachineRuntime {
Ok(())
}
Syscall::AssetLoad => {
let asset_id = expect_string(args, 0, "asset_id")?;
let asset_type = match expect_int(args, 1)? as u32 {
0 => BankType::TILES,
1 => BankType::SOUNDS,
_ => return Err(VmFault::Trap(TRAP_TYPE, "Invalid asset type".to_string())),
};
let slot = SlotRef { asset_type, index: expect_int(args, 2)? as usize };
let raw_asset_id = expect_int(args, 0)?;
let asset_id = AssetId::try_from(raw_asset_id).map_err(|_| {
VmFault::Trap(TRAP_TYPE, format!("asset_id out of i32 range: {}", raw_asset_id))
})?;
let slot_index = expect_int(args, 1)? as usize;
match hw.assets().load(&asset_id, slot) {
match hw.assets().load(asset_id, slot_index) {
Ok(handle) => {
ret.push_int(AssetOpStatus::Ok as i64);
ret.push_int(handle as i64);
@ -566,10 +564,12 @@ fn hex_decode(s: &str) -> Result<Vec<u8>, VmFault> {
let mut out = Vec::with_capacity(bytes.len() / 2);
let mut i = 0usize;
while i < bytes.len() {
let hi = nibble(bytes[i])
.ok_or_else(|| VmFault::Trap(TRAP_TYPE, "payload_hex contains invalid hex".to_string()))?;
let lo = nibble(bytes[i + 1])
.ok_or_else(|| VmFault::Trap(TRAP_TYPE, "payload_hex contains invalid hex".to_string()))?;
let hi = nibble(bytes[i]).ok_or_else(|| {
VmFault::Trap(TRAP_TYPE, "payload_hex contains invalid hex".to_string())
})?;
let lo = nibble(bytes[i + 1]).ok_or_else(|| {
VmFault::Trap(TRAP_TYPE, "payload_hex contains invalid hex".to_string())
})?;
out.push((hi << 4) | lo);
i += 2;
}

View File

@ -605,16 +605,14 @@ fn tick_asset_load_missing_asset_returns_status_and_zero_handle() {
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let signals = InputSignals::default();
let code =
assemble("PUSH_CONST 0\nPUSH_I32 0\nPUSH_I32 0\nHOSTCALL 0\nHALT").expect("assemble");
let program = serialized_single_function_module_with_consts(
let code = assemble("PUSH_I32 999\nPUSH_I32 0\nHOSTCALL 0\nHALT").expect("assemble");
let program = serialized_single_function_module(
code,
vec![ConstantPoolEntry::String("missing_asset".into())],
vec![SyscallDecl {
module: "asset".into(),
name: "load".into(),
version: 1,
arg_slots: 3,
arg_slots: 2,
ret_slots: 2,
}],
);
@ -642,16 +640,14 @@ fn tick_asset_load_invalid_slot_returns_status_and_zero_handle() {
vec![],
AssetsPayloadSource::from_bytes(asset_data),
);
let code =
assemble("PUSH_CONST 0\nPUSH_I32 0\nPUSH_I32 16\nHOSTCALL 0\nHALT").expect("assemble");
let program = serialized_single_function_module_with_consts(
let code = assemble("PUSH_I32 7\nPUSH_I32 16\nHOSTCALL 0\nHALT").expect("assemble");
let program = serialized_single_function_module(
code,
vec![ConstantPoolEntry::String("tile_asset".into())],
vec![SyscallDecl {
module: "asset".into(),
name: "load".into(),
version: 1,
arg_slots: 3,
arg_slots: 2,
ret_slots: 2,
}],
);
@ -667,43 +663,6 @@ fn tick_asset_load_invalid_slot_returns_status_and_zero_handle() {
);
}
#[test]
fn tick_asset_load_kind_mismatch_returns_status_and_zero_handle() {
let mut runtime = VirtualMachineRuntime::new(None);
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let signals = InputSignals::default();
let asset_data = test_tile_asset_data();
hardware.assets.initialize_for_cartridge(
vec![test_tile_asset_entry("tile_asset", asset_data.len())],
vec![],
AssetsPayloadSource::from_bytes(asset_data),
);
let code =
assemble("PUSH_CONST 0\nPUSH_I32 1\nPUSH_I32 0\nHOSTCALL 0\nHALT").expect("assemble");
let program = serialized_single_function_module_with_consts(
code,
vec![ConstantPoolEntry::String("tile_asset".into())],
vec![SyscallDecl {
module: "asset".into(),
name: "load".into(),
version: 1,
arg_slots: 3,
ret_slots: 2,
}],
);
let cartridge = cartridge_with_program(program, caps::ASSET);
runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize");
let report = runtime.tick(&mut vm, &signals, &mut hardware);
assert!(report.is_none(), "slot kind mismatch must not crash");
assert!(vm.is_halted());
assert_eq!(
vm.operand_stack_top(2),
vec![Value::Int64(0), Value::Int64(AssetLoadError::SlotKindMismatch as i64)]
);
}
#[test]
fn tick_asset_status_unknown_handle_returns_status_not_crash() {
let mut runtime = VirtualMachineRuntime::new(None);
@ -765,10 +724,7 @@ fn tick_asset_commit_invalid_transition_returns_status_not_crash() {
vec![],
AssetsPayloadSource::from_bytes(asset_data),
);
let handle = hardware
.assets
.load("tile_asset", prometeu_hal::asset::SlotRef::gfx(0))
.expect("asset handle must be allocated");
let handle = hardware.assets.load(7, 0).expect("asset handle must be allocated");
runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize");
let report = runtime.tick(&mut vm, &signals, &mut hardware);
@ -829,10 +785,7 @@ fn tick_asset_cancel_invalid_transition_returns_status_not_crash() {
vec![],
AssetsPayloadSource::from_bytes(asset_data),
);
let handle = hardware
.assets
.load("tile_asset", prometeu_hal::asset::SlotRef::gfx(0))
.expect("asset handle must be allocated");
let handle = hardware.assets.load(7, 0).expect("asset handle must be allocated");
runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize");
@ -856,41 +809,6 @@ fn tick_asset_cancel_invalid_transition_returns_status_not_crash() {
assert_eq!(vm.operand_stack_top(1), vec![Value::Int64(AssetOpStatus::InvalidState as i64)]);
}
#[test]
fn tick_asset_load_invalid_kind_surfaces_trap_not_panic() {
let mut runtime = VirtualMachineRuntime::new(None);
let mut vm = VirtualMachine::default();
let mut hardware = Hardware::new();
let signals = InputSignals::default();
let code =
assemble("PUSH_CONST 0\nPUSH_I32 2\nPUSH_I32 0\nHOSTCALL 0\nHALT").expect("assemble");
let program = serialized_single_function_module_with_consts(
code,
vec![ConstantPoolEntry::String("tile_asset".into())],
vec![SyscallDecl {
module: "asset".into(),
name: "load".into(),
version: 1,
arg_slots: 3,
ret_slots: 2,
}],
);
let cartridge = cartridge_with_program(program, caps::ASSET);
runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize");
let report = runtime
.tick(&mut vm, &signals, &mut hardware)
.expect("invalid asset kind must surface as trap");
match report {
CrashReport::VmTrap { trap } => {
assert_eq!(trap.code, TRAP_TYPE);
assert!(trap.message.contains("Invalid asset type"));
}
other => panic!("expected VmTrap crash report, got {:?}", other),
}
assert!(matches!(runtime.last_crash_report, Some(CrashReport::VmTrap { .. })));
}
#[test]
fn tick_status_first_surface_smoke_across_gfx_audio_and_asset() {
let mut runtime = VirtualMachineRuntime::new(None);
@ -900,13 +818,12 @@ fn tick_status_first_surface_smoke_across_gfx_audio_and_asset() {
let code = assemble(
"PUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_BOOL 1\nPUSH_BOOL 0\nPUSH_BOOL 0\nPUSH_I32 0\nHOSTCALL 0\n\
PUSH_I32 0\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 255\nPUSH_I32 128\nPUSH_I32 1\nPUSH_I32 0\nHOSTCALL 1\n\
PUSH_CONST 0\nPUSH_I32 0\nPUSH_I32 0\nHOSTCALL 2\n\
PUSH_I32 999\nPUSH_I32 0\nHOSTCALL 2\n\
HALT"
)
.expect("assemble");
let program = serialized_single_function_module_with_consts(
let program = serialized_single_function_module(
code,
vec![ConstantPoolEntry::String("missing_asset".into())],
vec![
SyscallDecl {
module: "gfx".into(),
@ -926,7 +843,7 @@ fn tick_status_first_surface_smoke_across_gfx_audio_and_asset() {
module: "asset".into(),
name: "load".into(),
version: 1,
arg_slots: 3,
arg_slots: 2,
ret_slots: 2,
},
],

View File

@ -2940,7 +2940,7 @@ mod tests {
module: "asset".into(),
name: "load".into(),
version: 1,
arg_slots: 3,
arg_slots: 2,
ret_slots: 2,
},
SyscallDecl {
@ -2996,7 +2996,7 @@ mod tests {
module: "asset".into(),
name: "load".into(),
version: 1,
arg_slots: 3,
arg_slots: 2,
ret_slots: 1,
},
SyscallDecl {
@ -3021,17 +3021,19 @@ mod tests {
let bytes = serialized_single_hostcall_module(syscall.clone());
let err = vm.initialize(bytes).expect_err("legacy ABI must be rejected");
match err {
VmInitError::LoaderPatchFailed(crate::vm_init_error::LoaderPatchError::ResolveFailed(
prometeu_hal::syscalls::DeclaredLoadError::AbiMismatch {
module,
name,
version,
declared_arg_slots,
declared_ret_slots,
expected_arg_slots,
expected_ret_slots,
},
)) => {
VmInitError::LoaderPatchFailed(
crate::vm_init_error::LoaderPatchError::ResolveFailed(
prometeu_hal::syscalls::DeclaredLoadError::AbiMismatch {
module,
name,
version,
declared_arg_slots,
declared_ret_slots,
expected_arg_slots,
expected_ret_slots,
},
),
) => {
assert_eq!(module, syscall.module);
assert_eq!(name, syscall.name);
assert_eq!(version, syscall.version);

View File

@ -1,4 +1,4 @@
{"type":"meta","next_id":{"DSC":18,"AGD":17,"DEC":1,"PLN":1,"LSN":19,"CLSN":1}}
{"type":"meta","next_id":{"DSC":19,"AGD":18,"DEC":2,"PLN":2,"LSN":20,"CLSN":1}}
{"type":"discussion","id":"DSC-0001","status":"done","ticket":"legacy-runtime-learn-import","title":"Import legacy runtime learn into discussion lessons","created_at":"2026-03-27","updated_at":"2026-03-27","tags":["migration","tech-debt"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0001","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0001-prometeu-learn-index.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0002","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0002-historical-asset-status-first-fault-and-return-contract.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0003","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0003-historical-audio-status-first-fault-and-return-contract.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0004","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0004-historical-cartridge-boot-protocol-and-manifest-authority.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0005","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0005-historical-game-memcard-slots-surface-and-semantics.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0006","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0006-historical-gfx-status-first-fault-and-return-contract.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0007","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0007-historical-retired-fault-and-input-decisions.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0008","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0008-historical-vm-core-and-assets.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0009","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0009-mental-model-asset-management.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0010","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0010-mental-model-audio.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0011","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0011-mental-model-gfx.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0012","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0012-mental-model-input.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0013","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0013-mental-model-observability-and-debugging.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0014","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0014-mental-model-portability-and-cross-platform.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0015","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0015-mental-model-save-memory-and-memcard.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0016","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0016-mental-model-status-first-and-fault-thinking.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0017","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0017-mental-model-time-and-cycles.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0018","file":"lessons/DSC-0001-runtime-learn-legacy-import/LSN-0018-mental-model-touch.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"}]}
{"type":"discussion","id":"DSC-0002","status":"open","ticket":"runtime-edge-test-plan","title":"Agenda - Runtime Edge Test Plan","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0001","file":"AGD-0001-runtime-edge-test-plan.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0003","status":"open","ticket":"packed-cartridge-loader-pmc","title":"Agenda - Packed Cartridge Loader PMC","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0002","file":"AGD-0002-packed-cartridge-loader-pmc.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
@ -16,3 +16,4 @@
{"type":"discussion","id":"DSC-0015","status":"open","ticket":"perf-cartridge-boot-and-program-ownership","title":"Agenda - [PERF] Cartridge Boot and Program Ownership","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0014","file":"AGD-0014-perf-cartridge-boot-and-program-ownership.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0016","status":"open","ticket":"tilemap-empty-cell-vs-tile-id-zero","title":"Tilemap Empty Cell vs Tile ID Zero","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0015","file":"AGD-0015-tilemap-empty-cell-vs-tile-id-zero.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0017","status":"open","ticket":"asset-entry-metadata-normalization-contract","title":"Asset Entry Metadata Normalization Contract","created_at":"2026-03-27","updated_at":"2026-03-27","tags":[],"agendas":[{"id":"AGD-0016","file":"AGD-0016-asset-entry-metadata-normalization-contract.md","status":"open","created_at":"2026-03-27","updated_at":"2026-03-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0018","status":"done","ticket":"asset-load-asset-id-int-contract","title":"Asset Load Asset ID Int Contract","created_at":"2026-03-27","updated_at":"2026-03-27","tags":["asset","runtime","abi"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0019","file":"lessons/DSC-0018-asset-load-asset-id-int-contract/LSN-0019-asset-load-id-abi-convergence.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"}]}

View File

@ -0,0 +1,48 @@
---
id: LSN-0019
ticket: asset-load-asset-id-int-contract
title: Asset Load ABI Must Converge with Asset Table Identity
created: 2026-03-27
tags: [asset, runtime, abi]
---
## Context
The runtime had already converged on numeric asset identity in the cartridge model:
- `AssetId` was `i32`;
- `preload` used `{ asset_id, slot }`;
- cartridge bootstrap validated preload entries against `asset_table` by `asset_id`.
The remaining inconsistency was the public runtime syscall surface. `asset.load` still accepted a string identifier plus an explicit asset kind, forcing the runtime to keep a `name_to_id` translation path alive even though `asset_table` already carried canonical identity and bank classification.
## Key Decisions
### Asset Load Asset ID Int Contract
**What:**
`asset.load` now uses the canonical runtime form `asset.load(asset_id, slot)`. The public ABI no longer accepts string asset names or an explicit `asset_type` argument.
**Why:**
Once `asset_table` exists, asset kind is derivable from `asset_id`. Keeping `asset_type` in the ABI only duplicates information and creates a second place for disagreement. Keeping string names in the load path also preserves a shadow identity model that the runtime no longer needs.
**Trade-offs:**
This is a hard cut with no compatibility layer. Legacy producers that still emit name-based asset loads must update, but the runtime stays simpler and the contract becomes unambiguous.
## Patterns and Algorithms
- Treat `asset_table` as the single source of truth for operational asset identity.
- Infer internal bank-qualified slot routing from the resolved `AssetEntry` rather than from caller-supplied ABI fields.
- Keep human-readable names as metadata and telemetry only, not as runtime lookup keys.
## Pitfalls
- If preload, loader validation, and on-demand runtime loading use different identity forms, the system develops a split contract that is easy to document incorrectly and hard to evolve safely.
- ABI migrations that leave dual support for both names and IDs tend to preserve ambiguity longer than intended.
- Removing a public parameter such as `asset_type` may also remove previously observable error states, so tests and specs must be updated together.
## Takeaways
- When a cartridge-wide identity table already exists, public runtime surfaces should use that identity directly.
- Derivable ABI fields should not remain public inputs once the derivation source is canonical.
- Asset preload and on-demand asset load should follow the same identity model end to end.

View File

@ -163,15 +163,17 @@ The current runtime exposes bank types:
- `TILES`
- `SOUNDS`
Assets are loaded into explicit slots identified by bank context plus index.
Assets are loaded into explicit slots identified by slot index at the public ABI boundary.
Conceptual slot reference:
The runtime resolves bank context from `asset_table` using `asset_id`.
Internally, the runtime may still use a bank-qualified slot reference such as:
```text
SlotRef { bank_type, index }
```
This prevents ambiguity between graphics and audio residency.
That internal representation is derived from the resolved `AssetEntry`, not supplied by the caller.
## 6 Load Lifecycle
@ -285,7 +287,7 @@ Fault boundary:
### 11.1 MVP syscall shape
- `asset.load(name, kind, slot) -> (status:int, handle:int)`
- `asset.load(asset_id, slot) -> (status:int, handle:int)`
- `asset.status(handle) -> status:int`
- `asset.commit(handle) -> status:int`
- `asset.cancel(handle) -> status:int`
@ -295,7 +297,9 @@ Rules:
- `handle` is valid only when `load` status is `OK`;
- failed `load` returns `handle = 0`;
- `commit` and `cancel` must not be silent no-op for unknown/invalid handle state.
- slot validation, kind mismatch, and residency/lifecycle rejection remain in `asset` status space and are not delegated to `bank`.
- `asset.load` resolves the target bank type from `asset_table` using `asset_id`;
- public callers must not supply `asset_name` or `bank_type` to `asset.load`;
- slot validation and residency/lifecycle rejection remain in `asset` status space and are not delegated to `bank`.
### 11.2 Minimum status tables
@ -303,7 +307,6 @@ Rules:
- `0` = `OK`
- `3` = `ASSET_NOT_FOUND`
- `4` = `SLOT_KIND_MISMATCH`
- `5` = `SLOT_INDEX_INVALID`
- `6` = `BACKEND_ERROR`

View File

@ -181,6 +181,23 @@ Canonical operations in v1 are:
Semantics and domain status catalog are defined by [`08-save-memory-and-memcard.md`](08-save-memory-and-memcard.md).
### Asset surface (`asset`, v1)
The asset runtime surface also follows the status-first ABI shape.
Canonical operations in v1 are:
- `asset.load(asset_id, slot) -> (status, handle)`
- `asset.status(handle) -> status`
- `asset.commit(handle) -> status`
- `asset.cancel(handle) -> status`
For `asset.load`:
- `asset_id` is a signed 32-bit runtime identity;
- `slot` is the target slot index;
- bank kind is resolved from `asset_table` by `asset_id`, not supplied by the caller.
## 7 Syscalls as Callable Entities (Not First-Class)
Syscalls behave like call sites, not like first-class guest values.

View File

@ -149,6 +149,7 @@ The verifier statically checks bytecode for structural safety and stackshape
- `SyscallMeta` defines expected arity and return slot counts. The loader resolves `HOSTCALL` against this metadata and rejects raw `SYSCALL` in PBX pre-load artifacts; the verifier checks final IDs/arity/returnslot counts against the same metadata.
- Arguments and returns
- Arguments are taken from the operand stack in the order defined by the ABI. Returns use multislot results via a hostside return buffer (`HostReturn`) which the VM copies back onto the stack, or zero slots for “void”. A mismatch in result counts is a fault/panic per current hardening logic.
- Example: the canonical asset runtime load surface is `asset.load(asset_id, slot) -> (status, handle)`. The caller does not supply `asset_name` or `asset_type`; bank kind is derived from `asset_table` using `asset_id`.
- Capabilities
- Cartridge capability flags are applied before load-time host resolution. Missing required capability aborts load; invoking a syscall without the required capability also traps defensively at runtime.

View File

@ -78,6 +78,14 @@ Authority rule:
- `INTRINSIC u32` — final numeric VM-owned intrinsic call.
- `FRAME_SYNC` — yield until the next frame boundary (e.g., vblank); explicit safepoint.
Host service arity is not encoded in the opcode itself. It is defined by resolved syscall metadata.
Example:
- `asset.load` currently resolves with `arg_slots = 2` and `ret_slots = 2`.
- The canonical stack contract is `asset_id, slot -> status, handle`.
- Callers do not provide an explicit asset kind; the runtime derives it from `asset_table`.
#### Canonical Intrinsic Registry Artifact
- Final intrinsic IDs and intrinsic stack metadata are published in [`INTRINSICS.csv`](INTRINSICS.csv).