Audio Status-First Surface and Fault Matrix

This commit is contained in:
bQUARKz 2026-03-10 09:36:15 +00:00
parent fd5a5cd22c
commit 9cb0e77b01
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
5 changed files with 166 additions and 54 deletions

View File

@ -277,3 +277,54 @@ impl Audio {
self.commands.clear(); self.commands.clear();
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::memory_banks::{MemoryBanks, SoundBankPoolAccess, SoundBankPoolInstaller};
use prometeu_hal::sound_bank::SoundBank;
fn sample() -> Arc<Sample> {
Arc::new(Sample::new(44_100, vec![0, 1, 0, -1]))
}
#[test]
fn play_returns_voice_invalid_for_out_of_range_voice() {
let banks = Arc::new(MemoryBanks::new());
let sound_banks = Arc::clone(&banks) as Arc<dyn SoundBankPoolAccess>;
let mut audio = Audio::new(sound_banks);
let status = audio.play(0, 0, MAX_CHANNELS, 255, 128, 1.0, 0, LoopMode::Off);
assert_eq!(status, AudioOpStatus::VoiceInvalid);
assert!(audio.commands.is_empty());
}
#[test]
fn play_returns_sample_not_found_when_sample_is_missing() {
let banks = Arc::new(MemoryBanks::new());
let installer = Arc::clone(&banks) as Arc<dyn SoundBankPoolInstaller>;
installer.install_sound_bank(0, Arc::new(SoundBank::new(vec![sample()])));
let sound_banks = Arc::clone(&banks) as Arc<dyn SoundBankPoolAccess>;
let mut audio = Audio::new(sound_banks);
let status = audio.play(0, 1, 0, 255, 128, 1.0, 0, LoopMode::Off);
assert_eq!(status, AudioOpStatus::SampleNotFound);
assert!(audio.commands.is_empty());
}
#[test]
fn play_sample_returns_voice_invalid_without_side_effects() {
let banks = Arc::new(MemoryBanks::new());
let sound_banks = Arc::clone(&banks) as Arc<dyn SoundBankPoolAccess>;
let mut audio = Audio::new(sound_banks);
let status =
audio.play_sample(sample(), MAX_CHANNELS, 255, 128, 1.0, 0, LoopMode::Off);
assert_eq!(status, AudioOpStatus::VoiceInvalid);
assert!(!audio.voices.iter().any(|voice| voice.active));
assert!(audio.commands.is_empty());
}
}

View File

