mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Review fixes: - The audio host takes each physics step exactly once (one-step pulses latched, nothing while paused or silenced, at most 4 steps per frame), so pushes and other one-frame events are never dropped or doubled. e2e at 60 fps and above is byte-identical; 30 / 45 fps change only by the latch and the step pair. - Removed the emitter PROFILES fallback, the stray trace dump and the audition script; data-gated tests are #[ignore = "needs the private install data"] and fail loudly when run without it; per-post log lines behind SKATE_AUDIO_TRACE; checked bank parsing, finite output, camera-cut Doppler reset, fewer render allocations. - The 15 verdict-settled A/B switches are removed; their "on" behaviour is the only one (byte-identical). World / NPC audio, hookable by engine systems and mods: - Map-change fix: world and NPC hosts reset when the map's banks are unloaded, so surviving objects sound again. - Engine surface (skate-game::world_audio): TrafficAudio / PedAudio / NpcSkaterAudio components, horn / alarm / speech messages, read-back of who is audible; retail limits (4 cars within 40 m, 15 peds within 50 m with footsteps for the nearest 3, one NPC skater within 30 m), measured in the recompilation. - Mod surface (Lua API 2, `sdk.world_audio.*`); test mod `mods/world-audio-test/` (cars, peds, a ghost skater). - Per-skater audio state builder (local player output identical); remote players take the NPC skater slot; an opt-in non-retail "more audible" setting. - Docs: doc 15 "Hooking up world audio", doc 11 sections, PR plan.
361 lines
14 KiB
Rust
361 lines
14 KiB
Rust
//! The living world's speech rules on the dev install's export (`speech/livingworld.json` with the
|
|
//! `.evt` rules and clip ids, `audio_manifest.json` `world_tuning.speech_tuning`;
|
|
//! `.claude/skills/aems-port/tools/stage_world_audio.py`). Every test skips without that data.
|
|
//! Headless: no audio device, no game.
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
|
|
use skate_audio::world::speech::{Clip, SpeechIndex, Take, parse_name};
|
|
use skate_audio::world::speech_manager::{EventTuning, FAR, GateInputs, NEAR, Refusal, Speaker, SpeechManager, event_for_value, kind, speaker_bits};
|
|
use skate_audio::world::speech_rules::{ClipHeader, ClipRef, Event, EventTable, Library, Record};
|
|
|
|
fn root() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
|
|
}
|
|
|
|
/// A minimal JSON reader for our own export files (test support only).
|
|
#[derive(Clone, Debug)]
|
|
enum J {
|
|
Null,
|
|
Bool(bool),
|
|
Num(f64),
|
|
Str(String),
|
|
Arr(Vec<J>),
|
|
Obj(Vec<(String, J)>),
|
|
}
|
|
|
|
impl J {
|
|
fn get(&self, key: &str) -> Option<&J> {
|
|
match self {
|
|
J::Obj(v) => v.iter().find(|(k, _)| k == key).map(|(_, v)| v),
|
|
_ => None,
|
|
}
|
|
}
|
|
fn arr(&self) -> &[J] {
|
|
match self {
|
|
J::Arr(v) => v,
|
|
_ => &[],
|
|
}
|
|
}
|
|
fn num(&self) -> f64 {
|
|
match self {
|
|
J::Num(n) => *n,
|
|
J::Bool(b) => f64::from(u8::from(*b)),
|
|
_ => 0.0,
|
|
}
|
|
}
|
|
fn str(&self) -> &str {
|
|
match self {
|
|
J::Str(s) => s,
|
|
_ => "",
|
|
}
|
|
}
|
|
fn is_null(&self) -> bool {
|
|
matches!(self, J::Null)
|
|
}
|
|
}
|
|
|
|
fn parse_json(text: &str) -> J {
|
|
fn ws(b: &[u8], i: &mut usize) {
|
|
while *i < b.len() && b[*i].is_ascii_whitespace() {
|
|
*i += 1;
|
|
}
|
|
}
|
|
fn string(b: &[u8], i: &mut usize) -> String {
|
|
*i += 1;
|
|
let mut out = Vec::new();
|
|
while b[*i] != b'"' {
|
|
if b[*i] == b'\\' {
|
|
*i += 1;
|
|
match b[*i] {
|
|
b'n' => out.push(b'\n'),
|
|
b't' => out.push(b'\t'),
|
|
b'u' => {
|
|
let code = u32::from_str_radix(std::str::from_utf8(&b[*i + 1..*i + 5]).unwrap(), 16).unwrap();
|
|
out.extend_from_slice(char::from_u32(code).unwrap_or('?').to_string().as_bytes());
|
|
*i += 4;
|
|
}
|
|
c => out.push(c),
|
|
}
|
|
} else {
|
|
out.push(b[*i]);
|
|
}
|
|
*i += 1;
|
|
}
|
|
*i += 1;
|
|
String::from_utf8_lossy(&out).into_owned()
|
|
}
|
|
fn value(b: &[u8], i: &mut usize) -> J {
|
|
ws(b, i);
|
|
match b[*i] {
|
|
b'{' => {
|
|
*i += 1;
|
|
let mut v = Vec::new();
|
|
loop {
|
|
ws(b, i);
|
|
if b[*i] == b'}' {
|
|
*i += 1;
|
|
return J::Obj(v);
|
|
}
|
|
let k = string(b, i);
|
|
ws(b, i);
|
|
*i += 1; // ':'
|
|
v.push((k, value(b, i)));
|
|
ws(b, i);
|
|
if b[*i] == b',' {
|
|
*i += 1;
|
|
}
|
|
}
|
|
}
|
|
b'[' => {
|
|
*i += 1;
|
|
let mut v = Vec::new();
|
|
loop {
|
|
ws(b, i);
|
|
if b[*i] == b']' {
|
|
*i += 1;
|
|
return J::Arr(v);
|
|
}
|
|
v.push(value(b, i));
|
|
ws(b, i);
|
|
if b[*i] == b',' {
|
|
*i += 1;
|
|
}
|
|
}
|
|
}
|
|
b'"' => J::Str(string(b, i)),
|
|
b't' => {
|
|
*i += 4;
|
|
J::Bool(true)
|
|
}
|
|
b'f' => {
|
|
*i += 5;
|
|
J::Bool(false)
|
|
}
|
|
b'n' => {
|
|
*i += 4;
|
|
J::Null
|
|
}
|
|
_ => {
|
|
let start = *i;
|
|
while *i < b.len() && matches!(b[*i], b'-' | b'+' | b'.' | b'e' | b'E' | b'0'..=b'9') {
|
|
*i += 1;
|
|
}
|
|
J::Num(std::str::from_utf8(&b[start..*i]).unwrap().parse().unwrap())
|
|
}
|
|
}
|
|
}
|
|
let mut i = 0;
|
|
value(text.as_bytes(), &mut i)
|
|
}
|
|
|
|
struct Data {
|
|
index: SpeechIndex,
|
|
table: EventTable,
|
|
headers: Vec<ClipHeader>,
|
|
tuning: HashMap<u16, EventTuning>,
|
|
}
|
|
|
|
fn load() -> Option<Data> {
|
|
let dir = root().join("assets/private/audio");
|
|
let index = parse_json(&std::fs::read_to_string(dir.join("speech/livingworld.json")).ok()?);
|
|
let rules = index.get("rules")?;
|
|
let mut clips = Vec::new();
|
|
let mut ids = Vec::new();
|
|
let mut headers = Vec::new();
|
|
for c in index.get("clips")?.arr() {
|
|
let name = c.get("name")?.str().to_owned();
|
|
let (event, voice, voice_name, line) = parse_name(&name)?;
|
|
let takes: Vec<Take> = c.get("takes")?.arr().iter().map(|t| Take { offset: t.get("offset").map_or(0.0, J::num) as u32, size: t.get("size").map_or(0.0, J::num) as u32, rate: t.get("rate").map_or(0.0, J::num) as u32, samples: t.get("samples").map_or(0.0, J::num) as u32 }).collect();
|
|
let id = c.get("id").filter(|j| !j.is_null()).map(|j| j.num() as u16);
|
|
if let Some(id) = id {
|
|
headers.push(ClipHeader { id, takes: takes.len() as u8, history: c.get("history").map_or(0.0, J::num) as u8, flags: 0 });
|
|
}
|
|
ids.push(id);
|
|
clips.push(Clip { name, event, voice, voice_name, line, takes });
|
|
}
|
|
let mut index = SpeechIndex::new(clips);
|
|
index.set_ids(&ids);
|
|
let events = rules
|
|
.get("events")?
|
|
.arr()
|
|
.iter()
|
|
.map(|e| Event {
|
|
id: e.get("id").unwrap().num() as u16,
|
|
name: e.get("name").unwrap().str().to_owned(),
|
|
queue_timeout: e.get("queue_timeout").unwrap().num() as u16,
|
|
priority: e.get("priority").unwrap().num() as u16,
|
|
conditions: e.get("conditions").unwrap().num() as u8,
|
|
flags: e.get("flags").unwrap().num() as u8,
|
|
probability: e.get("probability").unwrap().num() as u8,
|
|
flags2: e.get("flags2").unwrap().num() as u8,
|
|
fields: e.get("fields").unwrap().arr().iter().map(|f| f.num() as u8).collect(),
|
|
records: e
|
|
.get("records")
|
|
.unwrap()
|
|
.arr()
|
|
.iter()
|
|
.map(|r| Record {
|
|
weight_code: r.get("weight").unwrap().num() as u8,
|
|
probability: r.get("probability").unwrap().num() as u8,
|
|
mode: r.get("mode").unwrap().num() as u8,
|
|
locals: r.get("locals").unwrap().num() as u8,
|
|
values: r.get("values").unwrap().arr().iter().map(|v| v.num() as u32).collect(),
|
|
clips: r.get("clips").unwrap().arr().iter().map(|c| ClipRef { id: c.num() as u16, lookup: 0, params: 0 }).collect(),
|
|
})
|
|
.collect(),
|
|
})
|
|
.collect();
|
|
let table = EventTable { bank: rules.get("bank")?.num() as u8, sub_bank: rules.get("sub_bank")?.num() as u8, events };
|
|
let mut tuning = HashMap::new();
|
|
if let Ok(text) = std::fs::read_to_string(dir.join("audio_manifest.json"))
|
|
&& let Some(t) = parse_json(&text).get("world_tuning").and_then(|w| w.get("speech_tuning")).and_then(|s| s.get("1")).cloned()
|
|
&& let J::Obj(entries) = t
|
|
{
|
|
for (id, e) in entries {
|
|
let f = |k: &str| e.get(k).map_or(0.0, J::num) as f32;
|
|
tuning.insert(
|
|
id.parse().unwrap(),
|
|
EventTuning {
|
|
gap: f("gap"),
|
|
priority: f("priority") as i32,
|
|
probability: e.get("probability").map_or(100.0, J::num) as f32,
|
|
repeat: f("repeat"),
|
|
min_player_kmh: f("min_player_kmh"),
|
|
max_player_kmh: f("max_player_kmh"),
|
|
timer_40: f("timer_40"),
|
|
timer_44: f("timer_44"),
|
|
blocked_by: [false; 3],
|
|
zombie: e.get("zombie").is_some_and(|z| z.num() != 0.0),
|
|
not_follow: e.get("not_follow").map_or(&[][..], J::arr).iter().map(|p| (p.arr()[0].num() as u16, p.arr()[1].num() as f32)).collect(),
|
|
challenges: e.get("challenges").map_or(&[][..], J::arr).iter().map(|c| c.num() as i32).collect(),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
Some(Data { index, table, headers, tuning })
|
|
}
|
|
|
|
macro_rules! data {
|
|
() => {
|
|
match load() {
|
|
Some(d) => d,
|
|
None => panic!("missing private data: no speech rules export (stage_world_audio.py)"),
|
|
}
|
|
};
|
|
}
|
|
|
|
fn clip_name(d: &Data, id: u16) -> &str {
|
|
d.index.clip_by_id(id).map_or("?", |i| d.index.clips[i].name.as_str())
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "needs the private install data"]
|
|
fn flag_one_names_the_far_lines_and_two_the_near_ones() {
|
|
let d = data!();
|
|
let (mut agree, mut against) = (0, 0);
|
|
for ev in d.table.events.iter().filter(|e| matches!(e.id, 8202 | 8204 | 8207 | 8210 | 8220)) {
|
|
assert_eq!(ev.fields, vec![1, 2, 3], "{}", ev.name);
|
|
for r in &ev.records {
|
|
for c in &r.clips {
|
|
let line = clip_name(&d, c.id).trim_end_matches(".dat").to_ascii_lowercase();
|
|
let far = line.ends_with("_f") || line.ends_with("_far");
|
|
let near = line.ends_with("_n") || line.ends_with("_near");
|
|
match (r.values[2], far, near) {
|
|
(1, true, false) | (2, false, true) => agree += 1,
|
|
(1, false, true) | (2, true, false) => against += 1,
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
println!("near/far: {agree} records agree, {against} disagree");
|
|
assert!(agree > 300);
|
|
assert_eq!(against, 0);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "needs the private install data"]
|
|
fn voices_map_to_their_type_and_variant_bits() {
|
|
let d = data!();
|
|
for (voice, bits) in [(59, (kind::BUSINESS_MAN, 1)), (41, (kind::ADULT_MALE, 1)), (49, (kind::ADULT_FEMALE, 8)), (53, (kind::TOURIST_FEMALE, 2)), (87, (kind::BUM, 1)), (90, (kind::SKATER_MALE, 0x10)), (76, (kind::SECURITY_GUARD, 2))] {
|
|
assert_eq!(speaker_bits(&d.table, &d.index, voice), Some(bits), "voice {voice}");
|
|
}
|
|
// Every free-roam reaction event per speaker type: how many voices can say it.
|
|
let mut rng = || 1u32;
|
|
for value in [53, 25, 10, 23, 20, 2, 56, 6, 51] {
|
|
let event = event_for_value(value, kind::JOCK, &mut rng).unwrap();
|
|
let ev = d.table.event(event).unwrap();
|
|
let voices: std::collections::BTreeSet<(u32, u32)> = ev.records.iter().filter(|r| r.values.len() >= 2).map(|r| (r.values[0], r.values[1])).collect();
|
|
println!("value {value:2} → {} ({} records, {} speaker bit pairs)", ev.name, ev.records.len(), voices.len());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "needs the private install data"]
|
|
fn a_bumped_business_man_warns_far_or_near_and_cycles_his_takes() {
|
|
let d = data!();
|
|
let mut lib = Library::new(d.headers.clone());
|
|
let mut manager = SpeechManager::new(d.tuning.clone());
|
|
let warn = manager.tuning.get(&8210).cloned().unwrap_or_default();
|
|
println!("501_warn tuning: repeat {} s, gap {} s, probability {} %, not-follow {:?}", warn.repeat, warn.gap, warn.probability, warn.not_follow);
|
|
let busm1 = Speaker { index: 4, kind: kind::BUSINESS_MAN, variant: 1, ..Default::default() };
|
|
let mut crt = skate_audio::world::Lcg(1);
|
|
let mut now = 1000.0;
|
|
let line = manager.request(&mut lib, &d.table, 53, FAR, &busm1, &GateInputs { now, ..Default::default() }, &mut crt).unwrap();
|
|
assert_eq!(clip_name(&d, line.picks[0].clip), "501_59_busm1_Warn_f.dat");
|
|
assert_eq!(manager.request(&mut lib, &d.table, 53, NEAR, &busm1, &GateInputs { now: now + 1.0, ..Default::default() }, &mut crt), Err(Refusal::Repeat));
|
|
let clip = d.headers.iter().find(|h| clip_name(&d, h.id) == "501_59_busm1_Warn_n.dat").copied().unwrap();
|
|
let mut takes = Vec::new();
|
|
for _ in 0..usize::from(clip.takes) * 2 {
|
|
now += f64::from(warn.repeat.max(warn.gap)) + 1.0;
|
|
let line = manager.request(&mut lib, &d.table, 53, NEAR, &busm1, &GateInputs { now, ..Default::default() }, &mut crt);
|
|
if let Ok(line) = line {
|
|
assert_eq!(clip_name(&d, line.picks[0].clip), "501_59_busm1_Warn_n.dat");
|
|
takes.push(line.picks[0].take);
|
|
}
|
|
}
|
|
println!("busm1 Warn_n takes ({} takes, history {}): {takes:?}", clip.takes, clip.history);
|
|
let n = usize::from(clip.takes);
|
|
if takes.len() >= 2 * n && clip.history == clip.takes {
|
|
let mut first = takes[..n].to_vec();
|
|
first.sort_unstable();
|
|
first.dedup();
|
|
assert_eq!(first.len(), n, "every take once before a repeat");
|
|
assert_eq!(takes[n..2 * n], takes[..n], "then the same order again");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "needs the private install data"]
|
|
fn every_free_roam_record_resolves_or_fails_closed() {
|
|
let d = data!();
|
|
let mut missing = 0;
|
|
let mut total = 0;
|
|
for ev in &d.table.events {
|
|
assert_eq!(ev.conditions, 0, "{}", ev.name);
|
|
for r in &ev.records {
|
|
for c in &r.clips {
|
|
total += 1;
|
|
if d.index.clip_by_id(c.id).is_none() {
|
|
missing += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
println!("{total} clip references, {missing} without a clip (their records never play)");
|
|
assert!(missing * 10 < total);
|
|
// The radio record: chirp, line, chirp, three different takes of the chirps' clip at most once
|
|
// in a row.
|
|
let mut lib = Library::new(d.headers.clone());
|
|
let guard = Speaker { index: 1, kind: kind::SECURITY_GUARD, variant: 2, ..Default::default() };
|
|
let mut manager = SpeechManager::new(d.tuning.clone());
|
|
let mut crt = skate_audio::world::Lcg(7);
|
|
let line = manager.request(&mut lib, &d.table, 1, NEAR, &guard, &GateInputs { now: 1000.0, ..Default::default() }, &mut crt).unwrap();
|
|
let names: Vec<&str> = line.picks.iter().map(|p| clip_name(&d, p.clip)).collect();
|
|
println!("guard radio: {names:?} takes {:?}", line.picks.iter().map(|p| p.take).collect::<Vec<_>>());
|
|
assert_eq!(line.picks.len(), 3);
|
|
assert_eq!(line.picks[0].clip, line.picks[2].clip);
|
|
assert_ne!(line.picks[0].take, line.picks[2].take);
|
|
}
|