@ -195,10 +195,13 @@ impl NativeInterface for VirtualMachineRuntime {
let pan_raw = expect_int(args, 3)?; let pan_raw = expect_int(args, 3)?;
let pitch = expect_number(args, 4, "pitch")?; let pitch = expect_number(args, 4, "pitch")?;
if voice_id_raw < 0 || voice_id_raw >= 16 {
ret.push_int(AudioOpStatus::VoiceInvalid as i64);
return Ok(());
}
if sample_id_raw < 0 if sample_id_raw < 0
|| sample_id_raw > u16::MAX as i64 || sample_id_raw > u16::MAX as i64
|| voice_id_raw < 0
|| voice_id_raw >= 16
|| !(0..=255).contains(&volume_raw) || !(0..=255).contains(&volume_raw)
|| !(0..=255).contains(&pan_raw) || !(0..=255).contains(&pan_raw)
|| !pitch.is_finite() || !pitch.is_finite()
@ -222,13 +225,7 @@ impl NativeInterface for VirtualMachineRuntime {
Ok(()) Ok(())
} }
Syscall::AudioPlay => { Syscall::AudioPlay => {
let asset_name = match args let asset_name = expect_string(args, 0, "asset_name")?;
.first()
.ok_or_else(|| VmFault::Panic("Missing asset_name".into()))?
{
Value::String(s) => s.clone(),
_ => return Err(VmFault::Trap(TRAP_TYPE, "Expected string asset_name".into())),
};
let sample_id_raw = expect_int(args, 1)?; let sample_id_raw = expect_int(args, 1)?;
let voice_id_raw = expect_int(args, 2)?; let voice_id_raw = expect_int(args, 2)?;
let volume_raw = expect_int(args, 3)?; let volume_raw = expect_int(args, 3)?;
@ -239,10 +236,13 @@ impl NativeInterface for VirtualMachineRuntime {
_ => prometeu_hal::LoopMode::On, _ => prometeu_hal::LoopMode::On,
}; };
if voice_id_raw < 0 || voice_id_raw >= 16 {
ret.push_int(AudioOpStatus::VoiceInvalid as i64);
return Ok(());
}
if sample_id_raw < 0 if sample_id_raw < 0
|| sample_id_raw > u16::MAX as i64 || sample_id_raw > u16::MAX as i64
|| voice_id_raw < 0
|| voice_id_raw >= 16
|| !(0..=255).contains(&volume_raw) || !(0..=255).contains(&volume_raw)
|| !(0..=255).contains(&pan_raw) || !(0..=255).contains(&pan_raw)
|| !pitch.is_finite() || !pitch.is_finite()

View File

@ -447,6 +447,102 @@ fn tick_audio_play_sample_operational_error_returns_status_not_crash() {
assert_eq!(vm.operand_stack_top(1), vec![Value::Int64(AudioOpStatus::ArgRangeInvalid as i64)]); assert_eq!(vm.operand_stack_top(1), vec![Value::Int64(AudioOpStatus::ArgRangeInvalid as i64)]);
} }
#[test]
fn tick_audio_play_voice_invalid_returns_status_not_crash() {
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 0\nPUSH_I32 16\nPUSH_I32 255\nPUSH_I32 128\nPUSH_I32 1\nPUSH_I32 0\nHOSTCALL 0\nHALT",
)
.expect("assemble");
let program = serialized_single_function_module_with_consts(
code,
vec![ConstantPoolEntry::String("missing_sound_bank".into())],
vec![SyscallDecl {
module: "audio".into(),
name: "play".into(),
version: 1,
arg_slots: 7,
ret_slots: 1,
}],
);
let cartridge = cartridge_with_program(program, caps::AUDIO);
runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize");
let report = runtime.tick(&mut vm, &signals, &mut hardware);
assert!(report.is_none(), "invalid voice must not crash");
assert!(vm.is_halted());
assert_eq!(vm.operand_stack_top(1), vec![Value::Int64(AudioOpStatus::VoiceInvalid as i64)]);
}
#[test]
fn tick_audio_play_missing_asset_returns_status_not_crash() {
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 0\nPUSH_I32 0\nPUSH_I32 255\nPUSH_I32 128\nPUSH_I32 1\nPUSH_I32 0\nHOSTCALL 0\nHALT",
)
.expect("assemble");
let program = serialized_single_function_module_with_consts(
code,
vec![ConstantPoolEntry::String("missing_sound_bank".into())],
vec![SyscallDecl {
module: "audio".into(),
name: "play".into(),
version: 1,
arg_slots: 7,
ret_slots: 1,
}],
);
let cartridge = cartridge_with_program(program, caps::AUDIO);
runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize");
let report = runtime.tick(&mut vm, &signals, &mut hardware);
assert!(report.is_none(), "missing audio asset must not crash");
assert!(vm.is_halted());
assert_eq!(vm.operand_stack_top(1), vec![Value::Int64(AudioOpStatus::AssetNotFound as i64)]);
}
#[test]
fn tick_audio_play_type_mismatch_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_BOOL 1\nPUSH_I32 0\nPUSH_I32 0\nPUSH_I32 255\nPUSH_I32 128\nPUSH_I32 1\nPUSH_I32 0\nHOSTCALL 0\nHALT",
)
.expect("assemble");
let program = serialized_single_function_module(
code,
vec![SyscallDecl {
module: "audio".into(),
name: "play".into(),
version: 1,
arg_slots: 7,
ret_slots: 1,
}],
);
let cartridge = cartridge_with_program(program, caps::AUDIO);
runtime.initialize_vm(&mut vm, &cartridge).expect("runtime must initialize");
let report = runtime
.tick(&mut vm, &signals, &mut hardware)
.expect("type mismatch must surface as trap");
match report {
CrashReport::VmTrap { trap } => {
assert_eq!(trap.code, TRAP_TYPE);
assert!(trap.message.contains("Expected string asset_name"));
}
other => panic!("expected VmTrap crash report, got {:?}", other),
}
assert!(matches!(runtime.last_crash_report, Some(CrashReport::VmTrap { .. })));
}
#[test] #[test]
fn tick_asset_commit_operational_error_returns_status_not_crash() { fn tick_asset_commit_operational_error_returns_status_not_crash() {
let mut runtime = VirtualMachineRuntime::new(None); let mut runtime = VirtualMachineRuntime::new(None);

View File

@ -1,43 +0,0 @@
# PR003 - Audio Status-First Surface and Fault Matrix
## Briefing
A decision `009` fixou que `audio` deve seguir status-first.
No MVP atual, `audio.play` e `audio.play_sample` devem retornar `status:int`.
## Alvo
Aplicar a decisao `009` em spec e runtime para o dominio `audio`.
Arquivos principais:
- `docs/runtime/specs/05-audio-peripheral.md`
- `crates/console/prometeu-hal/src/syscalls/domains/audio.rs`
- `crates/console/prometeu-system/src/virtual_machine_runtime/dispatch.rs`
- `crates/console/prometeu-drivers/src/audio.rs`
## Escopo Funcional
- mudar surface de retorno de `audio.play` e `audio.play_sample` para `status`;
- remover fallback implicito e no-op silencioso para erros operacionais;
- validar faixas normativas (`volume`, `pan`, `pitch`) com status explicito;
- limpar caminhos que poderiam escalar para `Panic` por input de app.
## Fora de Escopo
- novos comandos de audio (stop/set pan/set pitch etc) fora do contrato atual;
- mudanca de arquitetura do mixer host.
## Critérios de Aceite
- `ret_slots` de `audio.play` e `audio.play_sample` atualizados no registry;
- casos operacionais retornam status inteiro canonico;
- `voice_id` invalido e asset/sample ausente nao ficam silenciosos;
- sem `Panic` operacional no dominio.
## Tests
- `cargo test -p prometeu-system`
- `cargo test -p prometeu-drivers`
- cenarios com `voice_id` invalido, asset ausente e range invalido.

View File

@ -192,6 +192,13 @@ In the current MVP:
- `audio.play` returns `status:int`; - `audio.play` returns `status:int`;
- `audio.play_sample` returns `status:int`. - `audio.play_sample` returns `status:int`.
Return-shape matrix in v1 syscall surface:
| Syscall | Return | Policy basis |
| --------------------- | ------------ | -------------------------------------- |
| `audio.play` | `status:int` | operational rejection is observable |
| `audio.play_sample` | `status:int` | operational rejection is observable |
### 11.2 Minimum status table for `play`/`play_sample` ### 11.2 Minimum status table for `play`/`play_sample`
- `0` = `OK` - `0` = `OK`
@ -205,4 +212,5 @@ Operational rules:
- no fallback to default bank when an asset cannot be resolved; - no fallback to default bank when an asset cannot be resolved;
- no silent no-op for invalid `voice_id`; - no silent no-op for invalid `voice_id`;
- invalid `voice_id` must return `VOICE_INVALID`, not `ARG_RANGE_INVALID`;
- invalid numeric ranges (e.g. `volume`, `pan`, `pitch`) must return explicit status. - invalid numeric ranges (e.g. `volume`, `pan`, `pitch`) must return explicit status.