the quest windows go stock, and the 20 ms frames were mis-split pairs
- the quest giver's dialog and the quest log are the reference's own `QuestFrame.xml` and `QuestLogFrame.xml`, run off the player's chain at the reference's own toc seats; our 1366-line and 1999-line copies are deleted, and the verbs the stock files call are built — the four `GameTooltip` quest-item and reward-spell setters, the reward spell itself (parsed on all three packets and dropped by the model until now), and two verbs; the detail pane could never scroll because `UpdateScrollChildRect` measured a frame late where the reference's measure is synchronous, so it measures through the installed font engine now; and ten things the reference does that ours did not, each found by a test that had enshrined ours — a shift-click never selects, an empty log hides the whole detail pane and says "No Active Quests", the abandon confirm lives in the popup registry (1944) - the quest bindings' law, read off the binary and folded back: no 1.12 packet carries a quest background material — `GetQuestBackgroundMaterial` (`0x502230`) forks on the dialog's SOURCE object, an item answering its template's PageMaterial, a GameObject its template data, a creature nil (FrameXML's "Parchment"), the id naming a `PageTextMaterial.dbc` row whose six words are nowhere in the binary; its twin `ItemTextGetMaterial` has the same body, so the reader window already held every piece and the giver's feed runs the same resolve; `QuestChooseRewardError` is game-error row `0x98`, and the row plays `igQuestFailed` first, which the verb now queues; the reward-spell getters' third return is `Spell.dbc` Attributes bit 5 (1161 of 22357 rows set — craft-cast spells set, `Pattern:` teaching spells clear), read off the catalog now (1946) - the "20 ms frames" of a raid, chased to their number: the probe's worst frames now carry their churn (entities, assets, first-play sound decodes, live particles, the GPU reading) and in a Blackrock raid with sound on every one of those read flat; then the same window on the PACED clock — under vsync with pipelined rendering the raw delta is mis-split into long/short pairs that sum to exact refresh multiples, so an alone Stormwind leg read 115 of 300 frames over budget with cpu and gpu both at their means, while the paced clock read 0 skipped frames in every leg; what remains real in the raid is `cpu_p99` at 22–23 ms with sound on; and every submesh the streamer spawns now carries why it is an entity, counted per reason — 5.8k resident ADT-doodad submeshes at the Stormwind auction house, 5.3k of them hidden, three prop families the retained pass declines by rule, each riding every per-`Mesh3d` sweep for ~0.4 ms of the alone frame (1947) - a raid's dozen voices cost the mixer ~1.5 ms of every 60 Hz frame: upstream kira spatializes every frame — normalizes, the attenuation curve and a dB→amplitude `powf` per frame per spatial track, two more `powf`s for the volume tween, ~1.7 M `powf`s a second at 48 kHz × 12 tracks for gains that change imperceptibly across a chunk — so kira 0.12.1 is vendored with one patch: the spatial gains and the volume × fade amplitude are evaluated at the chunk's two ends and interpolated across its ~10.7 ms, the granularity FMOD 3, the reference's own mixer, updates them at; nothing a listener can name changes (1945); and rig poses are evaluated in parallel over rigs — each reads its shared clips and writes only its own pose, and a raid's ~400 live rigs were 0.4 ms of one thread walked serially on the main thread's critical path (1947) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Generated
-2
@@ -4117,8 +4117,6 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
[[package]]
|
||||
name = "kira"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "065632c16b39f4c00bc5514de0698474d1b6c36120743a154c78ca27ffc1ffe1"
|
||||
dependencies = [
|
||||
"atomic-arena",
|
||||
"glam 0.33.2",
|
||||
|
||||
@@ -58,6 +58,9 @@ sha1 = "0.10.6"
|
||||
# authority. `third_party/lua-src/BENILLA.md` has the current state and the verifying command.
|
||||
[patch.crates-io]
|
||||
lua-src = { path = "third_party/lua-src" }
|
||||
# kira 0.12.1 with one patch: spatial gains and the volume tween evaluated per CHUNK and
|
||||
# interpolated, not per frame (decision 1945; `third_party/kira/BENILLA.md`).
|
||||
kira = { path = "third_party/kira" }
|
||||
|
||||
# Optimize *dependencies* even in dev builds. avian/parry collision math (and wgpu/bevy render) is
|
||||
# unusably slow at opt-level 0 — a plain `cargo run` crawls to <1 fps once physics is doing real work.
|
||||
|
||||
@@ -209,6 +209,17 @@
|
||||
RegisterForSave("LOCK_ACTIONBAR")
|
||||
ALWAYS_SHOW_MULTIBARS = "0"
|
||||
RegisterForSave("ALWAYS_SHOW_MULTIBARS")
|
||||
-- The quest pair's two flags, likewise UIOptionsFrame_Init's (stock l.110-111, l.122-123),
|
||||
-- here since the quest windows went stock (1944). QUEST_FADING_DISABLE is the reference's
|
||||
-- "0". AUTO_QUEST_WATCH is the string "1" ON PURPOSE (decision 1136): the reference assigns
|
||||
-- the NUMBER 1 while every consumer — stock QuestLogFrame.lua:64/68 and the checkbox's own
|
||||
-- load — compares against the string, so on a fresh 1.12 install the row paints unchecked
|
||||
-- and the auto-watch is dead until the box is clicked; the string ships the advertised
|
||||
-- default as the real one. A recorded deviation (1804's rule), not a quiet one.
|
||||
QUEST_FADING_DISABLE = "0"
|
||||
RegisterForSave("QUEST_FADING_DISABLE")
|
||||
AUTO_QUEST_WATCH = "1"
|
||||
RegisterForSave("AUTO_QUEST_WATCH")
|
||||
|
||||
UIOptionsFrameCheckButtons = { };
|
||||
------------------- Basic Options -------------------
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
||||
registration and the one-line arm) — and it fixes the dialog key, the field names and the
|
||||
one verb by name, so those are reproduced exactly.
|
||||
|
||||
WHY THIS IS ITS OWN FILE, next to QuestFrame.xml and QuestLogFrame.xml rather than inside
|
||||
WHY THIS IS ITS OWN FILE, next to the (stock, since 1944) QuestFrame.xml and QuestLogFrame.xml rather than inside
|
||||
either. It belongs to neither window. The confirm has no window at all — it is raised by
|
||||
`crate::ui_quest_share` off `SMSG_QUEST_CONFIRM_ACCEPT`, exactly as DuelFrame.xml's two
|
||||
dialogs are raised off the duel packets, and answering it sends a packet neither quest
|
||||
|
||||
@@ -2000,6 +2000,40 @@
|
||||
hideOnEscape = 1,
|
||||
}
|
||||
|
||||
-- The quest log's abandon confirm and its with-items twin — ref StaticPopup.lua l.749-775,
|
||||
-- in the reference's own home, which is this file (the same move RENAME_GUILD made). They
|
||||
-- lived in our QuestLogFrame.xml until 1944 took that window stock; stock's Abandon button
|
||||
-- calls `StaticPopup_Show("ABANDON_QUEST", GetAbandonQuestName())` and registers nothing.
|
||||
-- The wording is the GlobalStrings key by name, as this file spells its dialogs (the
|
||||
-- reference wraps the same globals in TEXT()); the with-items twin is declared as the
|
||||
-- reference declares it, whatever `GetAbandonQuestItems()` answers.
|
||||
StaticPopupDialogs["ABANDON_QUEST"] = {
|
||||
text = ABANDON_QUEST_CONFIRM,
|
||||
button1 = YES,
|
||||
button2 = NO,
|
||||
OnAccept = function()
|
||||
AbandonQuest()
|
||||
PlaySound("igQuestLogAbandonQuest")
|
||||
end,
|
||||
timeout = 0,
|
||||
whileDead = 1,
|
||||
exclusive = 1,
|
||||
hideOnEscape = 1,
|
||||
}
|
||||
StaticPopupDialogs["ABANDON_QUEST_WITH_ITEMS"] = {
|
||||
text = ABANDON_QUEST_CONFIRM_WITH_ITEMS,
|
||||
button1 = YES,
|
||||
button2 = NO,
|
||||
OnAccept = function()
|
||||
AbandonQuest()
|
||||
PlaySound("igQuestLogAbandonQuest")
|
||||
end,
|
||||
timeout = 0,
|
||||
whileDead = 1,
|
||||
exclusive = 1,
|
||||
hideOnEscape = 1,
|
||||
}
|
||||
|
||||
-- ref UIParent.lua l.324-343 + l.356-360, the four arms on one driver. Each equip arm
|
||||
-- hides the OTHER dialog first — the two questions are mutually exclusive by construction
|
||||
-- (one item, one destination), and the hide is what cancels the record the new question
|
||||
|
||||
@@ -976,10 +976,16 @@ CraftFrame.xml
|
||||
# every verb the stock file calls at runtime — not just at load — was checked by hand against
|
||||
# the bindings before this line changed.
|
||||
Interface\FrameXML\LootFrame.xml
|
||||
# The questgiver window (decision 0088): four sub-panels over the Era quest API. Loaded after
|
||||
# MerchantFrame (grouping; the BenillaMoney coin helpers now live in MoneyFrame.xml) and GossipFrame (shared parchment
|
||||
# art conventions); its title/panel-button labels use the auto-centred ButtonText slot.
|
||||
QuestFrame.xml
|
||||
# ══ THE QUESTGIVER WINDOW — the reference's own, off the player's chain (1751, 1944) ══
|
||||
# Stock QuestFrame.xml sources QuestFrame.lua and INCLUDES QuestFrameTemplates.xml (the panel,
|
||||
# item and title-button templates the quest log below inherits too), so one row brings the
|
||||
# three-file unit 1913 measured. Its `QuestFrameItems_Update` paints all three windows' reward
|
||||
# grids — the shared function that made the quest log un-migratable alone until the money went
|
||||
# stock (1937). Needs, all above: Fonts (QuestFont/QuestTitleFont), MoneyFrame (the two
|
||||
# MoneyFrameTemplate frames, resolved at LOAD), UiPanels, UIPanelTemplates. Its two saved-variable
|
||||
# flags (QUEST_FADING_DISABLE, AUTO_QUEST_WATCH) are declared where the reference declares them —
|
||||
# the options window's init (OptionsFrame.xml, far above).
|
||||
Interface\FrameXML\QuestFrame.xml
|
||||
# The two guild-charter windows (decision 1672): the guild registrar — the window the "How do I
|
||||
# form a guild?" gossip row opens — and the charter itself, the sheet nine people sign. **Both are
|
||||
# the reference's own files off the player's chain now** (1751, windows 12 and 13). Loaded here,
|
||||
@@ -1002,11 +1008,13 @@ Interface\FrameXML\GuildRegistrarFrame.xml
|
||||
# close/action buttons `inherits=` at LOAD), and the QuestGreeting parchment family it shares with
|
||||
# GuildRegistrarFrame directly above.
|
||||
Interface\FrameXML\PetitionFrame.xml
|
||||
# The quest log window (decision 0088 arc, the quest-log slice): durable player state, not an
|
||||
# NPC session — loaded after MerchantFrame (grouping; the coin helpers are in MoneyFrame.xml now) and
|
||||
# alongside QuestFrame (same arc); the 'L' binding (ui_script/input.rs) calls its
|
||||
# ToggleQuestLog().
|
||||
QuestLogFrame.xml
|
||||
# ══ THE QUEST LOG — the reference's own, off the player's chain (1751, 1944) ══
|
||||
# Stock QuestLogFrame.xml sources QuestLogFrame.lua; the 'L' binding calls its ToggleQuestLog(),
|
||||
# and its reward hovers are GameTooltip:SetQuestLogItem / SetQuestLogRewardSpell (engine, 1944).
|
||||
# After QuestFrame.xml (QuestItemTemplate, QuestMoneyFrame_OnLoad, QuestFrameItems_Update),
|
||||
# MicroMenu.xml (QuestLog_OnShow calls UpdateMicroButtons unguarded) and the same kit as the
|
||||
# giver; the reference's toc has the pair at l.53-54.
|
||||
Interface\FrameXML\QuestLogFrame.xml
|
||||
# The timed-quest countdown window under the minimap (decision 1150): the reference's own
|
||||
# file split. After MinimapCluster (its anchor), UIParent (the manage pass that re-seats it,
|
||||
# and SecondsToTime), GameTooltip (its row hover), and QuestLogFrame — whose window a row
|
||||
|
||||
@@ -52,6 +52,11 @@ impl Plugin for LiveFpsPlugin {
|
||||
samples: Vec::new(),
|
||||
cpu_samples: Vec::new(),
|
||||
cpu_prev: None,
|
||||
churn_samples: Vec::new(),
|
||||
entities_prev: 0,
|
||||
decodes_prev: 0,
|
||||
load_samples: Vec::new(),
|
||||
paced_samples: Vec::new(),
|
||||
cpu_at_start: None,
|
||||
sys_at_start: None,
|
||||
occluded_now: false,
|
||||
@@ -87,6 +92,22 @@ struct LiveFps {
|
||||
/// frame was CPU work or a wait is the first question about a stutter.
|
||||
cpu_samples: Vec<f32>,
|
||||
cpu_prev: Option<f64>,
|
||||
/// Per frame, beside `cpu_samples`: entities added net of removed, mesh assets added,
|
||||
/// model materials added — the three spike signatures a crowd produces (a spell kit
|
||||
/// spawning, a body dressing, a first-seen material realizing). Printed for the worst
|
||||
/// frames only.
|
||||
churn_samples: Vec<(i32, u32, u32, u32)>,
|
||||
entities_prev: usize,
|
||||
decodes_prev: u32,
|
||||
/// Live particles and the GPU meter's freshest reading per frame — the two "same work,
|
||||
/// more of it" signatures the churn triple cannot carry.
|
||||
load_samples: Vec<(u32, f32)>,
|
||||
/// The PACED clock's delta per frame (`Time<Virtual>`, `frame_pace`): under vsync with
|
||||
/// pipelined rendering the raw delta is mis-split into long/short pairs that sum to exact
|
||||
/// refresh multiples, so `samples`' tail counts presentation jitter as frames. The pacer
|
||||
/// snaps a cadenced delta to whole refreshes; a paced delta of two periods is a frame the
|
||||
/// display actually skipped.
|
||||
paced_samples: Vec<f32>,
|
||||
/// Process CPU seconds at the first sampled frame ([`crate::perf::process_cpu_secs`]) — the
|
||||
/// baseline for the window's `cpu_ms`/`cpu_pct`.
|
||||
cpu_at_start: Option<f64>,
|
||||
@@ -144,6 +165,10 @@ type ScreenParams<'w, 's> = (
|
||||
),
|
||||
With<Mesh3d>,
|
||||
>,
|
||||
// Asset adds per frame, for the worst-frame annotation (`LiveFps::churn_samples`).
|
||||
MessageReader<'w, 's, AssetEvent<Mesh>>,
|
||||
MessageReader<'w, 's, AssetEvent<benilla_assets::materials::WowModelMaterial>>,
|
||||
Res<'w, Time<bevy::time::Virtual>>,
|
||||
);
|
||||
|
||||
#[derive(SystemParam)]
|
||||
@@ -196,6 +221,18 @@ fn drive_live_fps(
|
||||
&screen.5,
|
||||
&screen.6,
|
||||
);
|
||||
let (mesh_events, mat_events) = (&mut screen.7, &mut screen.8);
|
||||
let paced_ms = screen.9.delta_secs() * 1000.0;
|
||||
// Read every frame (a reader that only reads inside the window would report the whole
|
||||
// backlog on its first sampled frame).
|
||||
let mesh_added = mesh_events
|
||||
.read()
|
||||
.filter(|e| matches!(e, AssetEvent::Added { .. }))
|
||||
.count() as u32;
|
||||
let mat_added = mat_events
|
||||
.read()
|
||||
.filter(|e| matches!(e, AssetEvent::Added { .. }))
|
||||
.count() as u32;
|
||||
// Drain every frame so the state is current whichever phase we're in — the window can be
|
||||
// occluded before sampling ever starts (a detached launch spawns behind whatever is open).
|
||||
for o in occlusions.read() {
|
||||
@@ -276,12 +313,31 @@ fn drive_live_fps(
|
||||
};
|
||||
probe.cpu_prev = cpu_now;
|
||||
probe.cpu_samples.push(cpu_ms);
|
||||
let n_entities = entities.iter().count();
|
||||
let d_entities = if probe.churn_samples.is_empty() {
|
||||
0
|
||||
} else {
|
||||
n_entities as i32 - probe.entities_prev as i32
|
||||
};
|
||||
probe.entities_prev = n_entities;
|
||||
let decodes = crate::sound::kit_decodes();
|
||||
let d_decodes = decodes.saturating_sub(probe.decodes_prev);
|
||||
probe.decodes_prev = decodes;
|
||||
probe
|
||||
.churn_samples
|
||||
.push((d_entities, mesh_added, mat_added, d_decodes));
|
||||
let mut gpu_now = 0.0;
|
||||
if let Some(gpu) = gpu {
|
||||
let ns = gpu.0.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if ns > 0 {
|
||||
gpu_samples.push(ns as f32 / 1.0e6);
|
||||
gpu_now = ns as f32 / 1.0e6;
|
||||
gpu_samples.push(gpu_now);
|
||||
}
|
||||
}
|
||||
probe
|
||||
.load_samples
|
||||
.push((census.live_particles() as u32, gpu_now));
|
||||
probe.paced_samples.push(paced_ms);
|
||||
if probe.samples.len() < probe.frames {
|
||||
return;
|
||||
}
|
||||
@@ -382,14 +438,25 @@ fn drive_live_fps(
|
||||
c.sort_by(f32::total_cmp);
|
||||
let cat = |q: f32| c[(((c.len() - 1) as f32) * q).round() as usize];
|
||||
let over = probe.samples.iter().filter(|&&f| f > 16.9).count();
|
||||
// Real skipped frames: paced deltas of two refreshes or more.
|
||||
let period = 1000.0 / 60.0;
|
||||
let dropped = probe
|
||||
.paced_samples
|
||||
.iter()
|
||||
.filter(|&&f| f > 1.5 * period)
|
||||
.count();
|
||||
let paced_max = probe.paced_samples.iter().copied().fold(0.0f32, f32::max);
|
||||
let mut worst: Vec<usize> = (0..probe.samples.len()).collect();
|
||||
worst.sort_by(|&a, &b| probe.samples[b].total_cmp(&probe.samples[a]));
|
||||
let worst = worst
|
||||
.iter()
|
||||
.take(6)
|
||||
.map(|&i| {
|
||||
let (de, dm, dmat, dsnd) =
|
||||
probe.churn_samples.get(i).copied().unwrap_or((0, 0, 0, 0));
|
||||
let (np, g) = probe.load_samples.get(i).copied().unwrap_or((0, 0.0));
|
||||
format!(
|
||||
"{i}:{:.1}/{:.1}",
|
||||
"{i}:{:.1}/{:.1}(e{de:+},m+{dm},mat+{dmat},snd+{dsnd},p{np},g{g:.1})",
|
||||
probe.samples[i],
|
||||
probe.cpu_samples.get(i).copied().unwrap_or(0.0)
|
||||
)
|
||||
@@ -398,7 +465,7 @@ fn drive_live_fps(
|
||||
.join(",");
|
||||
format!(
|
||||
" cpu_ms={per_frame_ms:.2} cpu_pct={:.0} cpu_p95={:.2} cpu_p99={:.2} \
|
||||
frames_over={over} worst=[{worst}]",
|
||||
frames_over={over} dropped={dropped} paced_max={paced_max:.1} worst=[{worst}]",
|
||||
per_frame_ms / mean as f64 * 100.0,
|
||||
cat(0.95),
|
||||
cat(0.99),
|
||||
@@ -590,8 +657,21 @@ fn print_vis_census(seen: &benilla_world::world_census::CensusReport) {
|
||||
.map(|(name, vis, gated)| format!("{name}={vis}/gated{gated}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let resident = seen
|
||||
.kinds
|
||||
.iter()
|
||||
.zip(seen.resident.iter())
|
||||
.map(|((name, _, _), n)| format!("{name}={n}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let why = seen
|
||||
.why
|
||||
.iter()
|
||||
.map(|(w, n)| format!("{w}={n}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
println!(
|
||||
"VIS_CENSUS visible-submeshes {line} | tagged={} hidden={} exempt={} no_aabb={}",
|
||||
"VIS_CENSUS visible-submeshes {line} | resident {resident} | why {why} | tagged={} hidden={} exempt={} no_aabb={}",
|
||||
seen.tagged, seen.hidden, seen.exempt, seen.no_aabb
|
||||
);
|
||||
// The escapees always print: a tagged, bounded object the cull left un-hidden is a defect by
|
||||
|
||||
@@ -56,6 +56,11 @@ pub(crate) struct GoTemplate {
|
||||
/// `Some` with a nonzero `page_id` is what makes a right-click *read* it; a type-9 template
|
||||
/// with no page (vanilla ships a handful) opens nothing at all, exactly like the reference.
|
||||
pub(crate) text_page: Option<TextPage>,
|
||||
/// The `PageTextMaterial.dbc` id `GetQuestBackgroundMaterial` answers for a quest sourced from
|
||||
/// this object — by `GAMEOBJECT_TYPE_ID`: QUESTGIVER (2) and GOOBER (9) read `data[2]`, CHEST
|
||||
/// (10) reads `data[9]`, every other type answers none (`0x5f5950`, wow-re
|
||||
/// quest-material-reward-spell-bindings.md §1).
|
||||
pub(crate) quest_material: Option<u32>,
|
||||
}
|
||||
|
||||
/// A MO_TRANSPORT template's path tuple (`gameobject_template.data0..2`, decision 0438).
|
||||
@@ -134,6 +139,11 @@ impl GameObjectTemplates {
|
||||
page_id: data[0].max(0) as u32,
|
||||
material: data[2].max(0) as u32,
|
||||
});
|
||||
let quest_material = match type_id {
|
||||
2 | 9 => Some(data[2].max(0) as u32),
|
||||
10 => Some(data[9].max(0) as u32),
|
||||
_ => None,
|
||||
};
|
||||
self.templates.insert(
|
||||
entry,
|
||||
GoTemplate {
|
||||
@@ -143,6 +153,7 @@ impl GameObjectTemplates {
|
||||
meeting_stone_area,
|
||||
mo_transport,
|
||||
text_page,
|
||||
quest_material,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -183,6 +194,29 @@ mod tests {
|
||||
assert_eq!(go_lock_slot(19), None); // MAILBOX — no lock (opens by USE)
|
||||
}
|
||||
|
||||
/// The quest-giver material arm — by type: QUESTGIVER (2) and GOOBER (9) read `data[2]`,
|
||||
/// CHEST (10) reads `data[9]`, anything else none (`0x5f5950`, wow-re
|
||||
/// quest-material-reward-spell-bindings.md §1).
|
||||
#[test]
|
||||
fn insert_captures_the_quest_material_by_type() {
|
||||
let mut t = GameObjectTemplates::default();
|
||||
let mut data = [0i32; 24];
|
||||
data[2] = 2;
|
||||
data[9] = 3;
|
||||
t.insert(100, 2, "Wanted Poster".into(), &data);
|
||||
t.insert(101, 9, "Book".into(), &data);
|
||||
t.insert(102, 10, "Chest".into(), &data);
|
||||
t.insert(103, 15, "Boat".into(), &data);
|
||||
assert_eq!(t.templates[&100].quest_material, Some(2));
|
||||
assert_eq!(t.templates[&101].quest_material, Some(2));
|
||||
assert_eq!(
|
||||
t.templates[&102].quest_material,
|
||||
Some(3),
|
||||
"a chest reads data[9]"
|
||||
);
|
||||
assert_eq!(t.templates[&103].quest_material, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_captures_mo_transport_tuple() {
|
||||
let mut t = GameObjectTemplates::default();
|
||||
|
||||
@@ -791,11 +791,11 @@ pub fn run(build: BuildId) -> AppExit {
|
||||
.add_plugins(UiLootPlugin)
|
||||
.add_plugins(UiLootRollPlugin)
|
||||
// The questgiver window (decision 0088): fills from the net drain's QuestGiver and drives
|
||||
// QuestFrame.xml's four sub-panels over the Era quest API.
|
||||
// the stock questgiver window's four sub-panels over the Era quest API (1944).
|
||||
.add_plugins(UiQuestPlugin)
|
||||
// The quest-log window (decision 0088's deferred second slice): fills from the self player's
|
||||
// PLAYER_QUEST_LOG descriptor slots + the SMSG_QUEST_QUERY_RESPONSE template cache, and drives
|
||||
// QuestLogFrame.xml over the Era quest-log API.
|
||||
// the stock quest log over the Era quest-log API (1944).
|
||||
.add_plugins(UiQuestLogPlugin)
|
||||
// The party quest-share (decision 1733): the verdict lines on a quest we pushed, and the
|
||||
// escort-quest confirm. Neither is bound to a window, so it is its own plugin rather than a
|
||||
|
||||
@@ -1052,10 +1052,16 @@ impl SoundKits {
|
||||
.with_context(|| format!("reading {path}"))?;
|
||||
let data = mixer::sfx_from_bytes(bytes)?;
|
||||
self.cache.insert(key, data.clone());
|
||||
DECODES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
Ok(data)
|
||||
}
|
||||
}
|
||||
|
||||
/// First-play decodes so far — a chain read plus a decode on the main thread, once per
|
||||
/// distinct kit file per session. `FPS_PROBE` reads it per frame to annotate a tail frame
|
||||
/// (a raid's buff wave meets dozens of distinct spell sounds in its first minute).
|
||||
pub(crate) static DECODES: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
|
||||
/// Startup: load the kit catalog off the chain (absent → no resource; every play site tolerates
|
||||
/// that, the same optional-catalog rule as `Creatures`).
|
||||
pub(super) fn load_sound_kits(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
|
||||
|
||||
@@ -50,6 +50,10 @@ mod water;
|
||||
mod weather;
|
||||
mod zone;
|
||||
pub(crate) use emote::EmoteSounds;
|
||||
/// First-play kit decodes so far (`kit::DECODES`) — the probe's tail annotation.
|
||||
pub(crate) fn kit_decodes() -> u32 {
|
||||
kit::DECODES.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
pub(crate) use glue::GlueSound;
|
||||
pub(crate) use greeting::NpcGreetingRequest;
|
||||
pub(crate) use message::MessageSounds;
|
||||
|
||||
@@ -207,6 +207,33 @@ struct Readable {
|
||||
page_head: u32,
|
||||
}
|
||||
|
||||
/// The material a quest sourced from `guid` paints on — `GetQuestBackgroundMaterial 0x502230`,
|
||||
/// whose body is `ItemTextGetMaterial`'s: the source object by guid, an item's template
|
||||
/// `PageMaterial` or a GameObject's template data by type, the id named through
|
||||
/// `PageTextMaterial.dbc`; a creature, an uncached template or an id with no row is `None`, which
|
||||
/// is FrameXML's "Parchment". A GameObject whose template is not cached yet is asked for.
|
||||
pub(crate) fn object_material(
|
||||
guid: u64,
|
||||
items: &mut Items,
|
||||
go_templates: &mut GameObjectTemplates,
|
||||
materials: Option<&PageMaterials>,
|
||||
commands: &NetCommands,
|
||||
) -> Option<String> {
|
||||
let name = |id: u32| materials.and_then(|m| m.0.name(id)).map(str::to_string);
|
||||
if benilla_protocol::guid::is_gameobject(guid) {
|
||||
return match go_templates.get(guid) {
|
||||
Some(go) => go.quest_material.and_then(name),
|
||||
None => {
|
||||
go_templates.request(guid, commands);
|
||||
None
|
||||
}
|
||||
};
|
||||
}
|
||||
let entry = items.object(guid)?.object_entry()?;
|
||||
let t = items.template(entry, guid, commands)?;
|
||||
name(t.page_material)
|
||||
}
|
||||
|
||||
/// Resolve the object's title + material + page head, whether it is an item or a GameObject. The
|
||||
/// reference does exactly this — one guid lookup with typemask 1, then virtual getters — so a
|
||||
/// single resolve serves both sources.
|
||||
@@ -498,3 +525,78 @@ mod tests {
|
||||
assert!(matches!(&sess.source, ReadSource::Pages { visited } if visited.is_empty()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod quest_material_tests {
|
||||
use super::*;
|
||||
|
||||
fn go_guid(entry: u64) -> u64 {
|
||||
(u64::from(benilla_protocol::guid::HIGH_GAMEOBJECT) << 48) | (entry << 24) | 1
|
||||
}
|
||||
|
||||
/// `GetQuestBackgroundMaterial`'s resolve, the reader's twin: a GameObject source answers its
|
||||
/// template data by type through `PageTextMaterial.dbc`; a creature source answers nothing
|
||||
/// (FrameXML's Parchment); an uncached GameObject is asked for and answers nothing meanwhile.
|
||||
#[test]
|
||||
fn a_quest_sourced_from_an_object_paints_the_objects_material() {
|
||||
let mut items = Items::default();
|
||||
let mut gos = GameObjectTemplates::default();
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let commands = NetCommands(tx);
|
||||
let catalog = PageMaterials(benilla_formats::PageTextMaterialCatalog::from_rows(&[
|
||||
(1, "Parchment"),
|
||||
(2, "Stone"),
|
||||
(3, "Marble"),
|
||||
]));
|
||||
let mut data = [0i32; 24];
|
||||
data[2] = 2;
|
||||
data[9] = 3;
|
||||
gos.insert(100, 2, "Wanted Poster".into(), &data);
|
||||
gos.insert(102, 10, "Chest".into(), &data);
|
||||
assert_eq!(
|
||||
object_material(
|
||||
go_guid(100),
|
||||
&mut items,
|
||||
&mut gos,
|
||||
Some(&catalog),
|
||||
&commands
|
||||
)
|
||||
.as_deref(),
|
||||
Some("Stone")
|
||||
);
|
||||
assert_eq!(
|
||||
object_material(
|
||||
go_guid(102),
|
||||
&mut items,
|
||||
&mut gos,
|
||||
Some(&catalog),
|
||||
&commands
|
||||
)
|
||||
.as_deref(),
|
||||
Some("Marble"),
|
||||
"a chest reads data[9]"
|
||||
);
|
||||
// A creature quest-giver: neither template, nothing asked.
|
||||
let creature = (u64::from(benilla_protocol::guid::HIGH_UNIT) << 48) | (6 << 24) | 1;
|
||||
assert_eq!(
|
||||
object_material(creature, &mut items, &mut gos, Some(&catalog), &commands),
|
||||
None
|
||||
);
|
||||
assert!(rx.try_recv().is_err(), "a creature source asks for nothing");
|
||||
// An uncached object: nothing yet, and the template is asked for.
|
||||
assert_eq!(
|
||||
object_material(
|
||||
go_guid(777),
|
||||
&mut items,
|
||||
&mut gos,
|
||||
Some(&catalog),
|
||||
&commands
|
||||
),
|
||||
None
|
||||
);
|
||||
assert!(
|
||||
rx.try_recv().is_ok(),
|
||||
"an uncached object's template is asked for"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,14 +22,14 @@ use benilla_protocol::messages::{
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_ui::script::{
|
||||
QuestAction, QuestItemView, QuestPanel, QuestState, ScriptValue, UiScript,
|
||||
QuestAction, QuestItemView, QuestPanel, QuestRewardSpell, QuestState, UiScript,
|
||||
};
|
||||
|
||||
use crate::entities::ItemDisplays;
|
||||
use crate::items::Items;
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfPlayer};
|
||||
use crate::ui_action::{show_messages, ui_error_text, MessageSink, Shown, UiError};
|
||||
use crate::ui_action::{show_messages, ui_error_text, MessageSink, Shown, Spells, UiError};
|
||||
use crate::ui_script::UiInput;
|
||||
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
|
||||
|
||||
@@ -397,12 +397,35 @@ fn resolve_items(
|
||||
/// Build the Lua-facing snapshot from the open view — `None` when no window is open. Every
|
||||
/// server-authored text runs the shared chat-macro substitution (`$N`/`$B`/`$G` —
|
||||
/// [`crate::npc_text`]): the wire delivers quest text un-expanded, the client substitutes.
|
||||
/// The quest's reward spell as the getters answer it: `rewSpell` off the packet, resolved to the
|
||||
/// spell's name and icon through the spell catalog (`Spell.dbc`). Zero — no spell — is `None`; an
|
||||
/// id the catalog cannot place still counts (the id is real), with nothing to paint.
|
||||
pub(crate) fn reward_spell_view(
|
||||
spell_id: u32,
|
||||
spells: Option<&Spells>,
|
||||
) -> Option<QuestRewardSpell> {
|
||||
if spell_id == 0 {
|
||||
return None;
|
||||
}
|
||||
let d = spells.and_then(|s| s.catalog.get(spell_id));
|
||||
Some(QuestRewardSpell {
|
||||
spell_id,
|
||||
name: d.map(|d| d.name.clone()),
|
||||
texture: d.and_then(|d| d.icon.clone()),
|
||||
// `isTradeskillSpell` is `Spell.dbc` col 6 `Attributes` bit 5 (`0x20`) — `0x501e59`,
|
||||
// wow-re quest-material-reward-spell-bindings.md §3 (1161 of 22357 rows set; craft-cast
|
||||
// spells set, `Pattern:` teaching spells clear).
|
||||
tradeskill: d.is_some_and(|d| d.attributes & 0x20 != 0),
|
||||
})
|
||||
}
|
||||
|
||||
fn snapshot(
|
||||
giver: &QuestGiver,
|
||||
items: &mut Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
macros: &crate::npc_text::MacroContext,
|
||||
spells: Option<&Spells>,
|
||||
) -> Option<QuestState> {
|
||||
let sub = |t: &str| crate::npc_text::substitute(t, macros);
|
||||
Some(match giver.view.as_ref()? {
|
||||
@@ -432,6 +455,7 @@ fn snapshot(
|
||||
choices: resolve_items(&d.choices, items, icons, commands),
|
||||
rewards: resolve_items(&d.rewards, items, icons, commands),
|
||||
reward_money: d.money.max(0) as u32,
|
||||
reward_spell: reward_spell_view(d.reward_spell, spells),
|
||||
..Default::default()
|
||||
},
|
||||
QuestView::Progress(p) => QuestState {
|
||||
@@ -450,6 +474,7 @@ fn snapshot(
|
||||
choices: resolve_items(&o.choices, items, icons, commands),
|
||||
rewards: resolve_items(&o.rewards, items, icons, commands),
|
||||
reward_money: o.money.max(0) as u32,
|
||||
reward_spell: reward_spell_view(o.reward_spell, spells),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
@@ -479,6 +504,9 @@ fn feed_quest(
|
||||
mut names: ResMut<NameCache>,
|
||||
states: Res<crate::world_state::WorldStates>,
|
||||
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
|
||||
spells: Option<Res<Spells>>,
|
||||
mut go_templates: ResMut<crate::go_templates::GameObjectTemplates>,
|
||||
materials: Option<Res<crate::ui_item_text::PageMaterials>>,
|
||||
mut sink: MessageSink,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<QuestState>>>,
|
||||
mut last_name: Local<crate::ui_script::VmMemo<Option<String>>>,
|
||||
@@ -518,7 +546,24 @@ fn feed_quest(
|
||||
subject: player.as_ref(),
|
||||
states: &states,
|
||||
},
|
||||
spells.as_deref(),
|
||||
);
|
||||
// The panel's background material is the SOURCE object's — an item's page material, a
|
||||
// GameObject's template data — never a creature's, and never on the wire (1946; wow-re
|
||||
// quest-material-reward-spell-bindings.md §1). Resolved the way the reader window resolves its
|
||||
// own, since the two bindings share a body.
|
||||
let fresh = fresh.map(|mut st| {
|
||||
st.background_material = giver.npc.and_then(|source| {
|
||||
crate::ui_item_text::object_material(
|
||||
source,
|
||||
&mut items,
|
||||
&mut go_templates,
|
||||
materials.as_deref(),
|
||||
&commands,
|
||||
)
|
||||
});
|
||||
st
|
||||
});
|
||||
let npc_name = giver
|
||||
.npc
|
||||
.and_then(|g| names.resolve(g, &commands).map(str::to_string));
|
||||
@@ -531,7 +576,9 @@ fn feed_quest(
|
||||
return;
|
||||
}
|
||||
script.set_quest(fresh.clone());
|
||||
let name_arg = || vec![ScriptValue::Str(npc_name.clone().unwrap_or_default())];
|
||||
// The reference's QUEST_GREETING/DETAIL/PROGRESS/COMPLETE carry no argument: the window
|
||||
// reads `UnitName("npc")` for its title (stock QuestFrame.lua:63), and the "npc" unit is
|
||||
// the session's (ui_unit.rs). Ours used to pass the name as arg1 (1944 dropped it).
|
||||
match (&*last, &fresh) {
|
||||
(_, Some(f)) if switched => {
|
||||
// A different giver → close the old panel, open the new (both kits play). QUEST_FINISHED
|
||||
@@ -539,7 +586,7 @@ fn feed_quest(
|
||||
// drain the pending actions so it does NOT clear the giver we just re-opened. Safe: a
|
||||
// switch is net-driven, so no user action is queued this frame to lose.
|
||||
script.fire_event("QUEST_FINISHED", vec![]);
|
||||
script.fire_event(panel_event(f.panel), name_arg());
|
||||
script.fire_event(panel_event(f.panel), vec![]);
|
||||
let _ = script.take_quest_actions();
|
||||
}
|
||||
(_, Some(f)) => {
|
||||
@@ -551,7 +598,7 @@ fn feed_quest(
|
||||
} else {
|
||||
panel_event(f.panel)
|
||||
};
|
||||
script.fire_event(event, name_arg());
|
||||
script.fire_event(event, vec![]);
|
||||
}
|
||||
(Some(_), None) => script.fire_event("QUEST_FINISHED", vec![]),
|
||||
(None, None) => {}
|
||||
@@ -838,6 +885,7 @@ mod tests {
|
||||
subject: Some(&player),
|
||||
states: &crate::world_state::WorldStates::default(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.expect("open");
|
||||
assert_eq!(snap.panel, QuestPanel::Detail);
|
||||
@@ -882,6 +930,7 @@ mod tests {
|
||||
subject: None,
|
||||
states: &crate::world_state::WorldStates::default(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(snap.panel, QuestPanel::Progress);
|
||||
|
||||
@@ -53,6 +53,7 @@ use crate::entities::ItemDisplays;
|
||||
use crate::items::Items;
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer};
|
||||
use crate::ui_action::Spells;
|
||||
use crate::ui_script::UiInput;
|
||||
use crate::ui_unit::UnitFeed;
|
||||
|
||||
@@ -330,6 +331,7 @@ fn build_detail(
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
macros: &crate::npc_text::MacroContext,
|
||||
spells: Option<&Spells>,
|
||||
) -> QuestLogDetail {
|
||||
let (required_money, reward_money) = money_split(template.money);
|
||||
let rewards = template
|
||||
@@ -354,6 +356,7 @@ fn build_detail(
|
||||
reward_money,
|
||||
choices,
|
||||
rewards,
|
||||
reward_spell: crate::ui_quest::reward_spell_view(template.reward_spell, spells),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,6 +489,7 @@ fn feed_quest_log(
|
||||
header_names: Option<Res<QuestHeaderNamesRes>>,
|
||||
tag_names: Option<Res<QuestTagNamesRes>>,
|
||||
states: Res<crate::world_state::WorldStates>,
|
||||
spells: Option<Res<Spells>>,
|
||||
mut last: Local<crate::ui_script::VmMemo<QuestLogState>>,
|
||||
mut prior_quest_ids: Local<crate::ui_script::VmMemo<Option<HashSet<u32>>>>,
|
||||
) {
|
||||
@@ -702,9 +706,16 @@ fn feed_quest_log(
|
||||
.filter(|e| !e.is_header)
|
||||
.map(|e| e.quest_id)
|
||||
.and_then(|quest_id| {
|
||||
quest_log
|
||||
.template(quest_id, &commands)
|
||||
.map(|t| build_detail(t, &mut items, icons.as_deref(), &commands, ¯os))
|
||||
quest_log.template(quest_id, &commands).map(|t| {
|
||||
build_detail(
|
||||
t,
|
||||
&mut items,
|
||||
icons.as_deref(),
|
||||
&commands,
|
||||
¯os,
|
||||
spells.as_deref(),
|
||||
)
|
||||
})
|
||||
});
|
||||
|
||||
let fresh = QuestLogState {
|
||||
@@ -745,8 +756,6 @@ fn feed_quest_log(
|
||||
script.set_quest_log(fresh.clone());
|
||||
script.fire_event("QUEST_LOG_UPDATE", vec![]);
|
||||
if !progressed.is_empty() {
|
||||
// Post-push = post-prune: the current watch list, the byte law's index space.
|
||||
let watched = script.quest_log_watched();
|
||||
for quest in progressed {
|
||||
for line in quest.changed_lines {
|
||||
script.fire_event("UI_INFO_MESSAGE", vec![ScriptValue::Str(line)]);
|
||||
@@ -759,13 +768,13 @@ fn feed_quest_log(
|
||||
};
|
||||
script.fire_event("UI_INFO_MESSAGE", vec![ScriptValue::Str(msg)]);
|
||||
}
|
||||
let watch_index = watched
|
||||
.iter()
|
||||
.position(|&id| id == quest.quest_id)
|
||||
.map_or(0, |p| p as i64 + 1);
|
||||
script.fire_event("QUEST_WATCH_UPDATE", vec![ScriptValue::Int(watch_index)]);
|
||||
// QUEST_WATCH_UPDATE's arg1 is the quest's LOG index: stock QuestLog_OnEvent hands it
|
||||
// to AutoQuestWatch_Update(questIndex), whose end is AddQuestWatch(questIndex)
|
||||
// (QuestLogFrame.lua:68, 752-769). Ours used to pass the watch-list position and
|
||||
// fire a BENILLA_QUEST_PROGRESS twin carrying the log index; the twin went with the
|
||||
// window (1944).
|
||||
script.fire_event(
|
||||
"BENILLA_QUEST_PROGRESS",
|
||||
"QUEST_WATCH_UPDATE",
|
||||
vec![ScriptValue::Int(i64::from(quest.index))],
|
||||
);
|
||||
}
|
||||
@@ -805,8 +814,6 @@ fn advanced(now: &QuestLogObjectiveView, was: &QuestLogObjectiveView) -> bool {
|
||||
/// One quest whose objectives advanced between log states — the announce unit of
|
||||
/// [`feed_quest_log`].
|
||||
struct QuestProgress {
|
||||
/// The quest id (stable identity — the watch list is id-keyed).
|
||||
quest_id: u32,
|
||||
/// The quest's 1-based index in the NEW log (the Era API's quest-log index space).
|
||||
index: u32,
|
||||
/// The quest title (the COMPLETE toast's `%s`; empty falls to the UNKNOWN form).
|
||||
@@ -846,7 +853,6 @@ fn quests_with_progressed_objectives(
|
||||
.collect();
|
||||
let completed = e.complete == 1 && prev.complete != 1;
|
||||
(!changed_lines.is_empty() || completed).then_some(QuestProgress {
|
||||
quest_id: e.quest_id,
|
||||
index: i as u32 + 1,
|
||||
title: e.title.clone(),
|
||||
changed_lines,
|
||||
|
||||
@@ -77,7 +77,7 @@ fn shipped_xml() -> Vec<(String, String)> {
|
||||
})
|
||||
.collect();
|
||||
out.sort();
|
||||
assert!(out.len() >= 40, "only {} xml files swept", out.len());
|
||||
assert!(out.len() >= 38, "only {} xml files swept", out.len());
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -389,6 +389,7 @@ fn the_quest_tracker_stacks_below_the_durability_guy() {
|
||||
for f in [
|
||||
"Interface\\FrameXML\\Fonts.xml",
|
||||
"MoneyFrame.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"UiPanels.xml",
|
||||
r"Interface\FrameXML\UIPanelTemplates.lua",
|
||||
r"Interface\FrameXML\UIPanelTemplates.xml",
|
||||
@@ -397,9 +398,11 @@ fn the_quest_tracker_stacks_below_the_durability_guy() {
|
||||
"MinimapCluster.xml",
|
||||
"ScrollTemplates.xml",
|
||||
"Interface\\FrameXML\\DurabilityFrame.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"QuestFrame.xml",
|
||||
"QuestLogFrame.xml",
|
||||
"Interface\\FrameXML\\BasicControls.xml",
|
||||
"Interface\\FrameXML\\ItemButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\QuestFrame.xml",
|
||||
"MicroMenu.xml",
|
||||
"Interface\\FrameXML\\QuestLogFrame.xml",
|
||||
] {
|
||||
load_xml(&s, f);
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@ fn an_error_toast_draws_over_an_open_panel_window() {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
@@ -122,9 +123,12 @@ fn an_error_toast_draws_over_an_open_panel_window() {
|
||||
// pane's UIPanelScrollFrameTemplate). A MISSING template is a loader *warning*, so an
|
||||
// under-loaded list passes and then dies on the first FauxScrollFrame_Update.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "MicroMenu.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
|
||||
// A left-slot panel open, and the toast raised after it — the order that must not decide.
|
||||
s.eval::<()>("ShowUIPanel(QuestLogFrame)").unwrap();
|
||||
|
||||
@@ -17,6 +17,7 @@ fn setup() -> UiScript {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
|
||||
@@ -44,7 +45,6 @@ fn setup() -> UiScript {
|
||||
// drags in the whole unit-frame cluster — and it must be the REAL frames: loading only
|
||||
// `PartyFrame.lua` for the two names gets "attempt to index a nil value" the first time one
|
||||
// iterates. The manifest already seats these far above RaidFrame (1874).
|
||||
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
|
||||
// The reference's own `BasicControls.xml`, which is manifest entry 3 — far above the pane
|
||||
// (1874). It is here for the error/message pair and `TEXT`. NOTE: unlike `raid_tests`, this
|
||||
// harness does not load `UIParent.xml`, so `UIParentLoadAddOn` — which 1881 moved there out
|
||||
|
||||
@@ -206,6 +206,7 @@ fn setup() -> UiScript {
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
|
||||
@@ -227,7 +228,6 @@ fn setup() -> UiScript {
|
||||
// drags in the whole unit-frame cluster — and it must be the REAL frames: loading only
|
||||
// `PartyFrame.lua` for the two names gets "attempt to index a nil value" the first time one
|
||||
// iterates. The manifest already seats these far above RaidFrame (1874).
|
||||
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\TextStatusBar.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\TextStatusBar.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\BuffFrame.xml");
|
||||
|
||||
@@ -30,6 +30,7 @@ fn setup() -> UiScript {
|
||||
// row, whose OnLoad calls `SmallMoneyFrame_OnLoad` — the TOC's own order (1580's
|
||||
// talent-wipe fixture hit this first).
|
||||
"MoneyFrame.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"UiPanels.xml",
|
||||
// The stock file's `HelpFrame_OnShow` calls `UpdateMicroButtons()` before
|
||||
// `GetGMStatus()`, so without the micro row the OnShow raises and the status ask never
|
||||
@@ -39,7 +40,6 @@ fn setup() -> UiScript {
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
"UIParent.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"Interface\\FrameXML\\MainMenuBar.xml",
|
||||
"Interface\\FrameXML\\ActionBarFrame.xml",
|
||||
"Interface\\FrameXML\\BonusActionBarFrame.xml",
|
||||
|
||||
@@ -718,6 +718,7 @@ fn interface_harness() -> UiScript {
|
||||
&[
|
||||
"Interface\\FrameXML\\Fonts.xml",
|
||||
"MoneyFrame.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"UiPanels.xml",
|
||||
r"Interface\FrameXML\UIPanelTemplates.lua",
|
||||
r"Interface\FrameXML\UIPanelTemplates.xml",
|
||||
@@ -750,7 +751,6 @@ fn interface_harness() -> UiScript {
|
||||
"Interface\\FrameXML\\ColorPickerFrame.xml",
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"Interface\\FrameXML\\MainMenuBar.xml",
|
||||
"Interface\\FrameXML\\ActionBarFrame.xml",
|
||||
"Interface\\FrameXML\\BonusActionBarFrame.xml",
|
||||
@@ -765,8 +765,11 @@ fn interface_harness() -> UiScript {
|
||||
"ScrollTemplates.xml",
|
||||
"Interface\\FrameXML\\MerchantFrame.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"QuestFrame.xml",
|
||||
"QuestLogFrame.xml",
|
||||
"Interface\\FrameXML\\BasicControls.xml",
|
||||
"Interface\\FrameXML\\ItemButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\QuestFrame.xml",
|
||||
"MicroMenu.xml",
|
||||
"Interface\\FrameXML\\QuestLogFrame.xml",
|
||||
],
|
||||
);
|
||||
harness_on(s)
|
||||
|
||||
@@ -12,6 +12,7 @@ use super::test_ui::load_ui as load_xml;
|
||||
fn load_pet_bar(s: &UiScript) {
|
||||
for file in [
|
||||
"MoneyFrame.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"UiPanels.xml",
|
||||
"Interface\\FrameXML\\Fonts.xml",
|
||||
r"Interface\FrameXML\UIPanelTemplates.lua",
|
||||
@@ -21,7 +22,6 @@ fn load_pet_bar(s: &UiScript) {
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"Interface\\FrameXML\\MainMenuBar.xml",
|
||||
"GameTooltip.xml",
|
||||
"Interface\\FrameXML\\ActionBarFrame.xml",
|
||||
@@ -369,6 +369,7 @@ fn pet_bar_row(with_multibar: bool) -> (usize, f32) {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
for file in [
|
||||
"MoneyFrame.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"UiPanels.xml",
|
||||
"Interface\\FrameXML\\Fonts.xml",
|
||||
r"Interface\FrameXML\UIPanelTemplates.lua",
|
||||
@@ -378,7 +379,6 @@ fn pet_bar_row(with_multibar: bool) -> (usize, f32) {
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"Interface\\FrameXML\\MainMenuBar.xml",
|
||||
"GameTooltip.xml",
|
||||
"Interface\\FrameXML\\ActionBarFrame.xml",
|
||||
@@ -677,6 +677,7 @@ fn load_pet_bar_with_tooltip(s: &UiScript) {
|
||||
for file in [
|
||||
"Interface\\FrameXML\\Fonts.xml",
|
||||
"MoneyFrame.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"UiPanels.xml",
|
||||
r"Interface\FrameXML\UIPanelTemplates.lua",
|
||||
r"Interface\FrameXML\UIPanelTemplates.xml",
|
||||
@@ -686,7 +687,6 @@ fn load_pet_bar_with_tooltip(s: &UiScript) {
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"Interface\\FrameXML\\MainMenuBar.xml",
|
||||
"Interface\\FrameXML\\ActionBarFrame.xml",
|
||||
"Interface\\FrameXML\\BonusActionBarFrame.xml",
|
||||
|
||||
@@ -102,7 +102,10 @@ fn setup() -> UiScript {
|
||||
// without this the rows build with no art at all and nothing goes red — which is exactly the
|
||||
// failure `load_ui_strict` exists to turn into a red test.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
// `ChatFrameEditBox`, which the reference's own purchase button indexes on every click to
|
||||
// decide where focus goes after the name box closes — a nil there raises before the charter is
|
||||
// bought. Ours guarded it; the reference does not.
|
||||
|
||||
@@ -19,6 +19,7 @@ fn questgiver_show_hide_plays_open_and_close_kits() {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
@@ -27,8 +28,11 @@ fn questgiver_show_hide_plays_open_and_close_kits() {
|
||||
// MerchantFrame.xml (the same documented cross-window dep the bag tests load).
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
|
||||
// Hidden at load: no open sound (never transitions on startup).
|
||||
assert!(
|
||||
@@ -82,14 +86,18 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
|
||||
let panels = [
|
||||
"QuestFrameGreetingPanel",
|
||||
@@ -106,10 +114,15 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
|
||||
active_titles: vec!["Report to Goldshire".into()],
|
||||
..QuestState::default()
|
||||
}));
|
||||
s.fire_event(
|
||||
"QUEST_GREETING",
|
||||
vec![ScriptValue::Str("Deputy Willem".into())],
|
||||
s.set_unit(
|
||||
"npc",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Deputy Willem".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
s.fire_event("QUEST_GREETING", vec![]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
for name in panels {
|
||||
assert_eq!(
|
||||
@@ -132,10 +145,15 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
|
||||
title: "A Threat Within".into(),
|
||||
..QuestState::default()
|
||||
}));
|
||||
s.fire_event(
|
||||
"QUEST_DETAIL",
|
||||
vec![ScriptValue::Str("Deputy Willem".into())],
|
||||
s.set_unit(
|
||||
"npc",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Deputy Willem".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
s.fire_event("QUEST_DETAIL", vec![]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
for name in panels {
|
||||
assert_eq!(
|
||||
@@ -163,14 +181,18 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
|
||||
let choice = |name: &str, quality: u32| benilla_ui::script::QuestItemView {
|
||||
item_id: 0,
|
||||
@@ -191,10 +213,15 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
|
||||
reward_money: 150,
|
||||
..QuestState::default()
|
||||
}));
|
||||
s.fire_event(
|
||||
"QUEST_DETAIL",
|
||||
vec![ScriptValue::Str("Marshal McBride".into())],
|
||||
s.set_unit(
|
||||
"npc",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Marshal McBride".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
s.fire_event("QUEST_DETAIL", vec![]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
|
||||
assert!(s.eval::<bool>("return QuestDetailItem1:IsShown()").unwrap());
|
||||
@@ -208,7 +235,7 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
|
||||
assert_eq!(
|
||||
s.eval::<String>("return QuestDetailItemChooseText:GetText()")
|
||||
.unwrap(),
|
||||
"You may choose one of these rewards:"
|
||||
"You will be able to choose one of these rewards:"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return QuestDetailItemReceiveText:GetText()")
|
||||
@@ -235,8 +262,11 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
|
||||
// arm at all: an unmodified click must not raise and must not set an itemChoice.
|
||||
s.run("QuestDetailItem1:Click()").ok();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
// The reward panel's `itemChoice` is set only by its own OnShow (stock QuestFrame.lua:104,
|
||||
// `= 0`) and a reward-row click; on the detail panel it is simply unset.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return QuestFrame.itemChoice").unwrap(),
|
||||
s.eval::<i64>("return QuestFrameRewardPanel.itemChoice or 0")
|
||||
.unwrap(),
|
||||
0,
|
||||
"a detail-panel row never selects a reward"
|
||||
);
|
||||
@@ -250,14 +280,18 @@ fn reward_panel_choice_click_selects_and_completes_with_zero_based_index() {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
|
||||
let choice = |name: &str| benilla_ui::script::QuestItemView {
|
||||
item_id: 0,
|
||||
@@ -275,10 +309,15 @@ fn reward_panel_choice_click_selects_and_completes_with_zero_based_index() {
|
||||
choices: vec![choice("Worn Sword"), choice("Worn Mace")],
|
||||
..QuestState::default()
|
||||
}));
|
||||
s.fire_event(
|
||||
"QUEST_COMPLETE",
|
||||
vec![ScriptValue::Str("Marshal McBride".into())],
|
||||
s.set_unit(
|
||||
"npc",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Marshal McBride".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
s.fire_event("QUEST_COMPLETE", vec![]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
|
||||
assert!(
|
||||
@@ -317,24 +356,33 @@ fn greeting_goodbye_button_closes_the_window() {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
|
||||
s.set_quest(Some(QuestState {
|
||||
panel: QuestPanel::Greeting,
|
||||
greeting: "Hello".into(),
|
||||
..QuestState::default()
|
||||
}));
|
||||
s.fire_event(
|
||||
"QUEST_GREETING",
|
||||
vec![ScriptValue::Str("Deputy Willem".into())],
|
||||
s.set_unit(
|
||||
"npc",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Deputy Willem".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
s.fire_event("QUEST_GREETING", vec![]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert!(s.eval::<bool>("return QuestFrame:IsVisible()").unwrap());
|
||||
|
||||
@@ -362,14 +410,18 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
// Planted, not shipped: instant text is OFF out of the box (see the doc comment).
|
||||
s.eval::<()>(r#"QUEST_FADING_DISABLE = "1""#).unwrap();
|
||||
s.set_quest(Some(QuestState {
|
||||
@@ -378,10 +430,15 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
|
||||
body: "Kill kobolds.".into(),
|
||||
..QuestState::default()
|
||||
}));
|
||||
s.fire_event(
|
||||
"QUEST_DETAIL",
|
||||
vec![ScriptValue::Str("Deputy Willem".into())],
|
||||
s.set_unit(
|
||||
"npc",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Deputy Willem".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
s.fire_event("QUEST_DETAIL", vec![]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert!(s
|
||||
.eval::<bool>("return QuestFrameAcceptButton:IsShown()")
|
||||
@@ -478,14 +535,18 @@ fn write_on_still_fades_when_instant_text_is_off() {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
s.eval::<()>(r#"QUEST_FADING_DISABLE = "0""#).unwrap();
|
||||
s.set_quest(Some(QuestState {
|
||||
panel: QuestPanel::Detail,
|
||||
@@ -493,10 +554,15 @@ fn write_on_still_fades_when_instant_text_is_off() {
|
||||
body: "Kill kobolds.".into(),
|
||||
..QuestState::default()
|
||||
}));
|
||||
s.fire_event(
|
||||
"QUEST_DETAIL",
|
||||
vec![ScriptValue::Str("Deputy Willem".into())],
|
||||
s.set_unit(
|
||||
"npc",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Deputy Willem".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
s.fire_event("QUEST_DETAIL", vec![]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
|
||||
// Mid-write (reveal edge at char 4 of 13): the quill scratches, Accept stays dead.
|
||||
@@ -543,14 +609,18 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
|
||||
s.set_quest(Some(QuestState {
|
||||
panel: QuestPanel::Detail,
|
||||
@@ -558,10 +628,15 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() {
|
||||
body: "Kill things.".into(),
|
||||
..QuestState::default()
|
||||
}));
|
||||
s.fire_event(
|
||||
"QUEST_DETAIL",
|
||||
vec![ScriptValue::Str("Marshal McBride".into())],
|
||||
s.set_unit(
|
||||
"npc",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Marshal McBride".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
s.fire_event("QUEST_DETAIL", vec![]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert_eq!(
|
||||
s.eval::<String>("return getglobal('QuestFrameNpcNameText'):GetText() or ''")
|
||||
@@ -608,16 +683,24 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() {
|
||||
fn greeting_panel_title_rows_grow_to_their_wrapped_titles() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// Stock sizes each row at show time — `SetHeight(GetTextHeight() + 2)` right after SetText
|
||||
// (QuestFrame.lua:245) — which the reference can do because its text measure is synchronous.
|
||||
// So is the app's (measure.rs); the harness installs the same seam's stand-in.
|
||||
s.set_text_measurer(Box::new(super::FixedWidthFont(7.0)));
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
|
||||
s.set_quest(Some(QuestState {
|
||||
panel: QuestPanel::Greeting,
|
||||
@@ -629,10 +712,15 @@ fn greeting_panel_title_rows_grow_to_their_wrapped_titles() {
|
||||
],
|
||||
..QuestState::default()
|
||||
}));
|
||||
s.fire_event(
|
||||
"QUEST_GREETING",
|
||||
vec![ScriptValue::Str("Deputy Willem".into())],
|
||||
s.set_unit(
|
||||
"npc",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Deputy Willem".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
s.fire_event("QUEST_GREETING", vec![]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
|
||||
let answer_measures = |s: &mut UiScript| {
|
||||
@@ -665,7 +753,7 @@ fn greeting_panel_title_rows_grow_to_their_wrapped_titles() {
|
||||
};
|
||||
let (t1, b1, h1) = row(1);
|
||||
let (t2, b2, h2) = row(2);
|
||||
assert!(h1 >= 28.0 && h2 >= 28.0, "both titles wrap: {h1}, {h2}");
|
||||
assert!(h1 >= 24.0 && h2 >= 24.0, "both titles wrap: {h1}, {h2}");
|
||||
assert!(
|
||||
(t1 - b1 - (h1 + 2.0)).abs() < 0.5,
|
||||
"row 1 is its wrapped title + 2: got {}, title {h1}",
|
||||
@@ -698,14 +786,18 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here
|
||||
load_xml(&s, "DressUpFrame.xml"); // DressUpItemLink lives here
|
||||
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit its menus build from
|
||||
@@ -737,10 +829,15 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
|
||||
)],
|
||||
..QuestState::default()
|
||||
}));
|
||||
s.fire_event(
|
||||
"QUEST_COMPLETE",
|
||||
vec![ScriptValue::Str("Marshal McBride".into())],
|
||||
s.set_unit(
|
||||
"npc",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
name: Some("Marshal McBride".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
s.fire_event("QUEST_COMPLETE", vec![]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
|
||||
// The regression this fork could break: a PLAIN click still picks the choice (the ref's third
|
||||
@@ -748,7 +845,8 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
|
||||
// move it if they leaked into the select arm.
|
||||
s.run("QuestRewardItem2:Click()").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return QuestFrame.itemChoice").unwrap(),
|
||||
s.eval::<i64>("return QuestFrameRewardPanel.itemChoice")
|
||||
.unwrap(),
|
||||
2,
|
||||
"a plain click still selects the reward choice"
|
||||
);
|
||||
@@ -772,7 +870,8 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
|
||||
"shift-click posted choice 1's link"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return QuestFrame.itemChoice").unwrap(),
|
||||
s.eval::<i64>("return QuestFrameRewardPanel.itemChoice")
|
||||
.unwrap(),
|
||||
2,
|
||||
"the shift arm returns — it must NOT also select the clicked choice"
|
||||
);
|
||||
@@ -803,7 +902,8 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
|
||||
"ctrl-click opened the room wearing choice 1"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return QuestFrame.itemChoice").unwrap(),
|
||||
s.eval::<i64>("return QuestFrameRewardPanel.itemChoice")
|
||||
.unwrap(),
|
||||
2,
|
||||
"the ctrl arm returns — it must NOT also select the clicked choice"
|
||||
);
|
||||
|
||||
@@ -37,8 +37,11 @@ fn harness() -> UiScript {
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "MicroMenu.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestTimerFrame.xml");
|
||||
s
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ fn eight_entries() -> QuestLogState {
|
||||
// lands, so the fixture carries that exact shape.
|
||||
link: Some(HAMMER_LINK.into()),
|
||||
}],
|
||||
reward_spell: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -85,6 +86,7 @@ fn shipped_questlog_frame_loads_clean() {
|
||||
let s = UiScript::new().unwrap();
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
@@ -95,9 +97,12 @@ fn shipped_questlog_frame_loads_clean() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "MicroMenu.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
}
|
||||
|
||||
/// The whole contract in one end-to-end drive: open plays the kit and renders row 1 + the count +
|
||||
@@ -111,6 +116,7 @@ fn shipped_questlog_frame_drives_end_to_end() {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
@@ -121,9 +127,12 @@ fn shipped_questlog_frame_drives_end_to_end() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "MicroMenu.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
|
||||
s.set_quest_log(eight_entries());
|
||||
|
||||
@@ -147,7 +156,7 @@ fn shipped_questlog_frame_drives_end_to_end() {
|
||||
// Row 1 carries entry 1's title (indented), the count line reads "Quests: |cffffffff8/20|r", and nothing having been
|
||||
// selected, the first non-header entry auto-selects (pin §2's SetFirstValidSelection).
|
||||
assert!(s
|
||||
.eval::<String>("return QuestLogTitle1Text:GetText()")
|
||||
.eval::<String>("return QuestLogTitle1NormalText:GetText()")
|
||||
.unwrap()
|
||||
.contains("Quest 1"));
|
||||
assert_eq!(
|
||||
@@ -194,10 +203,12 @@ fn shipped_questlog_frame_drives_end_to_end() {
|
||||
.unwrap(),
|
||||
"You will receive:"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return QuestLogItemChooseText:GetText()")
|
||||
// …hidden, not blanked: stock QuestFrameItems_Update Show()s/Hide()s the choose text
|
||||
// (QuestFrame.lua:454-473) and leaves its string alone.
|
||||
assert!(
|
||||
!s.eval::<bool>("return QuestLogItemChooseText:IsShown()")
|
||||
.unwrap(),
|
||||
""
|
||||
"no choices: the choose text is hidden"
|
||||
);
|
||||
// No choices, so the pool's first slot IS the fixed reward and the SECOND is the first
|
||||
// unused one. (One pool now — the ref's shape; there is no separate "choice row 1".)
|
||||
@@ -220,7 +231,7 @@ fn shipped_questlog_frame_drives_end_to_end() {
|
||||
})
|
||||
.expect("a 'Quest 3' row text quad");
|
||||
let before = s
|
||||
.eval::<String>("return QuestLogTitle1Text:GetText()")
|
||||
.eval::<String>("return QuestLogTitle1NormalText:GetText()")
|
||||
.unwrap();
|
||||
s.mouse_wheel(wx, wy, -1.0);
|
||||
assert!(s.errors().is_empty(), "wheel errors: {:?}", s.errors());
|
||||
@@ -230,7 +241,7 @@ fn shipped_questlog_frame_drives_end_to_end() {
|
||||
// The migration reverts our step; how far a notch should travel is a look call, recorded in
|
||||
// 1846 rather than re-authored back in.
|
||||
assert_ne!(
|
||||
s.eval::<String>("return QuestLogTitle1Text:GetText()")
|
||||
s.eval::<String>("return QuestLogTitle1NormalText:GetText()")
|
||||
.unwrap(),
|
||||
before,
|
||||
"wheel-down scrolled the list"
|
||||
@@ -240,7 +251,7 @@ fn shipped_questlog_frame_drives_end_to_end() {
|
||||
|
||||
// Abandon, No path: marks the selection, shows the registry's ABANDON_QUEST entry on the
|
||||
// shared StaticPopup engine (decision 0308 §3), but No drains nothing and hides it.
|
||||
s.run("BenillaQuestLogAbandonButton_OnClick()").unwrap();
|
||||
s.run("QuestLogFrameAbandonButton:Click()").unwrap();
|
||||
assert!(s.eval::<bool>("return StaticPopup1:IsShown()").unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetAbandonQuestName()").unwrap(),
|
||||
@@ -269,7 +280,7 @@ fn shipped_questlog_frame_drives_end_to_end() {
|
||||
);
|
||||
|
||||
// Abandon, Yes path: the pinned index (1, the selection at click time) drains, plus the kit.
|
||||
s.run("BenillaQuestLogAbandonButton_OnClick()").unwrap();
|
||||
s.run("QuestLogFrameAbandonButton:Click()").unwrap();
|
||||
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
|
||||
assert_eq!(s.take_quest_log_abandons(), vec![1]);
|
||||
// Show → igMainMenuOpen; the ref OnClick runs OnAccept FIRST (the abandon kit), THEN hides
|
||||
@@ -306,8 +317,12 @@ fn shift_click_toggles_the_watch_checkbox_and_the_tracker_hud() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// Stock seats the watch check at update time off `questNormalText:GetWidth()` (QuestLogFrame.lua:205-215),
|
||||
// synchronously — the app's measure seam is synchronous too (measure.rs); its stand-in here.
|
||||
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
@@ -318,11 +333,23 @@ fn shift_click_toggles_the_watch_checkbox_and_the_tracker_hud() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "MicroMenu.xml");
|
||||
// A shift-click reaches stock QuestLogTitleButton_OnClick's unguarded `ChatFrameEditBox:IsVisible()`
|
||||
// (QuestLogFrame.lua:478) — the chat window is on the manifest long before the log (1944).
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml");
|
||||
load_xml(&s, "ChatFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
|
||||
s.set_quest_log(eight_entries());
|
||||
// The app fires QUEST_LOG_UPDATE when the log lands; stock QuestLog_OnEvent's arm runs the
|
||||
// first QuestWatch_Update, which is what hides an empty tracker.
|
||||
s.fire_event("QUEST_LOG_UPDATE", vec![]);
|
||||
s.run("ToggleQuestLog()").unwrap();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
|
||||
@@ -403,9 +430,12 @@ fn shift_click_toggles_the_watch_checkbox_and_the_tracker_hud() {
|
||||
.unwrap(),
|
||||
" - Kobold Vermin slain: 3/10"
|
||||
);
|
||||
// A manual watch is permanent: no auto-watch timer entry rides it.
|
||||
// A manual watch is permanent: stock's shift-click inserts it with QUEST_WATCH_NO_EXPIRE
|
||||
// (QuestLogFrame.lua:496), the timer that AutoQuestWatch_OnUpdate never counts down.
|
||||
assert!(s
|
||||
.eval::<bool>("return BENILLA_QUEST_WATCH_TIMERS[\"Quest 1\"] == nil")
|
||||
.eval::<bool>(
|
||||
"return QUEST_WATCH_LIST[1] ~= nil and QUEST_WATCH_LIST[1].timer == QUEST_WATCH_NO_EXPIRE"
|
||||
)
|
||||
.unwrap());
|
||||
|
||||
// Shift-click again unwatches: the checkbox clears and — nothing left watched — the whole HUD
|
||||
@@ -433,6 +463,7 @@ fn watch_guards_no_op_without_erroring() {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
@@ -444,9 +475,18 @@ fn watch_guards_no_op_without_erroring() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "MicroMenu.xml");
|
||||
// A shift-click reaches stock QuestLogTitleButton_OnClick's unguarded `ChatFrameEditBox:IsVisible()`
|
||||
// (QuestLogFrame.lua:478) — the chat window is on the manifest long before the log (1944).
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml");
|
||||
load_xml(&s, "ChatFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
|
||||
s.set_quest_log(eight_entries());
|
||||
s.run("ToggleQuestLog()").unwrap();
|
||||
@@ -482,8 +522,9 @@ fn watch_guards_no_op_without_erroring() {
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetQuestLogSelection()").unwrap(),
|
||||
2,
|
||||
"the click still selects"
|
||||
1,
|
||||
"a shift-click never selects — stock QuestLogTitleButton_OnClick's shift branch returns \
|
||||
before the plain-click arm's QuestLog_SetSelection (QuestLogFrame.lua:472-500)"
|
||||
);
|
||||
|
||||
// Fill the watch list to the cap (5) with quests 2-6 via the Lua API directly — the engine-level
|
||||
@@ -532,6 +573,7 @@ fn progress_auto_watches_for_five_minutes() {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
@@ -542,14 +584,22 @@ fn progress_auto_watches_for_five_minutes() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
// AUTO_QUEST_WATCH is the options window's (UIOptionsFrame_Init in the reference; 1944), so the
|
||||
// auto-watch needs that window loaded, as the manifest has it long before the log.
|
||||
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
|
||||
load_xml(&s, "KeyBindingsPage.xml");
|
||||
load_xml(&s, "OptionsFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "MicroMenu.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
s.set_quest_log(eight_entries());
|
||||
|
||||
// A kill credit lands: the engine fires the event with the quest's 1-based log index.
|
||||
s.fire_event(
|
||||
"BENILLA_QUEST_PROGRESS",
|
||||
"QUEST_WATCH_UPDATE",
|
||||
vec![benilla_ui::script::ScriptValue::Int(1)],
|
||||
);
|
||||
assert!(s.errors().is_empty(), "auto-watch errors: {:?}", s.errors());
|
||||
@@ -557,15 +607,20 @@ fn progress_auto_watches_for_five_minutes() {
|
||||
assert!(s
|
||||
.eval::<bool>("return QuestWatchFrame:IsVisible()")
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>("return BENILLA_QUEST_WATCH_TIMERS[\"Quest 1\"] ~= nil")
|
||||
.unwrap());
|
||||
assert!(
|
||||
s.eval::<bool>(
|
||||
"return QUEST_WATCH_LIST[1] ~= nil and QUEST_WATCH_LIST[1].index == 1 \
|
||||
and QUEST_WATCH_LIST[1].timer == MAX_QUEST_WATCH_TIMER"
|
||||
)
|
||||
.unwrap(),
|
||||
"the reference's QUEST_WATCH_LIST carries the armed timer (QuestLogFrame.lua:752-769)"
|
||||
);
|
||||
|
||||
// 299 s in it still holds; fresh progress re-arms the timer; expiry unwatches and hides.
|
||||
s.tick(299.0);
|
||||
assert!(s.eval::<bool>("return IsQuestWatched(1)").unwrap());
|
||||
s.fire_event(
|
||||
"BENILLA_QUEST_PROGRESS",
|
||||
"QUEST_WATCH_UPDATE",
|
||||
vec![benilla_ui::script::ScriptValue::Int(1)],
|
||||
);
|
||||
s.tick(299.0);
|
||||
@@ -599,25 +654,35 @@ fn the_auto_watch_flag_is_the_references_uvar_and_gates_the_watch() {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
// The flag is declared by the options window's init since 1944, as the reference declares it
|
||||
// in UIOptionsFrame_Init — so the window that owns it loads first, as in the manifest.
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "KeyBindingsPage.xml");
|
||||
load_xml(&s, "OptionsFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
|
||||
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "MicroMenu.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
s.set_quest_log(eight_entries());
|
||||
assert_eq!(s.eval::<String>("return AUTO_QUEST_WATCH").unwrap(), "1");
|
||||
|
||||
s.run(r#"AUTO_QUEST_WATCH = "0""#).unwrap();
|
||||
s.fire_event(
|
||||
"BENILLA_QUEST_PROGRESS",
|
||||
"QUEST_WATCH_UPDATE",
|
||||
vec![benilla_ui::script::ScriptValue::Int(1)],
|
||||
);
|
||||
assert!(
|
||||
@@ -627,7 +692,7 @@ fn the_auto_watch_flag_is_the_references_uvar_and_gates_the_watch() {
|
||||
|
||||
s.run(r#"AUTO_QUEST_WATCH = "1""#).unwrap();
|
||||
s.fire_event(
|
||||
"BENILLA_QUEST_PROGRESS",
|
||||
"QUEST_WATCH_UPDATE",
|
||||
vec![benilla_ui::script::ScriptValue::Int(1)],
|
||||
);
|
||||
assert!(s.eval::<bool>("return IsQuestWatched(1)").unwrap());
|
||||
@@ -658,8 +723,12 @@ fn the_row_tag_is_its_own_right_flush_string_and_the_state_word_wins() {
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "MicroMenu.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
|
||||
let mut state = eight_entries();
|
||||
// Row 1: a plain elite quest. Row 2: an elite quest that is COMPLETE — the state word wins.
|
||||
@@ -695,11 +764,11 @@ fn the_row_tag_is_its_own_right_flush_string_and_the_state_word_wins() {
|
||||
// The title is the bare (indented) name — the state word lives on the tag string now, so
|
||||
// appending it here too would double it.
|
||||
let title1 = s
|
||||
.eval::<String>("return QuestLogTitle1Text:GetText()")
|
||||
.eval::<String>("return QuestLogTitle1NormalText:GetText()")
|
||||
.unwrap();
|
||||
assert_eq!(title1, " Quest 1");
|
||||
let title2 = s
|
||||
.eval::<String>("return QuestLogTitle2Text:GetText()")
|
||||
.eval::<String>("return QuestLogTitle2NormalText:GetText()")
|
||||
.unwrap();
|
||||
assert_eq!(title2, " Quest 2", "no \"(Complete)\" on the title");
|
||||
|
||||
@@ -722,6 +791,7 @@ fn empty_quest_log_hides_rows_and_disables_abandon() {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
@@ -732,9 +802,12 @@ fn empty_quest_log_hides_rows_and_disables_abandon() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "MicroMenu.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
|
||||
s.set_quest_log(QuestLogState::default());
|
||||
s.run("ToggleQuestLog()").unwrap();
|
||||
@@ -742,22 +815,24 @@ fn empty_quest_log_hides_rows_and_disables_abandon() {
|
||||
|
||||
// A bare FontString region has no Show/Hide/IsVisible in this engine (only Frames/Buttons do —
|
||||
// `region.rs`'s method table) — the empty-state message is a toggled SetText, read back here.
|
||||
// The empty log says the reference's QUESTLOG_NO_QUESTS_TEXT (GlobalStrings.lua:3225) through
|
||||
// stock QuestLogNoQuestsText; "Your quest log is empty." was ours (1944).
|
||||
assert_eq!(
|
||||
s.eval::<String>("return QuestLogNoQuestsText:GetText()")
|
||||
.unwrap(),
|
||||
"Your quest log is empty."
|
||||
"No Active Quests"
|
||||
);
|
||||
assert!(!s.eval::<bool>("return QuestLogTitle1:IsVisible()").unwrap());
|
||||
assert!(!s
|
||||
.eval::<bool>("return QuestLogFrameAbandonButton:IsEnabled() ~= 0")
|
||||
.unwrap());
|
||||
// The Description header is now Lua-managed (BenillaQuestLogDetail_Clear blanks it) rather than
|
||||
// a static `text=` — it used to float "Description" over the empty-log parchment with no
|
||||
// selection (this task's fix; QuestLogFrame.xml's header comment on the FontString).
|
||||
assert_eq!(
|
||||
s.eval::<String>("return QuestLogDescriptionTitle:GetText()")
|
||||
// With no selection the reference hides the whole detail pane (stock
|
||||
// `QuestLog_UpdateQuestDetails`: `QuestLogDetailScrollFrame:Hide()`), header included — it
|
||||
// keeps its static `text="QUEST_DESCRIPTION"`; ours used to blank the header instead (1944).
|
||||
assert!(
|
||||
!s.eval::<bool>("return QuestLogDetailScrollFrame:IsVisible()")
|
||||
.unwrap(),
|
||||
""
|
||||
"no selection: the detail pane is hidden, header and all"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return QuestLogQuestCount:GetText()")
|
||||
@@ -777,6 +852,7 @@ fn reward_rows_follow_the_refs_two_per_row_layout() {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
@@ -787,9 +863,12 @@ fn reward_rows_follow_the_refs_two_per_row_layout() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "MicroMenu.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
|
||||
let mut state = eight_entries();
|
||||
state.detail = Some(QuestLogDetail {
|
||||
@@ -826,6 +905,7 @@ fn reward_rows_follow_the_refs_two_per_row_layout() {
|
||||
usable: true,
|
||||
..Default::default()
|
||||
}],
|
||||
reward_spell: None,
|
||||
});
|
||||
s.set_quest_log(state);
|
||||
s.run("ToggleQuestLog()").unwrap();
|
||||
@@ -850,7 +930,7 @@ fn reward_rows_follow_the_refs_two_per_row_layout() {
|
||||
assert_eq!(
|
||||
s.eval::<String>("return QuestLogItemChooseText:GetText()")
|
||||
.unwrap(),
|
||||
"You may choose one of these rewards:"
|
||||
"You will be able to choose one of these rewards:"
|
||||
);
|
||||
|
||||
// With choices present, the receive text reads "...also..." (QuestFrame.lua:461-462) — it
|
||||
@@ -886,6 +966,24 @@ fn reward_rows_follow_the_refs_two_per_row_layout() {
|
||||
/// ResizeChild`) well past the pane — load-bearing for the scroll/clip tests below even
|
||||
/// engine-only (no font atlas, so every auto-height FontString measures 0 here — this fixture
|
||||
/// overflows on the FIXED-height portions alone: 10×12px objective rows + the reward row).
|
||||
/// The measure round-trip the app's resolve pass drives with its font engine: answer every
|
||||
/// FontString the layout asked about with the 6px-per-glyph, 12px-per-line stand-in the
|
||||
/// synchronous `FixedWidthFont(6.0)` uses, so the two agree.
|
||||
fn answer_measures(s: &mut UiScript) {
|
||||
let answers: Vec<(u32, f32, f32, u64)> = s
|
||||
.fontstrings_needing_measure()
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let ink = r.text.chars().count() as f32 * 6.0;
|
||||
match r.wrap_width {
|
||||
Some(w) if w > 0.0 && ink > w => (r.id, w, (ink / w).ceil() * 12.0, r.key),
|
||||
_ => (r.id, ink, 12.0, r.key),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
s.set_measured_text_unwrapped(&answers);
|
||||
}
|
||||
|
||||
fn overflowing_entry() -> QuestLogState {
|
||||
let objectives = (1..=10)
|
||||
.map(|i| QuestLogObjectiveView {
|
||||
@@ -921,6 +1019,7 @@ fn overflowing_entry() -> QuestLogState {
|
||||
usable: true,
|
||||
..Default::default()
|
||||
}],
|
||||
reward_spell: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -938,6 +1037,7 @@ fn overflowing_detail_content_clips_to_the_scrollframe_rect() {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
@@ -948,9 +1048,12 @@ fn overflowing_detail_content_clips_to_the_scrollframe_rect() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "MicroMenu.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
|
||||
s.set_quest_log(overflowing_entry());
|
||||
s.run("ToggleQuestLog()").unwrap();
|
||||
@@ -1002,8 +1105,10 @@ fn wheel_over_the_detail_pane_changes_vertical_scroll() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
@@ -1014,13 +1119,21 @@ fn wheel_over_the_detail_pane_changes_vertical_scroll() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "MicroMenu.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
|
||||
s.set_quest_log(overflowing_entry());
|
||||
s.run("ToggleQuestLog()").unwrap();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
// The detail pane's scroll range is the union of its measured content (0128): the layout's
|
||||
// measure round-trip has to be answered, as the app's resolve pass answers it every frame.
|
||||
s.resolve();
|
||||
answer_measures(&mut s);
|
||||
s.resolve(); // the measured span reaches the scroll range, and the bar, on this pass
|
||||
|
||||
s.resolve();
|
||||
let quads = s.extract();
|
||||
@@ -1053,8 +1166,10 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
@@ -1065,16 +1180,27 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "MicroMenu.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
|
||||
s.set_quest_log(overflowing_entry());
|
||||
s.run("ToggleQuestLog()").unwrap();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
// The detail pane's scroll range is the union of its measured content (0128): the layout's
|
||||
// measure round-trip has to be answered, as the app's resolve pass answers it every frame.
|
||||
s.resolve();
|
||||
answer_measures(&mut s);
|
||||
s.resolve(); // the measured span reaches the scroll range, and the bar, on this pass
|
||||
s.resolve(); // GetVerticalScrollRange (SetVerticalScroll's clamp) reads resolved rects.
|
||||
|
||||
s.run("QuestLogDetailScrollFrame:SetVerticalScroll(10)")
|
||||
// Scroll THROUGH the bar, as the wheel and the thumb do (ScrollFrameTemplate_OnMouseWheel sets
|
||||
// the bar; its OnValueChanged moves the frame): a reselect's `ScrollBar:SetValue(0)` is what
|
||||
// snaps the pane back, and a bar already at 0 would fire no change over a frame-only scroll.
|
||||
s.run("QuestLogDetailScrollFrameScrollBar:SetValue(10)")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return QuestLogDetailScrollFrame:GetVerticalScroll()")
|
||||
@@ -1093,8 +1219,7 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not
|
||||
);
|
||||
|
||||
// A manual reselect (the row-click path — no doNotScroll) DOES reset it.
|
||||
s.run(r#"BenillaQuestLogTitle_OnClick(QuestLogTitle1, "LeftButton")"#)
|
||||
.unwrap();
|
||||
s.run("QuestLogTitle1:Click()").unwrap();
|
||||
assert!(s.errors().is_empty(), "click errors: {:?}", s.errors());
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return QuestLogDetailScrollFrame:GetVerticalScroll()")
|
||||
@@ -1117,6 +1242,7 @@ fn reward_row_hover_serves_the_shared_item_tooltip() {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
@@ -1132,9 +1258,12 @@ fn reward_row_hover_serves_the_shared_item_tooltip() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "MicroMenu.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
|
||||
s.set_quest_log(eight_entries());
|
||||
s.run("ToggleQuestLog()").unwrap();
|
||||
@@ -1264,6 +1393,7 @@ fn reward_rows_preview_and_post_and_a_plain_click_stays_inert() {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
@@ -1274,9 +1404,15 @@ fn reward_rows_preview_and_post_and_a_plain_click_stays_inert() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "MicroMenu.xml");
|
||||
// A shift-click reaches stock QuestLogTitleButton_OnClick's unguarded `ChatFrameEditBox:IsVisible()`
|
||||
// (QuestLogFrame.lua:478) — the chat window is on the manifest long before the log (1944).
|
||||
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here
|
||||
load_xml(&s, "DressUpFrame.xml"); // DressUpItemLink lives here
|
||||
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit its menus build from
|
||||
@@ -1349,6 +1485,7 @@ fn shift_click_on_a_title_posts_the_quest_name_with_chat_open_and_watches_with_i
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
@@ -1359,9 +1496,15 @@ fn shift_click_on_a_title_posts_the_quest_name_with_chat_open_and_watches_with_i
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "MicroMenu.xml");
|
||||
// A shift-click reaches stock QuestLogTitleButton_OnClick's unguarded `ChatFrameEditBox:IsVisible()`
|
||||
// (QuestLogFrame.lua:478) — the chat window is on the manifest long before the log (1944).
|
||||
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here
|
||||
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit its menus build from
|
||||
load_xml(&s, "ChatFrame.xml"); // ChatFrameEditBox lives here
|
||||
@@ -1445,6 +1588,7 @@ fn share_quest_needs_both_a_sharable_selection_and_a_party() {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
@@ -1455,9 +1599,12 @@ fn share_quest_needs_both_a_sharable_selection_and_a_party() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "MicroMenu.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
|
||||
// Solo, quest 1 sharable and selected by the auto-selection.
|
||||
s.set_quest_log(entries_sharable(&[1]));
|
||||
@@ -1489,7 +1636,7 @@ fn share_quest_needs_both_a_sharable_selection_and_a_party() {
|
||||
|
||||
// Select an unsharable quest: party unchanged, button goes dark.
|
||||
s.run("SelectQuestLogEntry(2)").unwrap();
|
||||
s.run("BenillaQuestLogFrame_Update()").unwrap();
|
||||
s.run("QuestLog_Update()").unwrap();
|
||||
assert!(
|
||||
s.eval::<bool>("return GetQuestLogPushable() == nil")
|
||||
.unwrap(),
|
||||
@@ -1503,7 +1650,7 @@ fn share_quest_needs_both_a_sharable_selection_and_a_party() {
|
||||
|
||||
// Leaving the party darkens it again from the other side.
|
||||
s.run("SelectQuestLogEntry(1)").unwrap();
|
||||
s.run("BenillaQuestLogFrame_Update()").unwrap();
|
||||
s.run("QuestLog_Update()").unwrap();
|
||||
assert!(s
|
||||
.eval::<bool>("return QuestFramePushQuestButton:IsEnabled() ~= 0")
|
||||
.unwrap());
|
||||
@@ -1524,6 +1671,7 @@ fn share_quest_is_dark_on_an_empty_log_even_in_a_party() {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
@@ -1534,9 +1682,12 @@ fn share_quest_is_dark_on_an_empty_log_even_in_a_party() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "MicroMenu.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
|
||||
s.set_quest_log(QuestLogState::default());
|
||||
s.set_party(party(4));
|
||||
@@ -1557,6 +1708,7 @@ fn share_quest_click_queues_the_selected_quests_id() {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
@@ -1567,16 +1719,19 @@ fn share_quest_click_queues_the_selected_quests_id() {
|
||||
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
|
||||
// silently.
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "MicroMenu.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
|
||||
s.set_quest_log(entries_sharable(&[1, 5]));
|
||||
s.set_party(party(1));
|
||||
s.run("ToggleQuestLog()").unwrap();
|
||||
|
||||
s.run("SelectQuestLogEntry(5)").unwrap();
|
||||
s.run("BenillaQuestLogFrame_Update()").unwrap();
|
||||
s.run("QuestLog_Update()").unwrap();
|
||||
s.run("QuestFramePushQuestButton:Click()").unwrap();
|
||||
assert_eq!(s.take_quest_log_pushes(), vec![5]);
|
||||
assert!(s.take_quest_log_pushes().is_empty(), "drained");
|
||||
|
||||
@@ -26,6 +26,7 @@ fn setup() -> UiScript {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
@@ -40,7 +41,6 @@ fn setup() -> UiScript {
|
||||
// drags in the whole unit-frame cluster — and it must be the REAL frames: loading only
|
||||
// `PartyFrame.lua` for the two names gets "attempt to index a nil value" the first time one
|
||||
// iterates. The manifest already seats these far above RaidFrame (1874).
|
||||
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
|
||||
// The reference's own `BasicControls.xml`, which is manifest entry 3 — far above the pane
|
||||
// (1874). `UIParentLoadAddOn` used to be what this line was here for; 1881 moved that
|
||||
// function to `UIParent.xml`, its home in the reference, and this harness already loads
|
||||
|
||||
@@ -2047,6 +2047,12 @@ mod tests {
|
||||
("SHOW_COMPARE_TOOLTIP", "PaperDollFrame.lua"),
|
||||
("SYSMSG", "UIErrorsFrame.lua"),
|
||||
("UNIT_DEFENSE", "PetPaperDollFrame.lua"),
|
||||
(
|
||||
"UNIT_QUEST_LOG_CHANGED",
|
||||
"QuestLogFrame.lua — a party member's quest-log fields changing (the reference \
|
||||
fires it off the unit's PLAYER_QUEST_LOG_* descriptor updates); benilla's unit \
|
||||
feed does not derive it yet (1944)",
|
||||
),
|
||||
(
|
||||
"UNIT_MODEL_CHANGED",
|
||||
"four files — the paperdoll model refresh",
|
||||
@@ -2115,10 +2121,20 @@ mod tests {
|
||||
}
|
||||
let lines: Vec<&str> = text.lines().map(str::trim).collect();
|
||||
for (i, t) in lines.iter().enumerate() {
|
||||
// …or the value of a `match` arm (`QuestPanel::Progress =>
|
||||
// "QUEST_PROGRESS",` — `ui_quest.rs`'s `panel_event`).
|
||||
let (t, arm_value) = match t.find("=> \"") {
|
||||
Some(k) => (t[k + 3..].trim_end_matches(','), true),
|
||||
None => (*t, false),
|
||||
};
|
||||
let Some(lit) = t.strip_prefix('"').and_then(|x| x.strip_suffix('"'))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if arm_value && caps(lit) {
|
||||
fired.insert(lit.to_string());
|
||||
continue;
|
||||
}
|
||||
let arm = i > 0
|
||||
&& lines[i - 1].ends_with('{')
|
||||
&& lines.get(i + 1).is_some_and(|n| n.starts_with('}'));
|
||||
|
||||
@@ -30,7 +30,7 @@ fn every_shipped_ui_xml_parses() {
|
||||
// Never let the sweep pass by finding nothing — a moved assets dir would otherwise turn this
|
||||
// into a test that guards zero files while staying green.
|
||||
assert!(
|
||||
checked >= 40,
|
||||
checked >= 38, // 41 until 1944 took the two quest files stock
|
||||
"only {checked} xml files swept — sweep broke"
|
||||
);
|
||||
}
|
||||
@@ -753,7 +753,7 @@ fn no_shipped_script_sets_a_global_string_key_as_display_text() {
|
||||
}
|
||||
assert!(offenders.is_empty(), "{}", offenders.join("\n"));
|
||||
// The sweep must never pass by finding nothing to sweep.
|
||||
assert!(swept >= 40, "only {swept} xml files swept — sweep broke");
|
||||
assert!(swept >= 38, "only {swept} xml files swept — sweep broke");
|
||||
}
|
||||
|
||||
/// **The `$parentTextureFrame` idiom's contract, over the whole shipped UI**: a frame whose art is
|
||||
|
||||
@@ -121,6 +121,7 @@ fn shipped_spellbook_drives_end_to_end() {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
@@ -130,7 +131,6 @@ fn shipped_spellbook_drives_end_to_end() {
|
||||
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
|
||||
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "Interface\\FrameXML\\MainMenuBar.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
|
||||
|
||||
@@ -16,6 +16,7 @@ fn taxi_script() -> UiScript {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml"); // TaxiNodeOnButtonEnter's tooltip + SetTooltipMoney
|
||||
load_xml(&s, "Interface\\FrameXML\\UIErrorsFrame.xml"); // BenillaErrorsFrame_AddMessage — DrawOneHopLines' refusal
|
||||
@@ -25,7 +26,6 @@ fn taxi_script() -> UiScript {
|
||||
// · UIPanelTemplates (.lua then .xml) — `TaxiCloseButton` inherits `UIPanelCloseButton`,
|
||||
// which lives there and NOT in our UiPanels.xml. Without it the close button loads as a
|
||||
// bare Button with no handler and a click does nothing at all.
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
|
||||
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\TaxiFrame.xml");
|
||||
|
||||
@@ -26,6 +26,7 @@ fn trainer_script() -> UiScript {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
@@ -33,7 +34,6 @@ fn trainer_script() -> UiScript {
|
||||
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); // the filter dropdown's kit
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the faux-scroll bar kit
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); // the merchant kit; the money frames are MoneyFrame.xml's (1937)
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "TrainerFrame.xml");
|
||||
s.run(
|
||||
"TRAINER_FILTER_AVAILABLE = 1 TRAINER_FILTER_UNAVAILABLE = 1 TRAINER_FILTER_USED = 1 \
|
||||
|
||||
@@ -646,13 +646,19 @@ fn shipped_target_frame_runs_the_level_law() {
|
||||
load_unit_frames(&s);
|
||||
// GetDifficultyColor's own load chain (the quest log window, its ref home).
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||||
load_xml(&s, "MicroMenu.xml");
|
||||
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
|
||||
// The player at level 3, both feeds (the snapshot UnitLevel("player") reads; the req state
|
||||
// the −1 gate and GetQuestGreenRange read) — the app keeps the two in step.
|
||||
s.set_player_req_state(PlayerReqState {
|
||||
|
||||
@@ -466,6 +466,7 @@ fn load_pet_menu_frames(s: &UiScript) {
|
||||
"Interface\\FrameXML\\Fonts.xml",
|
||||
"UIParent.xml",
|
||||
"MoneyFrame.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"UiPanels.xml",
|
||||
r"Interface\FrameXML\UIPanelTemplates.lua",
|
||||
r"Interface\FrameXML\UIPanelTemplates.xml",
|
||||
@@ -483,7 +484,6 @@ fn load_pet_menu_frames(s: &UiScript) {
|
||||
"Interface\\FrameXML\\PetFrame.xml",
|
||||
"Cooldown.xml",
|
||||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"Interface\\FrameXML\\MainMenuBar.xml",
|
||||
"Interface\\FrameXML\\ActionBarFrame.xml",
|
||||
"Interface\\FrameXML\\BonusActionBarFrame.xml",
|
||||
|
||||
@@ -19,11 +19,11 @@ const FILES: [&str; 10] = [
|
||||
// the `MoneyInputFrame_*` verbs. Seated straight after MoneyFrame.xml, benilla.toc's order.
|
||||
r"Interface\FrameXML\MoneyInputFrame.lua",
|
||||
r"Interface\FrameXML\MoneyInputFrame.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"UiPanels.xml",
|
||||
r"Interface\FrameXML\UIPanelTemplates.lua",
|
||||
r"Interface\FrameXML\UIPanelTemplates.xml",
|
||||
"GameTooltip.xml",
|
||||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||||
"MailFrame.xml",
|
||||
];
|
||||
|
||||
|
||||
@@ -33,6 +33,14 @@ pub struct PageTextMaterialCatalog {
|
||||
}
|
||||
|
||||
impl PageTextMaterialCatalog {
|
||||
/// A catalog from `(id, basename)` rows — the shipped six are 1 Parchment · 2 Stone · 3 Marble
|
||||
/// · 4 Silver · 5 Bronze · 6 Valentine (wow-re quest-material-reward-spell-bindings.md §1).
|
||||
pub fn from_rows(rows: &[(u32, &str)]) -> Self {
|
||||
Self {
|
||||
by_id: rows.iter().map(|&(id, n)| (id, n.to_string())).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The basename for a material id, or `None` for `0`/an id the table doesn't carry — the
|
||||
/// reference's own `nil`, which `ItemTextFrame.lua` substitutes with "Parchment" (and then
|
||||
/// hides the corner art, since Parchment has none).
|
||||
|
||||
@@ -72,6 +72,10 @@ pub fn is_transport(guid: u64) -> bool {
|
||||
matches!(high(guid), HIGH_MO_TRANSPORT | HIGH_TRANSPORT)
|
||||
}
|
||||
|
||||
pub fn is_gameobject(guid: u64) -> bool {
|
||||
high(guid) == HIGH_GAMEOBJECT
|
||||
}
|
||||
|
||||
/// The embedded template entry, for the guid families that carry one. Two different layouts, both
|
||||
/// VERIFIED against vmangos:
|
||||
///
|
||||
|
||||
@@ -207,7 +207,9 @@ pub use model::{TextureProbe, TextureSizeProbe};
|
||||
pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo};
|
||||
pub use pet::{PetActionView, PetStats};
|
||||
pub use pvp::{HonorState, InspectHonorData};
|
||||
pub use quest::{QuestAction, QuestItemView, QuestPanel, QuestSelect, QuestState};
|
||||
pub use quest::{
|
||||
QuestAction, QuestItemView, QuestPanel, QuestRewardSpell, QuestSelect, QuestState,
|
||||
};
|
||||
pub use quest_log::{QuestLogDetail, QuestLogEntryView, QuestLogObjectiveView, QuestLogState};
|
||||
pub(crate) use region::{apply_font_parts, implicit_creation_anchor_lua};
|
||||
pub use reputation::{FactionEntry, ReputationSend, ReputationState};
|
||||
|
||||
@@ -68,6 +68,20 @@ pub struct QuestItemView {
|
||||
|
||||
/// One open questgiver panel: the active sub-panel plus every field the four panels might read.
|
||||
/// Pushed whole by the app; `None` means no quest window is open.
|
||||
/// The quest's reward spell — `rewSpell` on `SMSG_QUESTGIVER_QUEST_DETAILS` /
|
||||
/// `SMSG_QUESTGIVER_OFFER_REWARD` and on `SMSG_QUEST_QUERY_RESPONSE` — resolved by the app to
|
||||
/// the name and icon `GetRewardSpell` / `GetQuestLogRewardSpell` answer (stock
|
||||
/// `QuestFrameItems_Update` counts it as one more reward slot, `rewardType = "spell"`, and the
|
||||
/// slot's hover is `GameTooltip:SetQuestRewardSpell()`). `tradeskill` is the third return the
|
||||
/// reference derives from the spell itself (1944 — the derivation is wow-re's to pin).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct QuestRewardSpell {
|
||||
pub spell_id: u32,
|
||||
pub name: Option<String>,
|
||||
pub texture: Option<String>,
|
||||
pub tradeskill: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct QuestState {
|
||||
pub panel: QuestPanel,
|
||||
@@ -95,6 +109,12 @@ pub struct QuestState {
|
||||
pub required_money: u32,
|
||||
/// Whether the turn-in is completable (`IsQuestCompletable`, progress panel).
|
||||
pub completable: bool,
|
||||
/// The reward spell, if the quest teaches one (`GetRewardSpell`; detail/reward panels).
|
||||
pub reward_spell: Option<QuestRewardSpell>,
|
||||
/// The panel's background material (`GetQuestBackgroundMaterial` — nil is the reference's own
|
||||
/// "Parchment" fallback in `QuestFrame_GetMaterial`). What fills it on the wire is 1944's
|
||||
/// wow-re question; until the binary says, nothing does, and the verb answers nil honestly.
|
||||
pub background_material: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for QuestState {
|
||||
@@ -113,6 +133,8 @@ impl Default for QuestState {
|
||||
reward_money: 0,
|
||||
required_money: 0,
|
||||
completable: false,
|
||||
reward_spell: None,
|
||||
background_material: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,7 +192,36 @@ impl super::UiScript {
|
||||
}
|
||||
|
||||
/// `GetQuestItemInfo(type, index)` reads from this vector by `type`; unknown types → `None`.
|
||||
fn item_vec<'a>(state: &'a QuestState, kind: &str) -> Option<&'a Vec<QuestItemView>> {
|
||||
/// The three returns of `GetRewardSpell` / `GetQuestLogRewardSpell`: `texture, name,
|
||||
/// isTradeskillSpell` — the third a 1/nil boolean in the reference's convention.
|
||||
pub(super) fn reward_spell_returns(
|
||||
lua: &Lua,
|
||||
spell: Option<QuestRewardSpell>,
|
||||
) -> mlua::Result<MultiValue> {
|
||||
let Some(sp) = spell else {
|
||||
return Ok(MultiValue::from_vec(vec![
|
||||
Value::Nil,
|
||||
Value::Nil,
|
||||
Value::Nil,
|
||||
]));
|
||||
};
|
||||
let texture = match &sp.texture {
|
||||
Some(t) => Value::String(lua.create_string(t)?),
|
||||
None => Value::Nil,
|
||||
};
|
||||
let name = match &sp.name {
|
||||
Some(n) => Value::String(lua.create_string(n)?),
|
||||
None => Value::Nil,
|
||||
};
|
||||
let tradeskill = if sp.tradeskill {
|
||||
Value::Integer(1)
|
||||
} else {
|
||||
Value::Nil
|
||||
};
|
||||
Ok(MultiValue::from_vec(vec![texture, name, tradeskill]))
|
||||
}
|
||||
|
||||
pub(super) fn item_vec<'a>(state: &'a QuestState, kind: &str) -> Option<&'a Vec<QuestItemView>> {
|
||||
match kind {
|
||||
"choice" => Some(&state.choices),
|
||||
"reward" => Some(&state.rewards),
|
||||
@@ -315,12 +366,19 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetRewardSpell() → nil (spell-reward rows out of scope in v1, decision 0088).
|
||||
// GetRewardSpell() → texture, name, isTradeskillSpell (three returns, 0x501df0; three nils on
|
||||
// every empty path, the reference's own `(nil,nil,nil)` kind — 1842). Real since 1944: the
|
||||
// app resolves `rewSpell` off the giver packets; stock's `if ( GetRewardSpell() ) then` counts
|
||||
// the slot.
|
||||
g.set(
|
||||
"GetRewardSpell",
|
||||
// THREE values on every reachable path, and the reference's own kinds include
|
||||
// `(nil,nil,nil)` — so the empty answer is three nils, not one (decision 1842).
|
||||
lua.create_function(|_, ()| Ok((Value::Nil, Value::Nil, Value::Nil)))?,
|
||||
lua.create_function(|lua, ()| {
|
||||
let spell = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
model.quest.as_ref().and_then(|q| q.reward_spell.clone())
|
||||
};
|
||||
reward_spell_returns(lua, spell)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// ── Intents ───────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -357,6 +415,57 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
install_action(lua, "CloseQuest", super::QuestAction::Close)?;
|
||||
install_action(lua, "CompleteQuest", super::QuestAction::Continue)?;
|
||||
|
||||
// GetQuestBackgroundMaterial() → nil | string (0x502230; 0 args, 1 return). The reference's
|
||||
// `QuestFrame_GetMaterial` (QuestFrame.lua:597-603) treats nil as "Parchment" and any string
|
||||
// as the name of a material whose four `$parentMaterial*` quadrants and text colours
|
||||
// (`GetMaterialTextColors`) the panel switches to. Answered from the pushed panel state.
|
||||
g.set(
|
||||
"GetQuestBackgroundMaterial",
|
||||
lua.create_function(|lua, ()| {
|
||||
let material = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
model
|
||||
.quest
|
||||
.as_ref()
|
||||
.and_then(|q| q.background_material.clone())
|
||||
};
|
||||
match material {
|
||||
Some(m) => Ok(Value::String(lua.create_string(&m)?)),
|
||||
None => Ok(Value::Nil),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// QuestChooseRewardError() — 0x5021a0, 0 args, 0 returns: the Complete button pressed with
|
||||
// choices on offer and none picked (stock `QuestRewardCompleteButton_OnClick`,
|
||||
// QuestFrame.lua:96-100). Sixteen bytes — `push 0x98; call 0x496720` — the game-error row
|
||||
// `ERR_QUEST_MUST_CHOOSE` (wow-re quest-material-reward-spell-bindings.md §2): the row's sound
|
||||
// cue `igQuestFailed` plays first, then kind 2 routes the GlobalStrings text through
|
||||
// `UI_ERROR_MESSAGE` — synchronously, as SignalEvent is — and UIErrorsFrame prints it. The
|
||||
// text is read from the same global the FrameXML shows, so a localised chain shows its own
|
||||
// string; the shipped English is the fallback when no GlobalStrings is loaded.
|
||||
g.set(
|
||||
"QuestChooseRewardError",
|
||||
lua.create_function(|lua, ()| {
|
||||
{
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model
|
||||
.sound_queue
|
||||
.push(super::SoundRequest::KitName("igQuestFailed".to_string()));
|
||||
}
|
||||
let text: String = lua
|
||||
.globals()
|
||||
.get::<Option<String>>("ERR_QUEST_MUST_CHOOSE")?
|
||||
.unwrap_or_else(|| "You must choose a reward.".to_string());
|
||||
super::tick::fire_event_into(
|
||||
lua,
|
||||
"UI_ERROR_MESSAGE",
|
||||
vec![super::ScriptValue::Str(text)],
|
||||
);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// ConfirmAcceptQuest() — the escort confirm's Yes (ref StaticPopup.lua:731-733, raised by
|
||||
// QUEST_ACCEPT_CONFIRM). No argument and no quest id: the client answers whichever confirm it
|
||||
// is holding, so the engine counts the calls and the app supplies the id (decision 1733).
|
||||
@@ -369,16 +478,19 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetQuestReward(choice) — the reward panel's Complete button: queue the chosen choice index
|
||||
// (Era passes the 1-based item id; the app maps it to the 0-based wire index). A quest with no
|
||||
// choice rewards passes 0/nil.
|
||||
// GetQuestReward(choice) — the reward panel's Complete button. FrameXML passes the 1-based row
|
||||
// (`QuestFrameRewardPanel.itemChoice = this:GetID()`, stock QuestFrame.lua:97-101) and the
|
||||
// wire wants the 0-based choice (`CMSG_QUESTGIVER_CHOOSE_REWARD`'s reward index — vmangos
|
||||
// indexes `RewChoiceItemId[reward]` with it), so the conversion is the binding's, as it is the
|
||||
// client's; our own Lua used to do the -1 itself (1944). A quest with no choice rewards
|
||||
// passes 0, which stays 0.
|
||||
g.set(
|
||||
"GetQuestReward",
|
||||
lua.create_function(|lua, choice: Option<u32>| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model
|
||||
.quest_actions
|
||||
.push(super::QuestAction::Reward(choice.unwrap_or(0)));
|
||||
model.quest_actions.push(super::QuestAction::Reward(
|
||||
choice.unwrap_or(0).saturating_sub(1),
|
||||
));
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
@@ -431,6 +543,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_material_answers_the_pushed_material_or_nil() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_quest(Some(QuestState::default()));
|
||||
assert!(s
|
||||
.eval::<bool>("return GetQuestBackgroundMaterial() == nil")
|
||||
.unwrap());
|
||||
s.set_quest(Some(QuestState {
|
||||
background_material: Some("Stone".into()),
|
||||
..QuestState::default()
|
||||
}));
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetQuestBackgroundMaterial()")
|
||||
.unwrap(),
|
||||
"Stone"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choose_reward_error_fires_the_error_event_with_the_global_string() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.run(
|
||||
"ERR_QUEST_MUST_CHOOSE = 'Pick one.' \
|
||||
local f = CreateFrame('Frame') f:RegisterEvent('UI_ERROR_MESSAGE') \
|
||||
f:SetScript('OnEvent', function() SEEN = arg1 end) \
|
||||
N = table.getn({QuestChooseRewardError()})",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<String>("return SEEN").unwrap(), "Pick one.");
|
||||
assert_eq!(s.eval::<i64>("return N").unwrap(), 0, "zero return values");
|
||||
assert_eq!(
|
||||
s.take_sounds(),
|
||||
vec![crate::script::SoundRequest::KitName("igQuestFailed".into())],
|
||||
"row 0x98's cue plays with the message"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_panel_getters_read() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -572,14 +721,14 @@ mod tests {
|
||||
assert_eq!(s.take_quest_actions(), vec![QuestAction::Accept]);
|
||||
|
||||
s.run("CompleteQuest()").unwrap(); // progress -> reward
|
||||
s.run("GetQuestReward(2)").unwrap(); // choose reward index 2
|
||||
s.run("GetQuestReward(2)").unwrap(); // row 2 (1-based) → wire choice 1 (0-based)
|
||||
s.run("GetQuestReward()").unwrap(); // no-choice quest -> 0
|
||||
s.run("DeclineQuest()").unwrap();
|
||||
assert_eq!(
|
||||
s.take_quest_actions(),
|
||||
vec![
|
||||
QuestAction::Continue,
|
||||
QuestAction::Reward(2),
|
||||
QuestAction::Reward(1),
|
||||
QuestAction::Reward(0),
|
||||
QuestAction::Close,
|
||||
]
|
||||
|
||||
@@ -137,6 +137,9 @@ pub struct QuestLogDetail {
|
||||
pub choices: Vec<QuestItemView>,
|
||||
/// Fixed rewards (`GetNumQuestLogRewards`/`GetQuestLogRewardInfo`).
|
||||
pub rewards: Vec<QuestLogQuestItem>,
|
||||
/// The selected quest's reward spell (`rewSpell` on `SMSG_QUEST_QUERY_RESPONSE`), as
|
||||
/// `GetQuestLogRewardSpell` answers it.
|
||||
pub reward_spell: Option<super::quest::QuestRewardSpell>,
|
||||
}
|
||||
|
||||
/// A quest-log reward row is the same shape as a questgiver panel row.
|
||||
@@ -687,11 +690,21 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
|
||||
// ── v1 stubs (each the seam of a named later slice — see the module doc) ─────────────────────
|
||||
// GetQuestLogRewardSpell() → texture, name, isTradeskillSpell (0x4e1130): the selected
|
||||
// quest's reward spell, three nils when it has none.
|
||||
g.set(
|
||||
"GetQuestLogRewardSpell",
|
||||
// THREE values on every reachable path, and the reference's own kinds include
|
||||
// `(nil,nil,nil)` — so the empty answer is three nils, not one (decision 1842).
|
||||
lua.create_function(|_, ()| Ok((Value::Nil, Value::Nil, Value::Nil)))?,
|
||||
lua.create_function(|lua, ()| {
|
||||
let spell = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
model
|
||||
.quest_log
|
||||
.detail
|
||||
.as_ref()
|
||||
.and_then(|d| d.reward_spell.clone())
|
||||
};
|
||||
super::quest::reward_spell_returns(lua, spell)
|
||||
})?,
|
||||
)?;
|
||||
g.set(
|
||||
"IsUnitOnQuest",
|
||||
@@ -874,6 +887,7 @@ mod tests {
|
||||
usable: true,
|
||||
link: Some("|cffffffff|Hitem:2024:0:0:0|h[Militia Hammer]|h|r".into()),
|
||||
}],
|
||||
reward_spell: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,29 @@ fn subtree_span(model: &Model, f: FrameHandle) -> Option<(f32, f32)> {
|
||||
span
|
||||
}
|
||||
|
||||
/// Every FontString under `f` (its own regions, then its visible children's), in tree order.
|
||||
fn subtree_font_strings(model: &Model, f: FrameHandle) -> Vec<crate::widget::RegionHandle> {
|
||||
let mut out = Vec::new();
|
||||
let Some(frame) = model.arena.frame(f) else {
|
||||
return out;
|
||||
};
|
||||
for &rh in &frame.regions {
|
||||
if model
|
||||
.arena
|
||||
.region(rh)
|
||||
.is_some_and(|r| matches!(r.kind, crate::widget::RegionKind::FontString))
|
||||
{
|
||||
out.push(rh);
|
||||
}
|
||||
}
|
||||
for &ch in &frame.children {
|
||||
if model.arena.frame(ch).is_some_and(|c| c.effective_visible) {
|
||||
out.extend(subtree_font_strings(model, ch));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let m = lua.create_table()?;
|
||||
|
||||
@@ -229,6 +252,26 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
"UpdateScrollChildRect",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
// The reference derives the range from a child whose text is already laid out — its
|
||||
// measure is synchronous. Ours measures on the metric reads and on the host's
|
||||
// round-trip, which lands a frame later; stock `QuestLog_UpdateQuestDetails` calls
|
||||
// this right after the SetTexts that fill the pane, and read a range of 0 (1944). So
|
||||
// the child's FontStrings are measured here, through the installed measurer, before
|
||||
// the layout that the range reads; with no measurer installed the round-trip fills
|
||||
// them as before.
|
||||
let strings: Vec<crate::widget::RegionHandle> = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
match model.arena.frame(h).map(|f| &f.kind_state) {
|
||||
Some(KindState::Scroll(sf)) => sf
|
||||
.child
|
||||
.map(|c| subtree_font_strings(&model, c))
|
||||
.unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
for rh in strings {
|
||||
super::measure::ensure_measured(lua, rh);
|
||||
}
|
||||
let (id, range) = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
super::UiScript::resolve_layout(&mut model);
|
||||
|
||||
@@ -490,3 +490,43 @@ fn hit_test_denies_a_button_clipped_out_and_admits_it_once_scrolled_into_view()
|
||||
"scrolled into view, the button hits again"
|
||||
);
|
||||
}
|
||||
|
||||
/// `UpdateScrollChildRect` right after the SetTexts that fill a pane reads a range that already
|
||||
/// counts the wrapped text — the reference's measure is synchronous, and with a font engine
|
||||
/// installed so is ours (1944; stock `QuestLog_UpdateQuestDetails` is the caller that found it).
|
||||
#[test]
|
||||
fn update_scroll_child_rect_measures_the_childs_text_before_it_reads_the_range() {
|
||||
struct Rows;
|
||||
impl crate::script::TextMeasure for Rows {
|
||||
fn measure(&mut self, req: &crate::script::MeasureRequest) -> (f32, f32, f32) {
|
||||
let natural = req.text.chars().count() as f32 * 7.0;
|
||||
match req.wrap_width {
|
||||
Some(w) if w > 0.0 && natural > w => (w, (natural / w).ceil() * 14.0, natural),
|
||||
_ => (natural, 14.0, natural),
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_text_measurer(Box::new(Rows));
|
||||
s.run(
|
||||
r#"
|
||||
local sf = CreateFrame("ScrollFrame", "SF", UIParent)
|
||||
sf:SetPoint("TOPLEFT", 0, 0); sf:SetSize(100, 50)
|
||||
local child = CreateFrame("Frame", "SFChild", sf)
|
||||
child:SetSize(100, 50); sf:SetScrollChild(child)
|
||||
local fs = child:CreateFontString("SFText", "ARTWORK")
|
||||
fs:SetPoint("TOPLEFT", 0, 0); fs:SetWidth(70)
|
||||
sf:SetScript("OnScrollRangeChanged", function() RANGE = arg2 end)
|
||||
fs:SetText(string.rep("x", 40)) -- 280px of ink over a 70px column: four 14px rows
|
||||
sf:UpdateScrollChildRect()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return RANGE").unwrap(),
|
||||
6.0,
|
||||
"56px of measured text under a 50px frame: the range is known at the call, not a frame later"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -166,3 +166,67 @@ fn item_set_block_counts_and_colors() {
|
||||
assert_eq!(s.take_item_set_asks(), vec![1], "the set ask recorded");
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// `GameTooltip:SetQuestItem(type, index)` and `SetQuestLogItem(type, index)` — the quest-giver
|
||||
/// panels' and the quest log's reward hovers (stock QuestFrameTemplates.xml:148,
|
||||
/// QuestLogFrame.xml:113): the row's item by id through the shared renderer; a row that is not
|
||||
/// there leaves the tooltip empty (1944).
|
||||
#[test]
|
||||
fn quest_item_hovers_render_the_rows_item_by_id() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_item_template(
|
||||
2024,
|
||||
ItemTemplateView {
|
||||
name: "Militia Hammer".into(),
|
||||
quality: 1,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let row = || QuestItemView {
|
||||
item_id: 2024,
|
||||
name: Some("Militia Hammer".into()),
|
||||
quality: 1,
|
||||
..Default::default()
|
||||
};
|
||||
s.set_quest(Some(QuestState {
|
||||
panel: QuestPanel::Detail,
|
||||
choices: vec![row()],
|
||||
..QuestState::default()
|
||||
}));
|
||||
s.run(
|
||||
r#"
|
||||
local a = CreateFrame("Button", "Q1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
|
||||
local tt = CreateFrame("GameTooltip", "TT")
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT"); tt:SetQuestItem("choice", 1)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(lines_of(&mut s)[0].0, "Militia Hammer");
|
||||
|
||||
s.run(r#"TT:SetOwner(Q1, "ANCHOR_RIGHT"); TT:SetQuestItem("choice", 2)"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return TT:NumLines()").unwrap(),
|
||||
0,
|
||||
"an out-of-range row shows nothing"
|
||||
);
|
||||
s.run(r#"TT:SetOwner(Q1, "ANCHOR_RIGHT"); TT:SetQuestItem("reward", 1)"#)
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<i64>("return TT:NumLines()").unwrap(), 0);
|
||||
|
||||
s.set_quest_log(QuestLogState {
|
||||
detail: Some(QuestLogDetail {
|
||||
rewards: vec![row()],
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
s.run(r#"TT:SetOwner(Q1, "ANCHOR_RIGHT"); TT:SetQuestLogItem("reward", 1)"#)
|
||||
.unwrap();
|
||||
assert_eq!(lines_of(&mut s)[0].0, "Militia Hammer");
|
||||
s.run(r#"TT:SetOwner(Q1, "ANCHOR_RIGHT"); TT:SetQuestLogItem("choice", 1)"#)
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<i64>("return TT:NumLines()").unwrap(), 0);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -992,3 +992,86 @@ fn the_remaining_line_is_gated_on_until_cancelled_not_on_the_duration() {
|
||||
);
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// `GetRewardSpell()` / `GetQuestLogRewardSpell()` answer `texture, name, isTradeskillSpell` —
|
||||
/// three nils when the quest teaches nothing (the reference's own `(nil,nil,nil)` kind) — and
|
||||
/// `GameTooltip:SetQuestRewardSpell()` / `SetQuestLogRewardSpell()` render that spell (1944).
|
||||
#[test]
|
||||
fn quest_reward_spell_getters_and_hovers() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_spell_tooltip(133, fireball());
|
||||
s.run(
|
||||
r#"
|
||||
local a = CreateFrame("Button", "Q1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
|
||||
local tt = CreateFrame("GameTooltip", "TT")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
s.set_quest(Some(QuestState::default()));
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', GetRewardSpell())")
|
||||
.unwrap(),
|
||||
3,
|
||||
"three values on the empty path"
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"local t, n, ts = GetRewardSpell() return t == nil and n == nil and ts == nil"
|
||||
)
|
||||
.unwrap());
|
||||
s.run(r#"TT:SetOwner(Q1, "ANCHOR_RIGHT"); TT:SetQuestRewardSpell()"#)
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<i64>("return TT:NumLines()").unwrap(), 0);
|
||||
|
||||
let reward = QuestRewardSpell {
|
||||
spell_id: 133,
|
||||
name: Some("Fireball".into()),
|
||||
texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()),
|
||||
tradeskill: false,
|
||||
};
|
||||
s.set_quest(Some(QuestState {
|
||||
panel: QuestPanel::Reward,
|
||||
reward_spell: Some(reward.clone()),
|
||||
..QuestState::default()
|
||||
}));
|
||||
assert_eq!(
|
||||
s.eval::<(String, String, Option<i64>)>("return GetRewardSpell()")
|
||||
.unwrap(),
|
||||
(
|
||||
"Interface\\Icons\\Spell_Fire_FlameBolt".to_string(),
|
||||
"Fireball".to_string(),
|
||||
None
|
||||
)
|
||||
);
|
||||
s.run(r#"TT:SetOwner(Q1, "ANCHOR_RIGHT"); TT:SetQuestRewardSpell()"#)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return TTTextLeft1:GetText()").unwrap(),
|
||||
"Fireball"
|
||||
);
|
||||
|
||||
s.set_quest_log(QuestLogState {
|
||||
detail: Some(QuestLogDetail {
|
||||
reward_spell: Some(reward),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
s.eval::<(String, String, Option<i64>)>("return GetQuestLogRewardSpell()")
|
||||
.unwrap()
|
||||
.1,
|
||||
"Fireball"
|
||||
);
|
||||
s.run(r#"TT:SetOwner(Q1, "ANCHOR_RIGHT"); TT:SetQuestLogRewardSpell()"#)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return TTTextLeft1:GetText()").unwrap(),
|
||||
"Fireball"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -159,6 +159,26 @@ fn render_by_id(
|
||||
/// (rings, trinkets, one-hand weapons, and a two-hander displacing both hands) fire two
|
||||
/// shopping tooltips, ref PaperDollFrame.lua's `arg2`. Twin of the app-side
|
||||
/// `ui_items::find_equip_slot` (the equip-click fit rule) — one law, two consumers.
|
||||
/// The quest-item hover: the row's item by id, or an empty tooltip when the row is not there.
|
||||
fn set_quest_item_view(
|
||||
lua: &Lua,
|
||||
this: &Table,
|
||||
item: Option<crate::script::quest::QuestItemView>,
|
||||
) -> mlua::Result<()> {
|
||||
match item {
|
||||
Some(it) => render_by_id(lua, this, it.item_id, it.name.clone(), Some(it.quality)),
|
||||
None => {
|
||||
let h = frame_handle_of(lua, this)?;
|
||||
{
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
clear_content(&mut model, h);
|
||||
}
|
||||
super::tooltip::show_or_hide_empty(lua, h);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn equip_slots_for(inventory_type: u32) -> &'static [u32] {
|
||||
match inventory_type {
|
||||
1 => &[1], // head
|
||||
@@ -292,6 +312,46 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
)?,
|
||||
)?;
|
||||
|
||||
// GameTooltip:SetQuestItem(type, index) / SetQuestLogItem(type, index) — the quest-giver
|
||||
// panels' and the quest log's reward/choice/required hovers (stock QuestFrameTemplates.xml:148,
|
||||
// QuestLogFrame.xml:113; bindings 0x533610 / 0x533760, 3 args with self, 0 returns). `type` is
|
||||
// the same "choice" | "reward" | "required" key `GetQuestItemInfo` takes, `index` the 1-based
|
||||
// slot; the item is rendered by id through the shared renderer, with the row's own name and
|
||||
// quality as the fallbacks a template that has not arrived yet paints from. An unknown type or
|
||||
// an out-of-range index shows nothing — the reference's tooltip stays empty too.
|
||||
m.set(
|
||||
"SetQuestItem",
|
||||
lua.create_function(|lua, (this, kind, index): (Table, String, usize)| {
|
||||
let item = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
model.quest.as_ref().and_then(|q| {
|
||||
crate::script::quest::item_vec(q, &kind)
|
||||
.and_then(|v| index.checked_sub(1).and_then(|n| v.get(n)))
|
||||
.cloned()
|
||||
})
|
||||
};
|
||||
set_quest_item_view(lua, &this, item)
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"SetQuestLogItem",
|
||||
lua.create_function(|lua, (this, kind, index): (Table, String, usize)| {
|
||||
let item = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
model.quest_log.detail.as_ref().and_then(|d| {
|
||||
let v = match kind.as_str() {
|
||||
"choice" => Some(&d.choices),
|
||||
"reward" => Some(&d.rewards),
|
||||
_ => None,
|
||||
};
|
||||
v.and_then(|v| index.checked_sub(1).and_then(|n| v.get(n)))
|
||||
.cloned()
|
||||
})
|
||||
};
|
||||
set_quest_item_view(lua, &this, item)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GameTooltip:SetHyperlink(link) — the chat-link tooltip (ref ItemRef.lua's SetItemRef →
|
||||
// ItemRefTooltip:SetHyperlink). Accepts the full escaped link or a bare "item:<id>";
|
||||
// non-item links (player/spell/quest) have no tooltip surface yet and no-op.
|
||||
|
||||
@@ -318,6 +318,27 @@ pub(super) fn set_spell_with_talent(
|
||||
|
||||
/// Shared entry: clear, render (or record the ask and show nothing but the name if the caller
|
||||
/// knows one), show.
|
||||
/// The reward-spell hover: the spell by id with the quest's own name as the fallback, or an
|
||||
/// empty tooltip.
|
||||
fn set_reward_spell(
|
||||
lua: &Lua,
|
||||
this: &Table,
|
||||
spell: Option<(u32, Option<String>)>,
|
||||
) -> mlua::Result<()> {
|
||||
match spell {
|
||||
Some((id, name)) => set_spell_by_id(lua, this, id, name, SpellRenderOpts::default(), None),
|
||||
None => {
|
||||
let h = frame_handle_of(lua, this)?;
|
||||
{
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
clear_content(&mut model, h);
|
||||
}
|
||||
super::tooltip::show_or_hide_empty(lua, h);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_spell_by_id(
|
||||
lua: &Lua,
|
||||
this: &Table,
|
||||
@@ -614,6 +635,39 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// table). Not the spell title's gold, and nothing about the bound spell: 1.12 has no
|
||||
// `#showtooltip`. This arm was a `_ => Ok(())` left from before 0983 shipped macros, which is
|
||||
// why a macro on the bar hovered to nothing (the director, 2026-08-27, after 1636).
|
||||
// GameTooltip:SetQuestRewardSpell() / SetQuestLogRewardSpell() — the hover of the reward
|
||||
// slot stock marks `rewardType = "spell"` (QuestFrameTemplates.xml:150, QuestLogFrame.xml:115;
|
||||
// bindings 0x535bb0 / 0x535c60, self only, 0 returns): the quest's reward spell by id through
|
||||
// the spell renderer, an empty tooltip when the quest rewards none.
|
||||
m.set(
|
||||
"SetQuestRewardSpell",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let id = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
model
|
||||
.quest
|
||||
.as_ref()
|
||||
.and_then(|q| q.reward_spell.as_ref())
|
||||
.map(|s| (s.spell_id, s.name.clone()))
|
||||
};
|
||||
set_reward_spell(lua, &this, id)
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"SetQuestLogRewardSpell",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let id = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
model
|
||||
.quest_log
|
||||
.detail
|
||||
.as_ref()
|
||||
.and_then(|d| d.reward_spell.as_ref())
|
||||
.map(|s| (s.spell_id, s.name.clone()))
|
||||
};
|
||||
set_reward_spell(lua, &this, id)
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"SetAction",
|
||||
lua.create_function(|lua, (this, slot): (Table, u32)| {
|
||||
|
||||
@@ -1192,6 +1192,15 @@ pub struct ModelPart {
|
||||
pub blend: ModelBlend,
|
||||
}
|
||||
|
||||
/// **Why this submesh is an entity at all** — the world streamer's answer, one static label per
|
||||
/// batch, for the census (`VIS_CENSUS … why …`). A city keeps thousands of hidden model
|
||||
/// submeshes resident on the entity path while the retained pass draws the world (5.6k ADT
|
||||
/// doodad submeshes at the Stormwind auction house, 1945), and every one of them rides every
|
||||
/// per-`Mesh3d` sweep; which divert declined it is the first question about pricing them.
|
||||
/// Absent on units, GameObjects and everything the app lane spawns.
|
||||
#[derive(Component, Clone, Copy)]
|
||||
pub struct EntityPathWhy(pub &'static str);
|
||||
|
||||
/// Ordering handle so the one system allowed to *override* the model-`Visibility` authority — the
|
||||
/// self-avatar first-person hide ([`crate::player`]) — can run **after** it and win the frame.
|
||||
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
|
||||
@@ -261,7 +261,10 @@ struct Active {
|
||||
fn evaluate_rig_poses(
|
||||
mut rigs: Query<(&AnimationPlayer, &ModelAnimations, &mut RigPose), Without<AnimParked>>,
|
||||
) {
|
||||
for (player, anims, mut rig) in &mut rigs {
|
||||
// Parallel over rigs (decision 1945): a rig reads its shared clips and writes only its own
|
||||
// pose, and a raid stands ~400 live rigs / ~12k bones — 0.4 ms of one thread's frame when
|
||||
// walked serially, and this system sits on the main thread's critical path.
|
||||
rigs.par_iter_mut().for_each(|(player, anims, mut rig)| {
|
||||
let src = &anims.pose;
|
||||
// This rig's contributing animations, ascending by node index (Bevy's effective blend
|
||||
// order — sorted children folded through a LIFO stack, see the module doc).
|
||||
@@ -314,7 +317,7 @@ fn evaluate_rig_poses(
|
||||
blend_rig(src, many, &mut rig);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The multi-animation path (a cross-fade, a masked overlay, the grip): merge-walk the
|
||||
|
||||
@@ -116,6 +116,7 @@ fn spawn_exile(commands: &mut Commands, f: &GxFader, alpha: f32, admitted: bool)
|
||||
kind: crate::model_render::ModelKind::Doodad,
|
||||
blend: b.blend_mode,
|
||||
},
|
||||
crate::model_render::EntityPathWhy("exile"),
|
||||
crate::interact::PickMesh(b.geometry.clone()),
|
||||
bevy::mesh::MeshTag(crate::mesh_tag::alpha_bits(alpha)),
|
||||
crate::model_fade::DoodadFade {
|
||||
|
||||
@@ -402,10 +402,30 @@ pub fn spawn_model_entities(
|
||||
// prop lane admits steady interior props + never-fade exterior props while an
|
||||
// exterior FADER prop stays on the entity path (the exile protocol has no prop
|
||||
// shape — that keep is today's default look) and is TALLIED, never silent.
|
||||
// The census label for a batch that stays on the entity path (`EntityPathWhy`):
|
||||
// refined below as each divert declines it.
|
||||
let mut why: &'static str = "gx-off";
|
||||
if let Some((gx, site)) = staticgx.as_mut() {
|
||||
let facts = if !crate::static_gx::enabled() || shared_geometry[batch_idx] {
|
||||
let facts = if !crate::static_gx::enabled() {
|
||||
None
|
||||
} else if shared_geometry[batch_idx] {
|
||||
why = "shared-geometry";
|
||||
None
|
||||
} else {
|
||||
why = match site {
|
||||
crate::static_gx::GxSite::Doodad { .. } if is_wmo => "doodad-site-wmo",
|
||||
crate::static_gx::GxSite::Doodad { .. } if !class.merges() => "no-merge",
|
||||
crate::static_gx::GxSite::Doodad { .. } if class.interior_prop => {
|
||||
"interior-prop"
|
||||
}
|
||||
crate::static_gx::GxSite::Doodad { .. } => "fader-lane-off",
|
||||
crate::static_gx::GxSite::Wmo { .. } if !is_wmo => "wmo-site-m2",
|
||||
crate::static_gx::GxSite::Wmo { .. } if !class.merges() => "wmo-no-merge",
|
||||
crate::static_gx::GxSite::Wmo { .. } => "wmo-no-group",
|
||||
crate::static_gx::GxSite::Prop { .. } if is_wmo => "prop-site-wmo",
|
||||
crate::static_gx::GxSite::Prop { .. } if !class.merges() => "prop-no-merge",
|
||||
crate::static_gx::GxSite::Prop { .. } => "exterior-fader-prop",
|
||||
};
|
||||
match site {
|
||||
crate::static_gx::GxSite::Doodad { owner }
|
||||
if !is_wmo && class.merges() && !class.interior_prop =>
|
||||
@@ -469,6 +489,7 @@ pub fn spawn_model_entities(
|
||||
}
|
||||
};
|
||||
if let Some((owner, wmo, prop, fade)) = facts {
|
||||
why = "gx-declined";
|
||||
if gx.divert(crate::static_gx::GxBatch {
|
||||
geometry: &sub.geometry,
|
||||
transform,
|
||||
@@ -616,6 +637,7 @@ pub fn spawn_model_entities(
|
||||
kind,
|
||||
blend: sub.blend,
|
||||
},
|
||||
crate::model_render::EntityPathWhy(why),
|
||||
// The picker's triangles (decision 0857): the render forms are `RENDER_WORLD`-only,
|
||||
// so the inspector/probe rays read the model's resident geometry. The caster
|
||||
// centres a card at its pivot, the same bake the render form draws with.
|
||||
@@ -650,6 +672,7 @@ pub fn spawn_model_entities(
|
||||
kind,
|
||||
blend: sub.blend,
|
||||
},
|
||||
crate::model_render::EntityPathWhy(why),
|
||||
// The picker's triangles (decision 0857) — same rule as the card above.
|
||||
crate::interact::PickMesh(sub.geometry.clone()),
|
||||
mesh_tag,
|
||||
|
||||
@@ -79,6 +79,13 @@ pub struct CensusReport {
|
||||
/// Visible submeshes per model subsystem — `(column name, visible, of-those-gated)` — in a
|
||||
/// fixed column order, because a census whose columns move cannot be diffed across runs.
|
||||
pub kinds: [(&'static str, usize, usize); 4],
|
||||
/// Resident submeshes per model subsystem, same column order as `kinds` — hidden or not.
|
||||
/// A city's hidden twins (the retained pass draws them) ride every per-`Mesh3d` sweep
|
||||
/// whether they draw or not, and this is the count that prices them.
|
||||
pub resident: [usize; 4],
|
||||
/// Resident submeshes per `EntityPathWhy` label (the streamer's reason a batch is an
|
||||
/// entity), most first; `"-"` for the untagged (units, GameObjects, the app lane).
|
||||
pub why: Vec<(&'static str, usize)>,
|
||||
/// Of every [`ExteriorScene`]-tagged submesh: how many exist, how many the cull wrote `Hidden`
|
||||
/// on, how many were exempt (the camera's own placement — decision 0784), and how many carry
|
||||
/// **no `Aabb`**, which is the cull's fail-open arm admitting them unconditionally.
|
||||
@@ -191,6 +198,12 @@ impl WorldCensus<'_, '_> {
|
||||
}
|
||||
|
||||
/// Snapshot this frame.
|
||||
/// Live particles this instant — the one census number cheap enough to sample per frame
|
||||
/// (a fold over the emitters, no part walk): a spell burst's signature on a tail frame.
|
||||
pub fn live_particles(&self) -> usize {
|
||||
self.emitters.iter().map(|p| p.live()).sum()
|
||||
}
|
||||
|
||||
pub fn take(&self) -> CensusReport {
|
||||
let own_instance = self
|
||||
.claim
|
||||
@@ -199,13 +212,17 @@ impl WorldCensus<'_, '_> {
|
||||
.map(|c| c.room.instance);
|
||||
|
||||
let mut kinds = [(0usize, 0usize); 4];
|
||||
let mut resident = [0usize; 4];
|
||||
let mut why: HashMap<&'static str, usize> = HashMap::new();
|
||||
let mut labels: HashMap<(String, bool), usize> = HashMap::new();
|
||||
let mut escaped: HashMap<(String, bool), usize> = HashMap::new();
|
||||
let (mut tagged, mut hidden, mut exempt_n, mut no_aabb) = (0, 0, 0, 0);
|
||||
let (mut submeshes, mut drawn) = (0usize, 0usize);
|
||||
|
||||
for (vis, part, gated, object, want, aabb, card, group) in self.parts.iter() {
|
||||
for (vis, part, gated, object, want, aabb, card, group, path_why) in self.parts.iter() {
|
||||
submeshes += 1;
|
||||
resident[kind_index(part.kind)] += 1;
|
||||
*why.entry(path_why.map_or("-", |w| w.0)).or_default() += 1;
|
||||
drawn += usize::from(vis.get());
|
||||
if gated {
|
||||
// The camera's own placement is not exterior scene to itself (decision 0784) and
|
||||
@@ -266,6 +283,12 @@ impl WorldCensus<'_, '_> {
|
||||
CensusReport {
|
||||
submeshes,
|
||||
drawn,
|
||||
why: {
|
||||
let mut v: Vec<_> = why.into_iter().collect();
|
||||
v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
|
||||
v
|
||||
},
|
||||
resident,
|
||||
kinds: std::array::from_fn(|i| (KIND_COLUMNS[i], kinds[i].0, kinds[i].1)),
|
||||
tagged,
|
||||
hidden,
|
||||
@@ -352,6 +375,7 @@ type CensusData = (
|
||||
Option<&'static Aabb>,
|
||||
Has<crate::billboard::BillboardCard>,
|
||||
Option<&'static WmoGroupVis>,
|
||||
Option<&'static crate::model_render::EntityPathWhy>,
|
||||
);
|
||||
|
||||
/// The census column order, pinned: entry `i` names [`kind_index`]'s slot `i`. Column positions
|
||||
|
||||
Vendored
+39
@@ -0,0 +1,39 @@
|
||||
# benilla's `kira` fork — what differs from upstream, and how to check
|
||||
|
||||
Upstream: [`tesselode/kira`](https://github.com/tesselode/kira), MIT OR Apache-2.0, version
|
||||
`0.12.1` — the version the workspace lock resolves to. Wired in through `[patch.crates-io]` in
|
||||
the workspace root, the way `lua-src` is. The crate is copied from the cargo registry as
|
||||
published (`src/`, `Cargo.toml`, `README.md`) plus the two licence files from the repository;
|
||||
upstream files are CRLF and are left so, except the one file patched.
|
||||
|
||||
## Why a fork exists at all
|
||||
|
||||
The mixer runs on benilla's own audio render thread (`sound/output`), and with a raid buffing
|
||||
itself — a dozen live voices, most of them spatial — that thread cost **~1.5 ms of every 60 Hz
|
||||
frame** on the M2 Air (decision 1945: a sampled profile of the crowd rig's Blackrock leg with
|
||||
sound on, `Track::process` two thirds of it). Upstream's sub-track `process` spatializes **every
|
||||
frame**: two vector normalizes, a length, the attenuation curve and a dB→amplitude `powf` per
|
||||
frame per spatial track, then two more `powf`s per frame for the volume tween and the fade. At
|
||||
48 kHz × 12 tracks that is ~1.7 M `powf`s a second plus the vector math, for gains that change
|
||||
imperceptibly across a 10 ms chunk. FMOD 3 — the mixer the real client delegates to — updates
|
||||
pan and attenuation per mixer update, not per sample.
|
||||
|
||||
## The one patch — `src/track/sub.rs`
|
||||
|
||||
`Track::process` evaluates the spatial gains (attenuation, left/right ear gain, the mono fold)
|
||||
and the volume × fade amplitude at the chunk's two ends and interpolates them linearly across
|
||||
the chunk. `SpatialData::spatial_gains` is `spatialize` factored into a value; `spatialize`
|
||||
itself is kept, unused, so the factoring can be diffed against it. A chunk is the output
|
||||
device's buffer — 512 frames, ~10.7 ms, `sound/output::DEVICE_BUFFER_FRAMES`.
|
||||
|
||||
What changes audibly: nothing a listener can name. A source crossing an ear's axis within one
|
||||
chunk pans linearly in gain over 10 ms instead of following the dot product per sample; a
|
||||
volume tween ramps linearly in amplitude across each chunk instead of in decibels. Both are
|
||||
below the granularity the reference itself updates at.
|
||||
|
||||
## How to check
|
||||
|
||||
`git diff --no-index <registry>/kira-0.12.1/src/track/sub.rs third_party/kira/src/track/sub.rs`
|
||||
(after `sed 's/\r$//'` on the upstream copy) shows the patch and nothing else; every other file
|
||||
is byte-identical to the registry crate. The mixer's own tests (`cargo test -p benilla-app
|
||||
sound::`) run against the fork.
|
||||
Vendored
+182
@@ -0,0 +1,182 @@
|
||||
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
||||
#
|
||||
# When uploading crates to the registry Cargo will automatically
|
||||
# "normalize" Cargo.toml files for maximal compatibility
|
||||
# with all versions of Cargo and also rewrite `path` dependencies
|
||||
# to registry (e.g., crates.io) dependencies.
|
||||
#
|
||||
# If you are reading this file be aware that the original Cargo.toml
|
||||
# will likely look very different (and much more reasonable).
|
||||
# See Cargo.toml.orig for the original contents.
|
||||
|
||||
[package]
|
||||
edition = "2024"
|
||||
name = "kira"
|
||||
version = "0.12.1"
|
||||
authors = ["Andrew Minnich <aminnich3@gmail.com>"]
|
||||
build = false
|
||||
autolib = false
|
||||
autobins = false
|
||||
autoexamples = false
|
||||
autotests = false
|
||||
autobenches = false
|
||||
description = "Expressive audio library for games"
|
||||
readme = "README.md"
|
||||
keywords = [
|
||||
"gamedev",
|
||||
"audio",
|
||||
"music",
|
||||
]
|
||||
categories = [
|
||||
"game-development",
|
||||
"multimedia::audio",
|
||||
]
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/tesselode/kira"
|
||||
resolver = "2"
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
rustdoc-args = [
|
||||
"--cfg",
|
||||
"docsrs",
|
||||
]
|
||||
|
||||
[features]
|
||||
aac = [
|
||||
"symphonia",
|
||||
"symphonia/aac",
|
||||
]
|
||||
adpcm = [
|
||||
"symphonia",
|
||||
"symphonia/adpcm",
|
||||
]
|
||||
aiff = [
|
||||
"symphonia",
|
||||
"symphonia/aiff",
|
||||
]
|
||||
alac = [
|
||||
"symphonia",
|
||||
"symphonia/alac",
|
||||
]
|
||||
caf = [
|
||||
"symphonia",
|
||||
"symphonia/caf",
|
||||
]
|
||||
default = [
|
||||
"cpal",
|
||||
"mp3",
|
||||
"ogg",
|
||||
"vorbis",
|
||||
"flac",
|
||||
"wav",
|
||||
"pcm",
|
||||
]
|
||||
flac = [
|
||||
"symphonia",
|
||||
"symphonia/flac",
|
||||
]
|
||||
isomp4 = [
|
||||
"symphonia",
|
||||
"symphonia/isomp4",
|
||||
]
|
||||
mkv = [
|
||||
"symphonia",
|
||||
"symphonia/mkv",
|
||||
]
|
||||
mp1 = [
|
||||
"symphonia",
|
||||
"symphonia/mp1",
|
||||
]
|
||||
mp2 = [
|
||||
"symphonia",
|
||||
"symphonia/mp2",
|
||||
]
|
||||
mp3 = [
|
||||
"symphonia",
|
||||
"symphonia/mp3",
|
||||
]
|
||||
mpa = [
|
||||
"symphonia",
|
||||
"symphonia/mpa",
|
||||
]
|
||||
ogg = [
|
||||
"symphonia",
|
||||
"symphonia/ogg",
|
||||
]
|
||||
pcm = [
|
||||
"symphonia",
|
||||
"symphonia/pcm",
|
||||
]
|
||||
vorbis = [
|
||||
"symphonia",
|
||||
"symphonia/vorbis",
|
||||
]
|
||||
wav = [
|
||||
"symphonia",
|
||||
"symphonia/wav",
|
||||
]
|
||||
|
||||
[lib]
|
||||
name = "kira"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[test]]
|
||||
name = "change_sample_rate"
|
||||
path = "tests/change_sample_rate.rs"
|
||||
|
||||
[[test]]
|
||||
name = "streaming_sound_stops_on_error"
|
||||
path = "tests/streaming_sound_stops_on_error.rs"
|
||||
|
||||
[[test]]
|
||||
name = "sync_send"
|
||||
path = "tests/sync_send.rs"
|
||||
|
||||
[dependencies.assert_no_alloc]
|
||||
version = "1.1.2"
|
||||
optional = true
|
||||
|
||||
[dependencies.atomic-arena]
|
||||
version = "0.1.2"
|
||||
|
||||
[dependencies.glam]
|
||||
version = "0.33.0"
|
||||
features = ["mint"]
|
||||
|
||||
[dependencies.mint]
|
||||
version = "0.5.9"
|
||||
|
||||
[dependencies.pastey]
|
||||
version = "0.2.1"
|
||||
|
||||
[dependencies.rtrb]
|
||||
version = "0.3.2"
|
||||
|
||||
[dependencies.serde]
|
||||
version = "1.0.219"
|
||||
features = ["derive"]
|
||||
optional = true
|
||||
|
||||
[dependencies.symphonia]
|
||||
version = "0.5.4"
|
||||
optional = true
|
||||
default-features = false
|
||||
|
||||
[dependencies.triple_buffer]
|
||||
version = "9.0.0"
|
||||
|
||||
[dev-dependencies.approx]
|
||||
version = "0.5.1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.cpal]
|
||||
version = "0.17.0"
|
||||
optional = true
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies.cpal]
|
||||
version = "0.17.0"
|
||||
features = ["wasm-bindgen"]
|
||||
optional = true
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies.send_wrapper]
|
||||
version = "0.6.0"
|
||||
Vendored
+201
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2021 Andrew Minnich
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
Copyright 2021 Andrew Minnich
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
Vendored
+143
@@ -0,0 +1,143 @@
|
||||
# Kira
|
||||
|
||||
#### [crates.io](https://crates.io/crates/kira) | [docs](https://docs.rs/kira/)
|
||||
|
||||
Kira is a backend-agnostic library to create expressive audio for games. It
|
||||
provides tweens for smoothly adjusting properties of sounds, a flexible mixer
|
||||
for applying effects to audio, a clock system for precisely timing audio events,
|
||||
and spatial audio support.
|
||||
|
||||
## Examples
|
||||
|
||||
Playing a sound multiple times simultaneously:
|
||||
|
||||
```rust
|
||||
use kira::{
|
||||
AudioManager, AudioManagerSettings, DefaultBackend,
|
||||
sound::static_sound::StaticSoundData,
|
||||
};
|
||||
|
||||
// Create an audio manager. This plays sounds and manages resources.
|
||||
let mut manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default())?;
|
||||
let sound_data = StaticSoundData::from_file("sound.ogg")?;
|
||||
manager.play(sound_data.clone())?;
|
||||
// After a couple seconds...
|
||||
manager.play(sound_data.clone())?;
|
||||
// Cloning the sound data will not use any extra memory.
|
||||
```
|
||||
|
||||
Gradually speeding up a sound over time:
|
||||
|
||||
```rust
|
||||
use std::time::Duration;
|
||||
|
||||
use kira::{
|
||||
AudioManager, AudioManagerSettings, DefaultBackend,
|
||||
sound::static_sound::StaticSoundData,
|
||||
Tween,
|
||||
};
|
||||
|
||||
let mut manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default())?;
|
||||
let sound_data = StaticSoundData::from_file("sound.ogg")?;
|
||||
let mut sound = manager.play(sound_data)?;
|
||||
// Start smoothly adjusting the playback rate parameter.
|
||||
sound.set_playback_rate(
|
||||
2.0,
|
||||
Tween {
|
||||
duration: Duration::from_secs(3),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
Playing a sound with a low-pass filter applied (this makes the audio sound
|
||||
muffled):
|
||||
|
||||
```rust
|
||||
use kira::{
|
||||
AudioManager, AudioManagerSettings, DefaultBackend,
|
||||
sound::static_sound::StaticSoundData,
|
||||
track::{
|
||||
TrackBuilder,
|
||||
effect::filter::FilterBuilder,
|
||||
},
|
||||
};
|
||||
|
||||
let mut manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default())?;
|
||||
// Create a mixer sub-track with a filter.
|
||||
let track = manager.add_sub_track({
|
||||
let mut builder = TrackBuilder::new();
|
||||
builder.add_effect(FilterBuilder::new().cutoff(1000.0));
|
||||
builder
|
||||
})?;
|
||||
// Play the sound on the track.
|
||||
let sound_data = StaticSoundData::from_file("sound.ogg")?.output_destination(&track);
|
||||
manager.play(sound_data)?;
|
||||
```
|
||||
|
||||
Playing sounds in time with a musical beat:
|
||||
|
||||
```rust
|
||||
use kira::{
|
||||
AudioManager, AudioManagerSettings, DefaultBackend,
|
||||
sound::static_sound::StaticSoundData,
|
||||
clock::ClockSpeed,
|
||||
};
|
||||
|
||||
const TEMPO: f64 = 120.0;
|
||||
|
||||
let mut manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default())?;
|
||||
// Create a clock that ticks 120 times per minute. In this case,
|
||||
// each tick is one musical beat. We can use a tick to represent any
|
||||
// arbitrary amount of time.
|
||||
let mut clock = manager.add_clock(ClockSpeed::TicksPerMinute(TEMPO))?;
|
||||
// Play a sound 2 ticks (beats) from now.
|
||||
let sound_data_1 = StaticSoundData::from_file("sound1.ogg")?
|
||||
.start_time(clock.time() + 2);
|
||||
manager.play(sound_data_1)?;
|
||||
// Play a different sound 4 ticks (beats) from now.
|
||||
let sound_data_2 = StaticSoundData::from_file("sound2.ogg")?
|
||||
.start_time(clock.time() + 4);
|
||||
manager.play(sound_data_2)?;
|
||||
// Start the clock.
|
||||
clock.start()?;
|
||||
```
|
||||
|
||||
## Platform support
|
||||
|
||||
Kira is mainly meant for desktop platforms. Most testing has occurred on
|
||||
Windows, but it has been used successfully on Mac and Linux.
|
||||
|
||||
Kira can also be used in wasm environments with the following limitations:
|
||||
|
||||
- Static sounds cannot be loaded from files
|
||||
- Streaming sounds are not supported because they make heavy use of threads
|
||||
|
||||
If you'd like to help improve wasm support, please reach out!
|
||||
|
||||
## Roadmap
|
||||
|
||||
Features I'd like to have:
|
||||
|
||||
- C API
|
||||
- Spatial audio features:
|
||||
- Doppler effect
|
||||
|
||||
## Contributing
|
||||
|
||||
I'd love for other people to get involved with development! Since the library is
|
||||
still in the early stages, I'm open to all kinds of input - bug reports, feature
|
||||
requests, design critiques, etc. Feel free to open an issue or pull request!
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under either of
|
||||
|
||||
- Apache License, Version 2.0 (LICENSE-APACHE)
|
||||
- MIT license (LICENSE-MIT)
|
||||
|
||||
at your option.
|
||||
|
||||
Unless you explicitly state otherwise, any contribution intentionally submitted
|
||||
for inclusion in `kira` by you, as defined in the Apache-2.0 license, shall be
|
||||
dual licensed as above, without any additional terms or conditions.
|
||||
Vendored
+40
@@ -0,0 +1,40 @@
|
||||
//! Communication between Kira and a low-level audio API.
|
||||
|
||||
#[cfg(feature = "cpal")]
|
||||
pub mod cpal;
|
||||
pub mod mock;
|
||||
mod renderer;
|
||||
pub(crate) mod resources;
|
||||
|
||||
pub use renderer::*;
|
||||
|
||||
#[cfg(feature = "cpal")]
|
||||
/// The default backend used by [`AudioManager`](crate::AudioManager)s.
|
||||
///
|
||||
/// If the `cpal` feature is enabled, this will be the cpal backend. Otherwise,
|
||||
/// it will be the mock backend.
|
||||
pub type DefaultBackend = cpal::CpalBackend;
|
||||
#[cfg(not(feature = "cpal"))]
|
||||
/// The default backend used by [`AudioManager`](crate::AudioManager)s.
|
||||
///
|
||||
/// If the `cpal` feature is enabled, this will be the cpal backend. Otherwise,
|
||||
/// it will be the mock backend.
|
||||
pub type DefaultBackend = mock::MockBackend;
|
||||
|
||||
/// Connects a [`Renderer`] to a lower level audio API.
|
||||
pub trait Backend: Sized {
|
||||
/// Settings for this backend.
|
||||
type Settings;
|
||||
|
||||
/// Errors that can occur when using this backend.
|
||||
type Error;
|
||||
|
||||
/// Starts the backend and returns itself and the initial sample rate.
|
||||
fn setup(
|
||||
settings: Self::Settings,
|
||||
internal_buffer_size: usize,
|
||||
) -> Result<(Self, u32), Self::Error>;
|
||||
|
||||
/// Sends the renderer to the backend to start audio playback.
|
||||
fn start(&mut self, renderer: Renderer) -> Result<(), Self::Error>;
|
||||
}
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
//! Plays audio using [cpal](https://crates.io/crates/cpal).
|
||||
|
||||
#![cfg_attr(docsrs, doc(cfg(feature = "cpal")))]
|
||||
|
||||
mod error;
|
||||
use cpal::{Device, StreamConfig};
|
||||
pub use error::*;
|
||||
|
||||
/// Settings for the cpal backend.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CpalBackendSettings {
|
||||
/// The output audio device to use. If [`None`], the default output
|
||||
/// device will be used.
|
||||
pub device: Option<Device>,
|
||||
/// A StreamConfig given by Cpal. If [`None`], the default supported
|
||||
/// config will be used. You can also get a supported config of your
|
||||
/// choosing using Cpal functions.
|
||||
pub config: Option<StreamConfig>,
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod wasm;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use wasm::CpalBackend;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod desktop;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use desktop::CpalBackend;
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
mod renderer_with_cpu_usage;
|
||||
mod stream_manager;
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use renderer_with_cpu_usage::RendererWithCpuUsage;
|
||||
use rtrb::Consumer;
|
||||
use stream_manager::{StreamManager, StreamManagerController};
|
||||
|
||||
use crate::backend::{Backend, Renderer};
|
||||
use cpal::{
|
||||
BufferSize, Device, StreamConfig, StreamError,
|
||||
traits::{DeviceTrait, HostTrait},
|
||||
};
|
||||
|
||||
use super::{CpalBackendSettings, Error};
|
||||
|
||||
enum State {
|
||||
Empty,
|
||||
Uninitialized {
|
||||
device: Device,
|
||||
config: StreamConfig,
|
||||
},
|
||||
Initialized {
|
||||
stream_manager_controller: StreamManagerController,
|
||||
},
|
||||
}
|
||||
|
||||
/// A backend that uses [cpal](https://crates.io/crates/cpal) to
|
||||
/// connect a [`Renderer`] to the operating system's audio driver.
|
||||
pub struct CpalBackend {
|
||||
state: State,
|
||||
/// Whether the device was specified by the user.
|
||||
custom_device: bool,
|
||||
buffer_size: BufferSize,
|
||||
cpu_usage_consumer: Option<Mutex<Consumer<f32>>>,
|
||||
}
|
||||
|
||||
impl CpalBackend {
|
||||
/**
|
||||
Returns the oldest reported CPU usage in the queue.
|
||||
|
||||
The formula for the CPU usage is time elapsed / time allotted, where
|
||||
- time elapsed is the amount of time it took to fill the audio buffer
|
||||
requested by the OS
|
||||
- time allotted is the maximum amount of time Kira could take to process
|
||||
audio and still finish in time to avoid audio stuttering (num frames / sample
|
||||
rate)
|
||||
*/
|
||||
#[cfg_attr(docsrs, doc(cfg(not(wasm32))))]
|
||||
pub fn pop_cpu_usage(&mut self) -> Option<f32> {
|
||||
self.cpu_usage_consumer
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.get_mut()
|
||||
.unwrap()
|
||||
.pop()
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Returns the oldest available stream error in the queue.
|
||||
#[cfg_attr(docsrs, doc(cfg(not(wasm32))))]
|
||||
pub fn pop_error(&mut self) -> Option<StreamError> {
|
||||
if let State::Initialized {
|
||||
stream_manager_controller,
|
||||
} = &mut self.state
|
||||
{
|
||||
stream_manager_controller.pop_handled_error()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Returns the number of unhandled stream errors discarded because of overcrowding.
|
||||
This increases either:
|
||||
|
||||
1. When `cpal` produces errors faster than they are polled by `kira`, or
|
||||
2. When `kira` produces errors faster than they are polled by [`Self::pop_error`].
|
||||
*/
|
||||
#[must_use]
|
||||
#[cfg_attr(docsrs, doc(cfg(not(wasm32))))]
|
||||
pub fn num_stream_errors_discarded(&self) -> Option<u64> {
|
||||
if let State::Initialized {
|
||||
stream_manager_controller,
|
||||
} = &self.state
|
||||
{
|
||||
Some(stream_manager_controller.num_stream_errors_discarded())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for CpalBackend {
|
||||
type Settings = CpalBackendSettings;
|
||||
|
||||
type Error = Error;
|
||||
|
||||
fn setup(
|
||||
settings: Self::Settings,
|
||||
_internal_buffer_size: usize,
|
||||
) -> Result<(Self, u32), Self::Error> {
|
||||
let host = cpal::default_host();
|
||||
|
||||
let (device, custom_device) = if let Some(device) = settings.device {
|
||||
(device, true)
|
||||
} else {
|
||||
(
|
||||
host.default_output_device()
|
||||
.ok_or(Error::NoDefaultOutputDevice)?,
|
||||
false,
|
||||
)
|
||||
};
|
||||
|
||||
let config = if let Some(config) = settings.config {
|
||||
config
|
||||
} else {
|
||||
device.default_output_config()?.config()
|
||||
};
|
||||
|
||||
let sample_rate = config.sample_rate;
|
||||
let buffer_size = config.buffer_size;
|
||||
|
||||
Ok((
|
||||
Self {
|
||||
state: State::Uninitialized { device, config },
|
||||
custom_device,
|
||||
buffer_size,
|
||||
cpu_usage_consumer: None,
|
||||
},
|
||||
sample_rate,
|
||||
))
|
||||
}
|
||||
|
||||
fn start(&mut self, renderer: Renderer) -> Result<(), Self::Error> {
|
||||
let state = std::mem::replace(&mut self.state, State::Empty);
|
||||
if let State::Uninitialized { device, config } = state {
|
||||
let (renderer, cpu_usage_consumer) = RendererWithCpuUsage::new(renderer);
|
||||
self.state = State::Initialized {
|
||||
stream_manager_controller: StreamManager::start(
|
||||
renderer,
|
||||
device,
|
||||
config,
|
||||
self.custom_device,
|
||||
self.buffer_size,
|
||||
)?,
|
||||
};
|
||||
self.cpu_usage_consumer = Some(Mutex::new(cpu_usage_consumer));
|
||||
} else {
|
||||
panic!("Cannot initialize the backend multiple times")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CpalBackend {
|
||||
fn drop(&mut self) {
|
||||
if let State::Initialized {
|
||||
stream_manager_controller,
|
||||
} = &self.state
|
||||
{
|
||||
stream_manager_controller.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use std::{
|
||||
ops::{Deref, DerefMut},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use rtrb::{Consumer, Producer, RingBuffer};
|
||||
|
||||
use crate::backend::Renderer;
|
||||
|
||||
const CPU_USAGE_RINGBUFFER_CAPACITY: usize = 100;
|
||||
|
||||
pub struct RendererWithCpuUsage {
|
||||
renderer: Renderer,
|
||||
cpu_usage_producer: Producer<f32>,
|
||||
}
|
||||
|
||||
impl RendererWithCpuUsage {
|
||||
pub fn new(renderer: Renderer) -> (Self, Consumer<f32>) {
|
||||
let (cpu_usage_producer, cpu_usage_consumer) =
|
||||
RingBuffer::new(CPU_USAGE_RINGBUFFER_CAPACITY);
|
||||
(
|
||||
Self {
|
||||
renderer,
|
||||
cpu_usage_producer,
|
||||
},
|
||||
cpu_usage_consumer,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn process(&mut self, out: &mut [f32], num_channels: u16, sample_rate: u32) {
|
||||
let allotted_time = out.len() as f32 / num_channels as f32 / sample_rate as f32;
|
||||
let start_time = Instant::now();
|
||||
self.renderer.process(out, num_channels);
|
||||
let end_time = Instant::now();
|
||||
let process_duration = end_time - start_time;
|
||||
let cpu_usage = process_duration.as_secs_f32() / allotted_time;
|
||||
self.cpu_usage_producer.push(cpu_usage).ok();
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for RendererWithCpuUsage {
|
||||
type Target = Renderer;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.renderer
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for RendererWithCpuUsage {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.renderer
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
mod send_on_drop;
|
||||
|
||||
use std::{
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use super::renderer_with_cpu_usage::RendererWithCpuUsage;
|
||||
use cpal::{
|
||||
BufferSize, Device, Stream, StreamConfig, StreamError,
|
||||
traits::{DeviceTrait, HostTrait, StreamTrait},
|
||||
};
|
||||
use rtrb::{Consumer, Producer, PushError, RingBuffer};
|
||||
use send_on_drop::SendOnDrop;
|
||||
|
||||
use super::super::Error;
|
||||
|
||||
const CHECK_STREAM_INTERVAL: Duration = Duration::from_millis(500);
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum State {
|
||||
Empty,
|
||||
Idle {
|
||||
renderer: RendererWithCpuUsage,
|
||||
},
|
||||
Running {
|
||||
stream: Stream,
|
||||
renderer_consumer: Consumer<RendererWithCpuUsage>,
|
||||
},
|
||||
}
|
||||
|
||||
pub(super) struct StreamManagerController {
|
||||
should_drop: Arc<AtomicBool>,
|
||||
num_stream_errors_discarded: Arc<AtomicU64>,
|
||||
handled_stream_error_consumer: Mutex<Consumer<StreamError>>,
|
||||
}
|
||||
|
||||
impl StreamManagerController {
|
||||
pub fn stop(&self) {
|
||||
self.should_drop.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn num_stream_errors_discarded(&self) -> u64 {
|
||||
self.num_stream_errors_discarded.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub fn pop_handled_error(&mut self) -> Option<StreamError> {
|
||||
self.handled_stream_error_consumer
|
||||
.get_mut()
|
||||
.unwrap()
|
||||
.pop()
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a cpal stream and restarts it if needed
|
||||
/// in the case of device changes or disconnections.
|
||||
pub(super) struct StreamManager {
|
||||
state: State,
|
||||
device_id: String,
|
||||
sample_rate: u32,
|
||||
custom_device: bool,
|
||||
buffer_size: BufferSize,
|
||||
}
|
||||
|
||||
impl StreamManager {
|
||||
pub fn start(
|
||||
renderer: RendererWithCpuUsage,
|
||||
device: Device,
|
||||
mut config: StreamConfig,
|
||||
custom_device: bool,
|
||||
buffer_size: BufferSize,
|
||||
) -> Result<StreamManagerController, Error> {
|
||||
let should_drop = Arc::new(AtomicBool::new(false));
|
||||
let should_drop_clone = should_drop.clone();
|
||||
|
||||
let num_stream_errors_discarded = Arc::new(AtomicU64::new(0));
|
||||
let num_stream_errors_discarded_clone = num_stream_errors_discarded.clone();
|
||||
|
||||
let (mut handled_stream_error_producer, handled_stream_error_consumer) =
|
||||
RingBuffer::new(64);
|
||||
|
||||
let (mut initial_result_producer, mut initial_result_consumer) = RingBuffer::new(1);
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut stream_manager = StreamManager {
|
||||
state: State::Idle { renderer },
|
||||
device_id: device_id(&device),
|
||||
sample_rate: config.sample_rate,
|
||||
custom_device,
|
||||
buffer_size,
|
||||
};
|
||||
let mut unhandled_stream_error_consumer = match stream_manager.start_stream(
|
||||
&device,
|
||||
&mut config,
|
||||
num_stream_errors_discarded.clone(),
|
||||
) {
|
||||
Ok(unhandled_stream_error_consumer) => {
|
||||
initial_result_producer.push(Ok(())).unwrap();
|
||||
unhandled_stream_error_consumer
|
||||
}
|
||||
Err(err) => {
|
||||
initial_result_producer.push(Err(err)).unwrap();
|
||||
return;
|
||||
}
|
||||
};
|
||||
loop {
|
||||
std::thread::sleep(CHECK_STREAM_INTERVAL);
|
||||
if should_drop.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
stream_manager.check_stream(
|
||||
&mut unhandled_stream_error_consumer,
|
||||
&mut handled_stream_error_producer,
|
||||
&num_stream_errors_discarded,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
loop {
|
||||
if let Ok(result) = initial_result_consumer.pop() {
|
||||
result?;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_micros(100));
|
||||
}
|
||||
|
||||
Ok(StreamManagerController {
|
||||
should_drop: should_drop_clone,
|
||||
num_stream_errors_discarded: num_stream_errors_discarded_clone,
|
||||
handled_stream_error_consumer: Mutex::new(handled_stream_error_consumer),
|
||||
})
|
||||
}
|
||||
|
||||
/// Restarts the stream if the audio device gets disconnected.
|
||||
fn check_stream(
|
||||
&mut self,
|
||||
unhandled_stream_error_consumer: &mut Consumer<StreamError>,
|
||||
handled_stream_error_producer: &mut Producer<StreamError>,
|
||||
num_stream_errors_discarded: &Arc<AtomicU64>,
|
||||
) {
|
||||
if let State::Running { .. } = &self.state {
|
||||
while let Ok(error) = unhandled_stream_error_consumer.pop() {
|
||||
match error {
|
||||
// check for device disconnection
|
||||
StreamError::DeviceNotAvailable | StreamError::StreamInvalidated => {
|
||||
self.stop_stream();
|
||||
if let Ok((device, mut config)) = default_device_and_config() {
|
||||
// TODO: gracefully handle errors that occur in this function
|
||||
*unhandled_stream_error_consumer = self
|
||||
.start_stream(
|
||||
&device,
|
||||
&mut config,
|
||||
num_stream_errors_discarded.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
StreamError::BackendSpecific { err: _ } | StreamError::BufferUnderrun => {}
|
||||
}
|
||||
match handled_stream_error_producer.push(error) {
|
||||
Ok(()) => {}
|
||||
Err(PushError::Full(_stream_error)) => {
|
||||
num_stream_errors_discarded.fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
// check for device changes if a custom device hasn't been specified
|
||||
// Disabled on macos due to audio artifacts that seem to occur when the device is
|
||||
// queried while playing.
|
||||
// see: https://github.com/tesselode/kira/issues/38
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
if !self.custom_device {
|
||||
if let Ok((device, mut config)) = default_device_and_config() {
|
||||
let device_id = device_id(&device);
|
||||
let sample_rate = config.sample_rate;
|
||||
if device_id != self.device_id || sample_rate != self.sample_rate {
|
||||
self.stop_stream();
|
||||
*unhandled_stream_error_consumer = self
|
||||
.start_stream(&device, &mut config, num_stream_errors_discarded.clone())
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_stream(
|
||||
&mut self,
|
||||
device: &Device,
|
||||
config: &mut StreamConfig,
|
||||
num_stream_errors_discarded: Arc<AtomicU64>,
|
||||
) -> Result<Consumer<StreamError>, Error> {
|
||||
let mut renderer =
|
||||
if let State::Idle { renderer } = std::mem::replace(&mut self.state, State::Empty) {
|
||||
renderer
|
||||
} else {
|
||||
panic!("trying to start a stream when the stream manager is not idle");
|
||||
};
|
||||
config.buffer_size = self.buffer_size; // this won't change anything if the buffer size is BufferSize::Default
|
||||
let device_id = device_id(device);
|
||||
let sample_rate = config.sample_rate;
|
||||
if sample_rate != self.sample_rate {
|
||||
renderer.on_change_sample_rate(sample_rate);
|
||||
}
|
||||
self.device_id = device_id;
|
||||
self.sample_rate = sample_rate;
|
||||
let (mut renderer_wrapper, renderer_consumer) = SendOnDrop::new(renderer);
|
||||
let (mut unhandled_stream_error_producer, unhandled_stream_error_consumer) =
|
||||
RingBuffer::new(64);
|
||||
let channels = config.channels;
|
||||
let stream = device.build_output_stream(
|
||||
config,
|
||||
move |data: &mut [f32], _| {
|
||||
#[cfg(feature = "assert_no_alloc")]
|
||||
assert_no_alloc::assert_no_alloc(|| {
|
||||
process_renderer(&mut renderer_wrapper, data, channels, sample_rate);
|
||||
});
|
||||
#[cfg(not(feature = "assert_no_alloc"))]
|
||||
process_renderer(&mut renderer_wrapper, data, channels, sample_rate);
|
||||
},
|
||||
move |error| match unhandled_stream_error_producer.push(error) {
|
||||
Ok(()) => {}
|
||||
Err(PushError::Full(_stream_error)) => {
|
||||
num_stream_errors_discarded.fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
},
|
||||
None,
|
||||
)?;
|
||||
stream.play()?;
|
||||
self.state = State::Running {
|
||||
stream,
|
||||
renderer_consumer,
|
||||
};
|
||||
Ok(unhandled_stream_error_consumer)
|
||||
}
|
||||
|
||||
fn stop_stream(&mut self) {
|
||||
if let State::Running {
|
||||
mut renderer_consumer,
|
||||
stream,
|
||||
..
|
||||
} = std::mem::replace(&mut self.state, State::Empty)
|
||||
{
|
||||
drop(stream);
|
||||
let renderer = renderer_consumer
|
||||
.pop()
|
||||
.expect("Could not retrieve the renderer after dropping a stream");
|
||||
self.state = State::Idle { renderer };
|
||||
} else {
|
||||
panic!("Trying to stop the stream when it's not running")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_device_and_config() -> Result<(Device, StreamConfig), Error> {
|
||||
let host = cpal::default_host();
|
||||
let device = host
|
||||
.default_output_device()
|
||||
.ok_or(Error::NoDefaultOutputDevice)?;
|
||||
let config = device.default_output_config()?.config();
|
||||
Ok((device, config))
|
||||
}
|
||||
|
||||
fn device_id(device: &Device) -> String {
|
||||
match device.id() {
|
||||
Ok(id) => format!("{}", id),
|
||||
Err(_) => "device id unavailable".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn process_renderer(
|
||||
renderer: &mut SendOnDrop<RendererWithCpuUsage>,
|
||||
data: &mut [f32],
|
||||
channels: u16,
|
||||
sample_rate: u32,
|
||||
) {
|
||||
renderer.on_start_processing();
|
||||
renderer.process(data, channels, sample_rate);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use std::ops::{Deref, DerefMut};
|
||||
|
||||
use rtrb::{Consumer, Producer, RingBuffer};
|
||||
|
||||
/// Wraps `T` so that when it's dropped, it gets sent
|
||||
/// back through a thread channel.
|
||||
///
|
||||
/// This allows us to retrieve the data after a closure
|
||||
/// that takes ownership of the data is dropped because of,
|
||||
/// for instance, a cpal error.
|
||||
pub struct SendOnDrop<T> {
|
||||
data: Option<T>,
|
||||
producer: Producer<T>,
|
||||
}
|
||||
|
||||
impl<T> SendOnDrop<T> {
|
||||
pub fn new(data: T) -> (Self, Consumer<T>) {
|
||||
let (producer, consumer) = RingBuffer::new(1);
|
||||
(
|
||||
Self {
|
||||
data: Some(data),
|
||||
producer,
|
||||
},
|
||||
consumer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Deref for SendOnDrop<T> {
|
||||
type Target = T;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.data.as_ref().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> DerefMut for SendOnDrop<T> {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
self.data.as_mut().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Drop for SendOnDrop<T> {
|
||||
fn drop(&mut self) {
|
||||
self.producer
|
||||
.push(self.data.take().unwrap())
|
||||
.expect("send on drop producer full");
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
use cpal::{BuildStreamError, DefaultStreamConfigError, PlayStreamError};
|
||||
|
||||
/// Errors that can occur when using the cpal backend.
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
/// A default audio output device could not be determined.
|
||||
NoDefaultOutputDevice,
|
||||
/// An error occurred when getting the default output configuration.
|
||||
DefaultStreamConfigError(DefaultStreamConfigError),
|
||||
/// An error occurred when building the audio stream.
|
||||
BuildStreamError(BuildStreamError),
|
||||
/// An error occurred when starting the audio stream.
|
||||
PlayStreamError(PlayStreamError),
|
||||
}
|
||||
|
||||
impl Display for Error {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Error::NoDefaultOutputDevice => {
|
||||
f.write_str("Cannot find the default audio output device")
|
||||
}
|
||||
Error::DefaultStreamConfigError(error) => error.fmt(f),
|
||||
Error::BuildStreamError(error) => error.fmt(f),
|
||||
Error::PlayStreamError(error) => error.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Error::DefaultStreamConfigError(error) => Some(error),
|
||||
Error::BuildStreamError(error) => Some(error),
|
||||
Error::PlayStreamError(error) => Some(error),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DefaultStreamConfigError> for Error {
|
||||
fn from(v: DefaultStreamConfigError) -> Self {
|
||||
Self::DefaultStreamConfigError(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BuildStreamError> for Error {
|
||||
fn from(v: BuildStreamError) -> Self {
|
||||
Self::BuildStreamError(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PlayStreamError> for Error {
|
||||
fn from(v: PlayStreamError) -> Self {
|
||||
Self::PlayStreamError(v)
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
use crate::backend::{Backend, Renderer};
|
||||
use cpal::{
|
||||
Device, Stream, StreamConfig,
|
||||
traits::{DeviceTrait, HostTrait, StreamTrait},
|
||||
};
|
||||
use send_wrapper::SendWrapper;
|
||||
|
||||
use super::{CpalBackendSettings, Error};
|
||||
|
||||
enum State {
|
||||
Empty,
|
||||
Uninitialized {
|
||||
device: Device,
|
||||
config: StreamConfig,
|
||||
},
|
||||
Initialized {
|
||||
_stream: Stream,
|
||||
},
|
||||
}
|
||||
|
||||
/// A backend that uses [cpal](https://crates.io/crates/cpal) to
|
||||
/// connect a [`Renderer`] to the operating system's audio driver.
|
||||
pub struct CpalBackend {
|
||||
state: SendWrapper<State>,
|
||||
}
|
||||
|
||||
impl Backend for CpalBackend {
|
||||
type Settings = CpalBackendSettings;
|
||||
|
||||
type Error = Error;
|
||||
|
||||
fn setup(
|
||||
settings: Self::Settings,
|
||||
_internal_buffer_size: usize,
|
||||
) -> Result<(Self, u32), Self::Error> {
|
||||
let host = cpal::default_host();
|
||||
let device = if let Some(device) = settings.device {
|
||||
device
|
||||
} else {
|
||||
host.default_output_device()
|
||||
.ok_or(Error::NoDefaultOutputDevice)?
|
||||
};
|
||||
let config = if let Some(config) = settings.config {
|
||||
config
|
||||
} else {
|
||||
device.default_output_config()?.config()
|
||||
};
|
||||
let sample_rate = config.sample_rate;
|
||||
Ok((
|
||||
Self {
|
||||
state: SendWrapper::new(State::Uninitialized { device, config }),
|
||||
},
|
||||
sample_rate,
|
||||
))
|
||||
}
|
||||
|
||||
fn start(&mut self, mut renderer: Renderer) -> Result<(), Self::Error> {
|
||||
if let State::Uninitialized { device, config } =
|
||||
std::mem::replace(&mut *self.state, State::Empty)
|
||||
{
|
||||
let channels = config.channels;
|
||||
let stream = device.build_output_stream(
|
||||
&config,
|
||||
move |data: &mut [f32], _| {
|
||||
renderer.on_start_processing();
|
||||
renderer.process(data, channels);
|
||||
},
|
||||
move |_| {},
|
||||
None,
|
||||
)?;
|
||||
stream.play()?;
|
||||
self.state = SendWrapper::new(State::Initialized { _stream: stream });
|
||||
} else {
|
||||
panic!("Cannot initialize the backend multiple times")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Vendored
+99
@@ -0,0 +1,99 @@
|
||||
//! Useful for testing and benchmarking.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use super::{Backend, Renderer};
|
||||
|
||||
enum State {
|
||||
Uninitialized,
|
||||
Initialized { renderer: Mutex<Renderer> },
|
||||
}
|
||||
|
||||
/// Settings for the mock backend.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct MockBackendSettings {
|
||||
/// The sample rate that the [`Renderer`] should run at.
|
||||
pub sample_rate: u32,
|
||||
}
|
||||
|
||||
impl Default for MockBackendSettings {
|
||||
fn default() -> Self {
|
||||
Self { sample_rate: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
/// A backend that does not connect to any lower-level
|
||||
/// audio APIs, but allows manually calling
|
||||
/// [`Renderer::on_start_processing`] and [`Renderer::process`].
|
||||
///
|
||||
/// This is useful for testing and benchmarking.
|
||||
pub struct MockBackend {
|
||||
sample_rate: u32,
|
||||
state: State,
|
||||
frames: Vec<f32>,
|
||||
}
|
||||
|
||||
impl MockBackend {
|
||||
/// Changes the sample rate of the [`Renderer`].
|
||||
pub fn set_sample_rate(&mut self, sample_rate: u32) {
|
||||
self.sample_rate = sample_rate;
|
||||
if let State::Initialized { renderer } = &mut self.state {
|
||||
renderer
|
||||
.get_mut()
|
||||
.expect("mutex poisoned")
|
||||
.on_change_sample_rate(sample_rate);
|
||||
}
|
||||
}
|
||||
|
||||
/// Calls the [`on_start_processing`](Renderer::on_start_processing)
|
||||
/// callback of the [`Renderer`].
|
||||
pub fn on_start_processing(&mut self) {
|
||||
if let State::Initialized { renderer } = &mut self.state {
|
||||
renderer
|
||||
.get_mut()
|
||||
.expect("mutex poisoned")
|
||||
.on_start_processing();
|
||||
} else {
|
||||
panic!("backend is not initialized")
|
||||
}
|
||||
}
|
||||
|
||||
/// Calls the [`process`](Renderer::process) callback of the [`Renderer`].
|
||||
pub fn process(&mut self) {
|
||||
if let State::Initialized { renderer } = &mut self.state {
|
||||
renderer
|
||||
.get_mut()
|
||||
.expect("mutex poisoned")
|
||||
.process(&mut self.frames, 2)
|
||||
} else {
|
||||
panic!("backend is not initialized")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for MockBackend {
|
||||
type Settings = MockBackendSettings;
|
||||
|
||||
type Error = ();
|
||||
|
||||
fn setup(
|
||||
settings: Self::Settings,
|
||||
internal_buffer_size: usize,
|
||||
) -> Result<(Self, u32), Self::Error> {
|
||||
Ok((
|
||||
Self {
|
||||
sample_rate: settings.sample_rate,
|
||||
state: State::Uninitialized,
|
||||
frames: vec![0.0; internal_buffer_size * 2],
|
||||
},
|
||||
settings.sample_rate,
|
||||
))
|
||||
}
|
||||
|
||||
fn start(&mut self, renderer: Renderer) -> Result<(), Self::Error> {
|
||||
self.state = State::Initialized {
|
||||
renderer: Mutex::new(renderer),
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU32, Ordering},
|
||||
};
|
||||
|
||||
use crate::Frame;
|
||||
|
||||
use super::resources::Resources;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct RendererShared {
|
||||
pub(crate) sample_rate: AtomicU32,
|
||||
}
|
||||
|
||||
impl RendererShared {
|
||||
#[must_use]
|
||||
pub fn new(sample_rate: u32) -> Self {
|
||||
Self {
|
||||
sample_rate: AtomicU32::new(sample_rate),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Produces [`Frame`]s of audio data to be consumed by a
|
||||
/// low-level audio API.
|
||||
///
|
||||
/// You will probably not need to interact with [`Renderer`]s
|
||||
/// directly unless you're writing a [`Backend`](super::Backend).
|
||||
pub struct Renderer {
|
||||
dt: f64,
|
||||
shared: Arc<RendererShared>,
|
||||
resources: Resources,
|
||||
internal_buffer_size: usize,
|
||||
temp_buffer: Vec<Frame>,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
shared: Arc<RendererShared>,
|
||||
internal_buffer_size: usize,
|
||||
resources: Resources,
|
||||
) -> Self {
|
||||
Self {
|
||||
dt: 1.0 / shared.sample_rate.load(Ordering::SeqCst) as f64,
|
||||
shared,
|
||||
resources,
|
||||
internal_buffer_size,
|
||||
temp_buffer: vec![Frame::ZERO; internal_buffer_size],
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by the backend when the sample rate of the
|
||||
/// audio output changes.
|
||||
pub fn on_change_sample_rate(&mut self, sample_rate: u32) {
|
||||
self.dt = 1.0 / sample_rate as f64;
|
||||
self.shared.sample_rate.store(sample_rate, Ordering::SeqCst);
|
||||
self.resources.mixer.on_change_sample_rate(sample_rate);
|
||||
}
|
||||
|
||||
/// Called by the backend when it's time to process
|
||||
/// a new batch of samples.
|
||||
pub fn on_start_processing(&mut self) {
|
||||
self.resources.mixer.on_start_processing();
|
||||
self.resources.clocks.on_start_processing();
|
||||
self.resources.listeners.on_start_processing();
|
||||
self.resources.modulators.on_start_processing();
|
||||
}
|
||||
|
||||
/// Produces the next [`Frame`]s of audio.
|
||||
pub fn process(&mut self, out: &mut [f32], num_channels: u16) {
|
||||
for chunk in out.chunks_mut(self.internal_buffer_size * num_channels as usize) {
|
||||
self.process_chunk(chunk, num_channels);
|
||||
}
|
||||
}
|
||||
|
||||
fn process_chunk(&mut self, chunk: &mut [f32], num_channels: u16) {
|
||||
let num_frames = chunk.len() / num_channels as usize;
|
||||
|
||||
self.resources.modulators.process(
|
||||
self.dt * num_frames as f64,
|
||||
&self.resources.clocks,
|
||||
&self.resources.listeners,
|
||||
);
|
||||
self.resources.clocks.update(
|
||||
self.dt * num_frames as f64,
|
||||
&self.resources.modulators,
|
||||
&self.resources.listeners,
|
||||
);
|
||||
self.resources.listeners.update(
|
||||
self.dt * num_frames as f64,
|
||||
&self.resources.clocks,
|
||||
&self.resources.modulators,
|
||||
);
|
||||
|
||||
self.resources.mixer.process(
|
||||
&mut self.temp_buffer[..num_frames],
|
||||
self.dt,
|
||||
&self.resources.clocks,
|
||||
&self.resources.modulators,
|
||||
&self.resources.listeners,
|
||||
);
|
||||
|
||||
// convert from frames to requested number of channels
|
||||
for (i, channels) in chunk.chunks_mut(num_channels.into()).enumerate() {
|
||||
let mut frame = self.temp_buffer[i];
|
||||
frame.left = frame.left.clamp(-1.0, 1.0);
|
||||
frame.right = frame.right.clamp(-1.0, 1.0);
|
||||
if num_channels == 1 {
|
||||
channels[0] = (frame.left + frame.right) / 2.0;
|
||||
} else {
|
||||
channels[0] = frame.left;
|
||||
channels[1] = frame.right;
|
||||
/*
|
||||
if there's more channels, send silence to them. if we don't,
|
||||
we might get bad sounds outputted to those channels.
|
||||
(https://github.com/tesselode/kira/issues/50)
|
||||
*/
|
||||
for channel in channels.iter_mut().skip(2) {
|
||||
*channel = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.temp_buffer.fill(Frame::ZERO);
|
||||
}
|
||||
}
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
pub(crate) mod clocks;
|
||||
pub(crate) mod listeners;
|
||||
pub(crate) mod mixer;
|
||||
pub(crate) mod modulators;
|
||||
|
||||
#[cfg(test)]
|
||||
mod test;
|
||||
|
||||
use std::{
|
||||
fmt::{Debug, Formatter},
|
||||
sync::Mutex,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ResourceLimitReached,
|
||||
listener::Listener,
|
||||
track::{MainTrackBuilder, MainTrackHandle, SendTrack, Track},
|
||||
};
|
||||
use atomic_arena::{Arena, Controller, Key};
|
||||
use listeners::Listeners;
|
||||
use rtrb::{Consumer, Producer, RingBuffer};
|
||||
|
||||
use crate::{clock::Clock, manager::Capacities, modulator::Modulator};
|
||||
|
||||
use self::{clocks::Clocks, mixer::Mixer, modulators::Modulators};
|
||||
|
||||
pub(crate) struct ResourceStorage<T> {
|
||||
pub(crate) resources: Arena<T>,
|
||||
new_resource_consumer: Consumer<(Key, T)>,
|
||||
unused_resource_producer: Producer<T>,
|
||||
}
|
||||
|
||||
impl<T> ResourceStorage<T> {
|
||||
#[must_use]
|
||||
pub fn new(capacity: usize) -> (Self, ResourceController<T>) {
|
||||
let (new_resource_producer, new_resource_consumer) = RingBuffer::new(capacity);
|
||||
let (unused_resource_producer, unused_resource_consumer) = RingBuffer::new(capacity);
|
||||
let resources = Arena::new(capacity);
|
||||
let arena_controller = resources.controller();
|
||||
(
|
||||
Self {
|
||||
resources,
|
||||
new_resource_consumer,
|
||||
unused_resource_producer,
|
||||
},
|
||||
ResourceController {
|
||||
arena_controller,
|
||||
new_resource_producer: Mutex::new(new_resource_producer),
|
||||
unused_resource_consumer: Mutex::new(unused_resource_consumer),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn remove_and_add(&mut self, remove_test: impl FnMut(&T) -> bool) {
|
||||
for (_, resource) in self.resources.drain_filter(remove_test) {
|
||||
self.unused_resource_producer
|
||||
.push(resource)
|
||||
.unwrap_or_else(|_| panic!("unused resource producer is full"));
|
||||
}
|
||||
while let Ok((key, resource)) = self.new_resource_consumer.pop() {
|
||||
self.resources
|
||||
.insert_with_key(key, resource)
|
||||
.expect("error inserting resource");
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_mut(&mut self, key: Key) -> Option<&mut T> {
|
||||
self.resources.get_mut(key)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn iter(&self) -> atomic_arena::iter::Iter<'_, T> {
|
||||
self.resources.iter()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn iter_mut(&mut self) -> atomic_arena::iter::IterMut<'_, T> {
|
||||
self.resources.iter_mut()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.resources.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T> IntoIterator for &'a mut ResourceStorage<T> {
|
||||
type Item = (Key, &'a mut T);
|
||||
|
||||
type IntoIter = atomic_arena::iter::IterMut<'a, T>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.iter_mut()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct SelfReferentialResourceStorage<T> {
|
||||
pub(crate) resources: Arena<T>,
|
||||
keys: Vec<Key>,
|
||||
new_resource_consumer: Consumer<(Key, T)>,
|
||||
unused_resource_producer: Producer<T>,
|
||||
dummy: T,
|
||||
}
|
||||
|
||||
impl<T> SelfReferentialResourceStorage<T> {
|
||||
pub fn new(capacity: usize) -> (Self, ResourceController<T>)
|
||||
where
|
||||
T: Default,
|
||||
{
|
||||
let (new_resource_producer, new_resource_consumer) = RingBuffer::new(capacity);
|
||||
let (unused_resource_producer, unused_resource_consumer) = RingBuffer::new(capacity);
|
||||
let resources = Arena::new(capacity);
|
||||
let arena_controller = resources.controller();
|
||||
(
|
||||
Self {
|
||||
resources,
|
||||
keys: Vec::with_capacity(capacity),
|
||||
new_resource_consumer,
|
||||
unused_resource_producer,
|
||||
dummy: T::default(),
|
||||
},
|
||||
ResourceController {
|
||||
arena_controller,
|
||||
new_resource_producer: Mutex::new(new_resource_producer),
|
||||
unused_resource_consumer: Mutex::new(unused_resource_consumer),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn remove_and_add(&mut self, remove_test: impl FnMut(&T) -> bool) {
|
||||
self.remove_unused(remove_test);
|
||||
while let Ok((key, resource)) = self.new_resource_consumer.pop() {
|
||||
self.resources
|
||||
.insert_with_key(key, resource)
|
||||
.expect("error inserting resource");
|
||||
self.keys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[must_use]
|
||||
pub fn get_mut(&mut self, key: Key) -> Option<&mut T> {
|
||||
self.resources.get_mut(key)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn iter_mut(&mut self) -> atomic_arena::iter::IterMut<'_, T> {
|
||||
self.resources.iter_mut()
|
||||
}
|
||||
|
||||
pub fn for_each(&mut self, mut f: impl FnMut(&mut T, &mut Arena<T>)) {
|
||||
for key in &self.keys {
|
||||
std::mem::swap(&mut self.resources[*key], &mut self.dummy);
|
||||
f(&mut self.dummy, &mut self.resources);
|
||||
std::mem::swap(&mut self.resources[*key], &mut self.dummy);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_unused(&mut self, mut remove_test: impl FnMut(&T) -> bool) {
|
||||
let mut i = 0;
|
||||
while i < self.keys.len() && !self.unused_resource_producer.is_full() {
|
||||
let key = self.keys[i];
|
||||
let resource = &mut self.resources[key];
|
||||
if remove_test(resource) {
|
||||
let resource = self.resources.remove(key).unwrap();
|
||||
self.unused_resource_producer
|
||||
.push(resource)
|
||||
.unwrap_or_else(|_| panic!("unused resource producer is full"));
|
||||
self.keys.remove(i);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T> IntoIterator for &'a mut SelfReferentialResourceStorage<T> {
|
||||
type Item = (Key, &'a mut T);
|
||||
|
||||
type IntoIter = atomic_arena::iter::IterMut<'a, T>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.iter_mut()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ResourceController<T> {
|
||||
pub arena_controller: Controller,
|
||||
pub new_resource_producer: Mutex<Producer<(Key, T)>>,
|
||||
pub unused_resource_consumer: Mutex<Consumer<T>>,
|
||||
}
|
||||
|
||||
impl<T> ResourceController<T> {
|
||||
pub fn insert(&mut self, resource: T) -> Result<Key, ResourceLimitReached> {
|
||||
let key = self.try_reserve()?;
|
||||
self.insert_with_key(key, resource);
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
pub fn try_reserve(&self) -> Result<Key, ResourceLimitReached> {
|
||||
self.arena_controller
|
||||
.try_reserve()
|
||||
.map_err(|_| ResourceLimitReached)
|
||||
}
|
||||
|
||||
pub fn insert_with_key(&mut self, key: Key, resource: T) {
|
||||
self.remove_unused();
|
||||
self.new_resource_producer
|
||||
.get_mut()
|
||||
.expect("new resource producer mutex poisoned")
|
||||
.push((key, resource))
|
||||
.unwrap_or_else(|_| panic!("new resource producer full"));
|
||||
}
|
||||
|
||||
fn remove_unused(&mut self) {
|
||||
let unused_resource_consumer = &mut self
|
||||
.unused_resource_consumer
|
||||
.get_mut()
|
||||
.expect("unused resource consumer mutex poisoned");
|
||||
while unused_resource_consumer.pop().is_ok() {}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn capacity(&self) -> usize {
|
||||
self.arena_controller.capacity()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.arena_controller.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Debug for ResourceController<T> {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ResourceController")
|
||||
.field("arena_controller", &self.arena_controller)
|
||||
.field("new_resource_producer", &ProducerDebug)
|
||||
.field("unused_resource_consumer", &ConsumerDebug)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
struct ProducerDebug;
|
||||
|
||||
impl Debug for ProducerDebug {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Producer").finish()
|
||||
}
|
||||
}
|
||||
|
||||
struct ConsumerDebug;
|
||||
|
||||
impl Debug for ConsumerDebug {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Consumer").finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Resources {
|
||||
pub mixer: Mixer,
|
||||
pub clocks: Clocks,
|
||||
pub modulators: Modulators,
|
||||
pub listeners: Listeners,
|
||||
}
|
||||
|
||||
pub(crate) struct ResourceControllers {
|
||||
pub sub_track_controller: ResourceController<Track>,
|
||||
pub send_track_controller: ResourceController<SendTrack>,
|
||||
pub clock_controller: ResourceController<Clock>,
|
||||
pub modulator_controller: ResourceController<Box<dyn Modulator>>,
|
||||
pub listener_controller: ResourceController<Listener>,
|
||||
pub main_track_handle: MainTrackHandle,
|
||||
}
|
||||
|
||||
pub(crate) fn create_resources(
|
||||
capacities: Capacities,
|
||||
main_track_builder: MainTrackBuilder,
|
||||
sample_rate: u32,
|
||||
internal_buffer_size: usize,
|
||||
) -> (Resources, ResourceControllers) {
|
||||
let (mixer, sub_track_controller, send_track_controller, main_track_handle) = Mixer::new(
|
||||
capacities.sub_track_capacity,
|
||||
capacities.send_track_capacity,
|
||||
sample_rate,
|
||||
internal_buffer_size,
|
||||
main_track_builder,
|
||||
);
|
||||
let (clocks, clock_controller) = Clocks::new(capacities.clock_capacity);
|
||||
let (modulators, modulator_controller) = Modulators::new(capacities.modulator_capacity);
|
||||
let (listeners, listener_controller) = Listeners::new(capacities.listener_capacity);
|
||||
(
|
||||
Resources {
|
||||
mixer,
|
||||
clocks,
|
||||
modulators,
|
||||
listeners,
|
||||
},
|
||||
ResourceControllers {
|
||||
sub_track_controller,
|
||||
send_track_controller,
|
||||
clock_controller,
|
||||
modulator_controller,
|
||||
listener_controller,
|
||||
main_track_handle,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use crate::{clock::Clock, info::Info};
|
||||
|
||||
use super::{
|
||||
ResourceController, SelfReferentialResourceStorage, listeners::Listeners,
|
||||
modulators::Modulators,
|
||||
};
|
||||
|
||||
pub(crate) struct Clocks(pub(crate) SelfReferentialResourceStorage<Clock>);
|
||||
|
||||
impl Clocks {
|
||||
#[must_use]
|
||||
pub(crate) fn new(capacity: usize) -> (Self, ResourceController<Clock>) {
|
||||
let (storage, controller) = SelfReferentialResourceStorage::new(capacity);
|
||||
(Self(storage), controller)
|
||||
}
|
||||
|
||||
pub(crate) fn on_start_processing(&mut self) {
|
||||
self.0
|
||||
.remove_and_add(|clock| clock.shared().is_marked_for_removal());
|
||||
for (_, clock) in &mut self.0 {
|
||||
clock.on_start_processing();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn update(&mut self, dt: f64, modulators: &Modulators, listeners: &Listeners) {
|
||||
self.0.for_each(|clock, others| {
|
||||
clock.update(
|
||||
dt,
|
||||
&Info::new(
|
||||
others,
|
||||
&modulators.0.resources,
|
||||
&listeners.0.resources,
|
||||
None,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
use crate::{info::Info, listener::Listener};
|
||||
|
||||
use super::{
|
||||
ResourceController, SelfReferentialResourceStorage, clocks::Clocks, modulators::Modulators,
|
||||
};
|
||||
|
||||
pub(crate) struct Listeners(pub(crate) SelfReferentialResourceStorage<Listener>);
|
||||
|
||||
impl Listeners {
|
||||
#[must_use]
|
||||
pub(crate) fn new(capacity: usize) -> (Self, ResourceController<Listener>) {
|
||||
let (storage, controller) = SelfReferentialResourceStorage::new(capacity);
|
||||
(Self(storage), controller)
|
||||
}
|
||||
|
||||
pub(crate) fn on_start_processing(&mut self) {
|
||||
self.0
|
||||
.remove_and_add(|listener| listener.shared.is_marked_for_removal());
|
||||
for (_, listener) in &mut self.0 {
|
||||
listener.on_start_processing();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn update(&mut self, dt: f64, clocks: &Clocks, modulators: &Modulators) {
|
||||
self.0.for_each(|listener, others| {
|
||||
listener.update(
|
||||
dt,
|
||||
&Info::new(&clocks.0.resources, &modulators.0.resources, others, None),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
use crate::{
|
||||
frame::Frame,
|
||||
info::Info,
|
||||
track::{MainTrack, MainTrackBuilder, MainTrackHandle, SendTrack, Track},
|
||||
};
|
||||
|
||||
use super::{
|
||||
ResourceController, ResourceStorage, clocks::Clocks, listeners::Listeners,
|
||||
modulators::Modulators,
|
||||
};
|
||||
|
||||
pub(crate) struct Mixer {
|
||||
main_track: MainTrack,
|
||||
sub_tracks: ResourceStorage<Track>,
|
||||
send_tracks: ResourceStorage<SendTrack>,
|
||||
temp_buffer: Vec<Frame>,
|
||||
}
|
||||
|
||||
impl Mixer {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
sub_track_capacity: usize,
|
||||
send_track_capacity: usize,
|
||||
sample_rate: u32,
|
||||
internal_buffer_size: usize,
|
||||
main_track_builder: MainTrackBuilder,
|
||||
) -> (
|
||||
Self,
|
||||
ResourceController<Track>,
|
||||
ResourceController<SendTrack>,
|
||||
MainTrackHandle,
|
||||
) {
|
||||
let (mut main_track, main_track_handle) = main_track_builder.build(internal_buffer_size);
|
||||
main_track.init_effects(sample_rate);
|
||||
let (sub_tracks, sub_track_controller) = ResourceStorage::new(sub_track_capacity);
|
||||
let (send_tracks, send_track_controller) = ResourceStorage::new(send_track_capacity);
|
||||
(
|
||||
Self {
|
||||
main_track,
|
||||
sub_tracks,
|
||||
send_tracks,
|
||||
temp_buffer: vec![Frame::ZERO; internal_buffer_size],
|
||||
},
|
||||
sub_track_controller,
|
||||
send_track_controller,
|
||||
main_track_handle,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn on_change_sample_rate(&mut self, sample_rate: u32) {
|
||||
self.main_track.on_change_sample_rate(sample_rate);
|
||||
for (_, track) in &mut self.sub_tracks {
|
||||
track.on_change_sample_rate(sample_rate);
|
||||
}
|
||||
for (_, track) in &mut self.send_tracks {
|
||||
track.on_change_sample_rate(sample_rate);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_start_processing(&mut self) {
|
||||
self.sub_tracks
|
||||
.remove_and_add(|track| track.should_be_removed());
|
||||
for (_, track) in &mut self.sub_tracks {
|
||||
track.on_start_processing();
|
||||
}
|
||||
self.send_tracks
|
||||
.remove_and_add(|track| track.shared().is_marked_for_removal());
|
||||
for (_, track) in &mut self.send_tracks {
|
||||
track.on_start_processing();
|
||||
}
|
||||
self.main_track.on_start_processing();
|
||||
}
|
||||
|
||||
pub fn process(
|
||||
&mut self,
|
||||
out: &mut [Frame],
|
||||
dt: f64,
|
||||
clocks: &Clocks,
|
||||
modulators: &Modulators,
|
||||
listeners: &Listeners,
|
||||
) {
|
||||
for (_, track) in &mut self.sub_tracks {
|
||||
track.process(
|
||||
&mut self.temp_buffer[..out.len()],
|
||||
dt,
|
||||
clocks,
|
||||
modulators,
|
||||
listeners,
|
||||
None,
|
||||
&mut self.send_tracks,
|
||||
);
|
||||
for (summed_out, sound_out) in out.iter_mut().zip(self.temp_buffer.iter().copied()) {
|
||||
*summed_out += sound_out;
|
||||
}
|
||||
self.temp_buffer.fill(Frame::ZERO);
|
||||
}
|
||||
let info = Info::new(
|
||||
&clocks.0.resources,
|
||||
&modulators.0.resources,
|
||||
&listeners.0.resources,
|
||||
None,
|
||||
);
|
||||
for (_, track) in &mut self.send_tracks {
|
||||
track.process(&mut self.temp_buffer[..out.len()], dt, &info);
|
||||
for (summed_out, sound_out) in out.iter_mut().zip(self.temp_buffer.iter().copied()) {
|
||||
*summed_out += sound_out;
|
||||
}
|
||||
self.temp_buffer.fill(Frame::ZERO);
|
||||
}
|
||||
self.main_track.process(out, dt, &info);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use crate::{info::Info, modulator::Modulator};
|
||||
|
||||
use super::{
|
||||
ResourceController, SelfReferentialResourceStorage, clocks::Clocks, listeners::Listeners,
|
||||
};
|
||||
|
||||
pub(crate) struct Modulators(pub(crate) SelfReferentialResourceStorage<Box<dyn Modulator>>);
|
||||
|
||||
impl Modulators {
|
||||
#[must_use]
|
||||
pub fn new(capacity: usize) -> (Self, ResourceController<Box<dyn Modulator>>) {
|
||||
let (storage, controller) = SelfReferentialResourceStorage::new(capacity);
|
||||
(Self(storage), controller)
|
||||
}
|
||||
|
||||
pub fn on_start_processing(&mut self) {
|
||||
self.0.remove_and_add(|modulator| modulator.finished());
|
||||
for (_, modulator) in &mut self.0 {
|
||||
modulator.on_start_processing();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process(&mut self, dt: f64, clocks: &Clocks, listeners: &Listeners) {
|
||||
self.0.for_each(|modulator, others| {
|
||||
modulator.update(
|
||||
dt,
|
||||
&Info::new(&clocks.0.resources, others, &listeners.0.resources, None),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
struct DummyModulator;
|
||||
|
||||
impl Modulator for DummyModulator {
|
||||
fn update(&mut self, _dt: f64, _info: &Info) {}
|
||||
|
||||
fn value(&self) -> f64 {
|
||||
0.0
|
||||
}
|
||||
|
||||
fn finished(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Box<dyn Modulator> {
|
||||
fn default() -> Self {
|
||||
Box::new(DummyModulator)
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
use crate::{ResourceLimitReached, backend::resources::SelfReferentialResourceStorage};
|
||||
|
||||
use super::ResourceStorage;
|
||||
|
||||
#[test]
|
||||
fn resource_storage() {
|
||||
let (mut storage, mut controller) = ResourceStorage::new(2);
|
||||
|
||||
// add
|
||||
let one = controller.insert(1).unwrap();
|
||||
let two = controller.insert(2).unwrap();
|
||||
assert_eq!(controller.insert(3), Err(ResourceLimitReached));
|
||||
storage.remove_and_add(|_| false);
|
||||
|
||||
// get
|
||||
assert_eq!(storage.get_mut(one), Some(&mut 1));
|
||||
assert_eq!(storage.get_mut(two), Some(&mut 2));
|
||||
|
||||
// iter
|
||||
assert_eq!(
|
||||
storage.iter_mut().collect::<Vec<_>>(),
|
||||
vec![(two, &mut 2), (one, &mut 1)]
|
||||
);
|
||||
|
||||
// remove
|
||||
storage.remove_and_add(|&x| x < 2);
|
||||
assert_eq!(storage.get_mut(one), None);
|
||||
assert_eq!(storage.get_mut(two), Some(&mut 2));
|
||||
assert_eq!(storage.iter_mut().collect::<Vec<_>>(), vec![(two, &mut 2)]);
|
||||
|
||||
// re-add
|
||||
let three = controller.insert(3).unwrap();
|
||||
assert_eq!(controller.insert(4), Err(ResourceLimitReached));
|
||||
storage.remove_and_add(|_| false);
|
||||
assert_eq!(storage.get_mut(two), Some(&mut 2));
|
||||
assert_eq!(storage.get_mut(three), Some(&mut 3));
|
||||
assert_eq!(
|
||||
storage.iter_mut().collect::<Vec<_>>(),
|
||||
vec![(three, &mut 3), (two, &mut 2)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_referential_resource_storage() {
|
||||
let (mut storage, mut controller) = SelfReferentialResourceStorage::new(2);
|
||||
|
||||
// add
|
||||
let one = controller.insert(1).unwrap();
|
||||
let two = controller.insert(2).unwrap();
|
||||
assert_eq!(controller.insert(3), Err(ResourceLimitReached));
|
||||
storage.remove_and_add(|_| false);
|
||||
|
||||
// get
|
||||
assert_eq!(storage.get_mut(one), Some(&mut 1));
|
||||
assert_eq!(storage.get_mut(two), Some(&mut 2));
|
||||
|
||||
// iter
|
||||
assert_eq!(
|
||||
storage.iter_mut().collect::<Vec<_>>(),
|
||||
vec![(two, &mut 2), (one, &mut 1)]
|
||||
);
|
||||
|
||||
// remove
|
||||
storage.remove_and_add(|&x| x < 2);
|
||||
assert_eq!(storage.get_mut(one), None);
|
||||
assert_eq!(storage.get_mut(two), Some(&mut 2));
|
||||
assert_eq!(storage.iter_mut().collect::<Vec<_>>(), vec![(two, &mut 2)]);
|
||||
|
||||
// re-add
|
||||
let three = controller.insert(3).unwrap();
|
||||
assert_eq!(controller.insert(4), Err(ResourceLimitReached));
|
||||
storage.remove_and_add(|_| false);
|
||||
assert_eq!(storage.get_mut(two), Some(&mut 2));
|
||||
assert_eq!(storage.get_mut(three), Some(&mut 3));
|
||||
assert_eq!(
|
||||
storage.iter_mut().collect::<Vec<_>>(),
|
||||
vec![(three, &mut 3), (two, &mut 2)]
|
||||
);
|
||||
}
|
||||
Vendored
+355
@@ -0,0 +1,355 @@
|
||||
/*!
|
||||
Precise timing for audio events.
|
||||
|
||||
Clocks can be used to set the start times of sounds and tweens. To create a
|
||||
clock, use [`AudioManager::add_clock`](crate::AudioManager::add_clock).
|
||||
|
||||
```no_run
|
||||
use kira::{
|
||||
AudioManager, AudioManagerSettings, DefaultBackend,
|
||||
clock::ClockSpeed,
|
||||
};
|
||||
|
||||
let mut manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default())?;
|
||||
let mut clock = manager.add_clock(ClockSpeed::SecondsPerTick(1.0))?;
|
||||
clock.start();
|
||||
# Result::<(), Box<dyn std::error::Error>>::Ok(())
|
||||
```
|
||||
|
||||
You can specify the speed of the clock as seconds per tick, ticks per second, or
|
||||
ticks per minute.
|
||||
|
||||
Clocks are stopped when you first create them, so be sure to explicitly call
|
||||
[`ClockHandle::start`] when you want the clock to start ticking.
|
||||
|
||||
## Starting sounds on clock ticks
|
||||
|
||||
Sounds can be set to only start playing when a clock has ticked a certain
|
||||
number of times. You can configure this using
|
||||
[`StaticSoundData::start_time`](crate::sound::static_sound::StaticSoundData::start_time)
|
||||
or [`StreamingSoundData::start_time`](crate::sound::streaming::StreamingSoundData::start_time).
|
||||
|
||||
```no_run
|
||||
use kira::{
|
||||
clock::{ClockTime, ClockSpeed},
|
||||
AudioManager, AudioManagerSettings, DefaultBackend,
|
||||
sound::static_sound::{StaticSoundData, StaticSoundSettings},
|
||||
StartTime,
|
||||
};
|
||||
|
||||
let mut manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default())?;
|
||||
let mut clock = manager.add_clock(ClockSpeed::SecondsPerTick(1.0))?;
|
||||
manager.play(
|
||||
StaticSoundData::from_file("sound.ogg")?
|
||||
.start_time(StartTime::ClockTime(ClockTime {
|
||||
clock: clock.id(),
|
||||
ticks: 4,
|
||||
fraction: 0.0,
|
||||
}))
|
||||
)?;
|
||||
clock.start();
|
||||
# Result::<(), Box<dyn std::error::Error>>::Ok(())
|
||||
```
|
||||
|
||||
As a shorthand, you can pass the [`ClockTime`] directly into
|
||||
the `start_time` function.
|
||||
|
||||
```no_run
|
||||
# use kira::{
|
||||
# clock::{ClockTime, ClockSpeed},
|
||||
# AudioManager,
|
||||
# AudioManagerSettings,
|
||||
# DefaultBackend,
|
||||
# sound::static_sound::{StaticSoundData, StaticSoundSettings},
|
||||
# StartTime,
|
||||
# };
|
||||
#
|
||||
# let mut manager = AudioManager::<DefaultBackend>::new(
|
||||
# AudioManagerSettings::default(),
|
||||
# )?;
|
||||
# let mut clock = manager.add_clock(ClockSpeed::SecondsPerTick(1.0))?;
|
||||
manager.play(
|
||||
StaticSoundData::from_file("sound.ogg")?
|
||||
.start_time(ClockTime {
|
||||
clock: clock.id(),
|
||||
ticks: 4,
|
||||
fraction: 0.0,
|
||||
})
|
||||
)?;
|
||||
# clock.start();
|
||||
# Result::<(), Box<dyn std::error::Error>>::Ok(())
|
||||
```
|
||||
|
||||
As an even shorter hand, you can use [`ClockHandle::time`] to get the clock's
|
||||
current [`ClockTime`], and then add to it to get a time in the future:
|
||||
|
||||
```no_run
|
||||
use kira::{
|
||||
AudioManager, AudioManagerSettings, DefaultBackend,
|
||||
sound::static_sound::{StaticSoundData, StaticSoundSettings},
|
||||
clock::ClockSpeed,
|
||||
};
|
||||
|
||||
# let mut manager = AudioManager::<DefaultBackend>::new(
|
||||
# AudioManagerSettings::default(),
|
||||
# )?;
|
||||
# let mut clock = manager.add_clock(ClockSpeed::SecondsPerTick(1.0))?;
|
||||
manager.play(
|
||||
StaticSoundData::from_file("sound.ogg")?
|
||||
.start_time(clock.time() + 4)
|
||||
)?;
|
||||
# clock.start();
|
||||
# Result::<(), Box<dyn std::error::Error>>::Ok(())
|
||||
```
|
||||
|
||||
## Starting tweens on clock ticks
|
||||
|
||||
You can also use clocks to set the start time of tweens. In this example, we set
|
||||
the playback rate of a sound to start tweening when a clock reaches its third
|
||||
tick.
|
||||
|
||||
```no_run
|
||||
use std::time::Duration;
|
||||
|
||||
use kira::{
|
||||
AudioManager, AudioManagerSettings, DefaultBackend,
|
||||
sound::static_sound::{StaticSoundData, StaticSoundSettings},
|
||||
Tween,
|
||||
clock::ClockSpeed,
|
||||
StartTime,
|
||||
};
|
||||
|
||||
let mut manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default())?;
|
||||
let mut clock = manager.add_clock(ClockSpeed::SecondsPerTick(1.0))?;
|
||||
let mut sound = manager.play(StaticSoundData::from_file("sound.ogg")?)?;
|
||||
sound.set_playback_rate(
|
||||
0.5,
|
||||
Tween {
|
||||
start_time: StartTime::ClockTime(clock.time() + 3),
|
||||
duration: Duration::from_secs(2),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
clock.start();
|
||||
# Result::<(), Box<dyn std::error::Error>>::Ok(())
|
||||
```
|
||||
*/
|
||||
|
||||
mod clock_speed;
|
||||
mod handle;
|
||||
mod time;
|
||||
|
||||
#[cfg(test)]
|
||||
mod test;
|
||||
|
||||
use atomic_arena::Key;
|
||||
pub use clock_speed::*;
|
||||
pub use handle::*;
|
||||
pub use time::*;
|
||||
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
Parameter, Value,
|
||||
command::{ValueChangeCommand, read_commands_into_parameters},
|
||||
command_writers_and_readers,
|
||||
info::Info,
|
||||
};
|
||||
|
||||
/// A unique identifier for a clock.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct ClockId(pub(crate) Key);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ClockShared {
|
||||
ticking: AtomicBool,
|
||||
ticks: AtomicU64,
|
||||
fractional_position: AtomicU64,
|
||||
removed: AtomicBool,
|
||||
}
|
||||
|
||||
impl ClockShared {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
ticking: AtomicBool::new(false),
|
||||
ticks: AtomicU64::new(0),
|
||||
fractional_position: AtomicU64::new(0.0f64.to_bits()),
|
||||
removed: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn ticking(&self) -> bool {
|
||||
self.ticking.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn ticks(&self) -> u64 {
|
||||
self.ticks.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn fractional_position(&self) -> f64 {
|
||||
f64::from_bits(self.fractional_position.load(Ordering::SeqCst))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_marked_for_removal(&self) -> bool {
|
||||
self.removed.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub fn mark_for_removal(&self) {
|
||||
self.removed.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) enum State {
|
||||
NotStarted,
|
||||
Started {
|
||||
ticks: u64,
|
||||
fractional_position: f64,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) struct Clock {
|
||||
command_readers: CommandReaders,
|
||||
shared: Arc<ClockShared>,
|
||||
ticking: bool,
|
||||
speed: Parameter<ClockSpeed>,
|
||||
state: State,
|
||||
}
|
||||
|
||||
impl Clock {
|
||||
#[must_use]
|
||||
pub(crate) fn new(speed: Value<ClockSpeed>, id: ClockId) -> (Self, ClockHandle) {
|
||||
let (command_writers, command_readers) = command_writers_and_readers();
|
||||
let shared = Arc::new(ClockShared::new());
|
||||
(
|
||||
Self {
|
||||
command_readers,
|
||||
shared: shared.clone(),
|
||||
ticking: false,
|
||||
speed: Parameter::new(speed, ClockSpeed::TicksPerMinute(120.0)),
|
||||
state: State::NotStarted,
|
||||
},
|
||||
ClockHandle {
|
||||
id,
|
||||
shared,
|
||||
command_writers,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn without_handle(speed: Value<ClockSpeed>) -> Self {
|
||||
let (_, command_readers) = command_writers_and_readers();
|
||||
Self {
|
||||
command_readers,
|
||||
shared: Arc::new(ClockShared::new()),
|
||||
ticking: false,
|
||||
speed: Parameter::new(speed, ClockSpeed::TicksPerMinute(120.0)),
|
||||
state: State::NotStarted,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn shared(&self) -> Arc<ClockShared> {
|
||||
self.shared.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn state(&self) -> State {
|
||||
self.state
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn ticking(&self) -> bool {
|
||||
self.ticking
|
||||
}
|
||||
|
||||
pub(crate) fn on_start_processing(&mut self) {
|
||||
read_commands_into_parameters!(self, speed);
|
||||
if let Some(ticking) = self.command_readers.set_ticking.read() {
|
||||
self.set_ticking(ticking);
|
||||
}
|
||||
if self.command_readers.reset.read().is_some() {
|
||||
self.reset();
|
||||
}
|
||||
self.update_shared();
|
||||
}
|
||||
|
||||
fn set_ticking(&mut self, ticking: bool) {
|
||||
self.ticking = ticking;
|
||||
self.shared.ticking.store(ticking, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.state = State::NotStarted;
|
||||
self.shared.ticks.store(0, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn update_shared(&mut self) {
|
||||
let (ticks, fractional_position) = match &self.state {
|
||||
State::NotStarted => (0, 0.0),
|
||||
State::Started {
|
||||
ticks,
|
||||
fractional_position,
|
||||
} => (*ticks, *fractional_position),
|
||||
};
|
||||
self.shared.ticks.store(ticks, Ordering::SeqCst);
|
||||
self.shared
|
||||
.fractional_position
|
||||
.store(fractional_position.to_bits(), Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Updates the [`Clock`].
|
||||
///
|
||||
/// If the tick count changes this update, returns `Some(tick_number)`.
|
||||
/// Otherwise, returns `None`.
|
||||
pub(crate) fn update(&mut self, dt: f64, info: &Info) -> Option<u64> {
|
||||
self.speed.update(dt, info);
|
||||
if !self.ticking {
|
||||
return None;
|
||||
}
|
||||
let mut new_tick_count = None;
|
||||
if self.state == State::NotStarted {
|
||||
self.state = State::Started {
|
||||
ticks: 0,
|
||||
fractional_position: 0.0,
|
||||
};
|
||||
new_tick_count = Some(0);
|
||||
}
|
||||
if let State::Started {
|
||||
ticks,
|
||||
fractional_position: tick_timer,
|
||||
} = &mut self.state
|
||||
{
|
||||
*tick_timer += self.speed.value().as_ticks_per_second() * dt;
|
||||
while *tick_timer >= 1.0 {
|
||||
*tick_timer -= 1.0;
|
||||
*ticks += 1;
|
||||
new_tick_count = Some(*ticks);
|
||||
}
|
||||
} else {
|
||||
panic!("clock state should be Started by now");
|
||||
}
|
||||
new_tick_count
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Clock {
|
||||
fn default() -> Self {
|
||||
Self::without_handle(Value::Fixed(ClockSpeed::TicksPerSecond(0.0)))
|
||||
}
|
||||
}
|
||||
|
||||
command_writers_and_readers! {
|
||||
set_speed: ValueChangeCommand<ClockSpeed>,
|
||||
set_ticking: bool,
|
||||
reset: (),
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
use crate::{Value, tween::Tweenable};
|
||||
|
||||
/// The rate that a [clock](crate::clock) ticks at.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum ClockSpeed {
|
||||
/// The clock ticks every x seconds.
|
||||
SecondsPerTick(f64),
|
||||
/// The clock ticks x times per second.
|
||||
TicksPerSecond(f64),
|
||||
/// The clock ticks x times per minute.
|
||||
TicksPerMinute(f64),
|
||||
}
|
||||
|
||||
impl ClockSpeed {
|
||||
/// Returns the [`ClockSpeed`] as a number of seconds between each tick.
|
||||
#[must_use]
|
||||
pub fn as_seconds_per_tick(&self) -> f64 {
|
||||
match self {
|
||||
ClockSpeed::SecondsPerTick(seconds_per_tick) => *seconds_per_tick,
|
||||
ClockSpeed::TicksPerSecond(ticks_per_second) => 1.0 / *ticks_per_second,
|
||||
ClockSpeed::TicksPerMinute(ticks_per_minute) => 60.0 / *ticks_per_minute,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the [`ClockSpeed`] as a number of ticks per second.
|
||||
#[must_use]
|
||||
pub fn as_ticks_per_second(&self) -> f64 {
|
||||
match self {
|
||||
ClockSpeed::SecondsPerTick(seconds_per_tick) => 1.0 / *seconds_per_tick,
|
||||
ClockSpeed::TicksPerSecond(ticks_per_second) => *ticks_per_second,
|
||||
ClockSpeed::TicksPerMinute(ticks_per_minute) => *ticks_per_minute / 60.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the [`ClockSpeed`] as a number of ticks per minute.
|
||||
#[must_use]
|
||||
pub fn as_ticks_per_minute(&self) -> f64 {
|
||||
match self {
|
||||
ClockSpeed::SecondsPerTick(seconds_per_tick) => 60.0 / *seconds_per_tick,
|
||||
ClockSpeed::TicksPerSecond(ticks_per_second) => *ticks_per_second * 60.0,
|
||||
ClockSpeed::TicksPerMinute(ticks_per_minute) => *ticks_per_minute,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Tweenable for ClockSpeed {
|
||||
fn interpolate(a: Self, b: Self, amount: f64) -> Self {
|
||||
match b {
|
||||
ClockSpeed::SecondsPerTick(b) => ClockSpeed::SecondsPerTick(Tweenable::interpolate(
|
||||
a.as_seconds_per_tick(),
|
||||
b,
|
||||
amount,
|
||||
)),
|
||||
ClockSpeed::TicksPerSecond(b) => ClockSpeed::TicksPerSecond(Tweenable::interpolate(
|
||||
a.as_ticks_per_second(),
|
||||
b,
|
||||
amount,
|
||||
)),
|
||||
ClockSpeed::TicksPerMinute(b) => ClockSpeed::TicksPerMinute(Tweenable::interpolate(
|
||||
a.as_ticks_per_minute(),
|
||||
b,
|
||||
amount,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ClockSpeed> for Value<ClockSpeed> {
|
||||
fn from(clock_speed: ClockSpeed) -> Self {
|
||||
Value::Fixed(clock_speed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
#[allow(clippy::float_cmp)]
|
||||
fn test() {
|
||||
const SECONDS_PER_TICK: f64 = 0.5;
|
||||
const TICKS_PER_SECOND: f64 = 2.0;
|
||||
const TICKS_PER_MINUTE: f64 = 120.0;
|
||||
|
||||
assert_eq!(
|
||||
ClockSpeed::SecondsPerTick(SECONDS_PER_TICK).as_seconds_per_tick(),
|
||||
SECONDS_PER_TICK
|
||||
);
|
||||
assert_eq!(
|
||||
ClockSpeed::SecondsPerTick(SECONDS_PER_TICK).as_ticks_per_second(),
|
||||
TICKS_PER_SECOND
|
||||
);
|
||||
assert_eq!(
|
||||
ClockSpeed::SecondsPerTick(SECONDS_PER_TICK).as_ticks_per_minute(),
|
||||
TICKS_PER_MINUTE
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
ClockSpeed::TicksPerSecond(TICKS_PER_SECOND).as_seconds_per_tick(),
|
||||
SECONDS_PER_TICK
|
||||
);
|
||||
assert_eq!(
|
||||
ClockSpeed::TicksPerSecond(TICKS_PER_SECOND).as_ticks_per_second(),
|
||||
TICKS_PER_SECOND
|
||||
);
|
||||
assert_eq!(
|
||||
ClockSpeed::TicksPerSecond(TICKS_PER_SECOND).as_ticks_per_minute(),
|
||||
TICKS_PER_MINUTE
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
ClockSpeed::TicksPerMinute(TICKS_PER_MINUTE).as_seconds_per_tick(),
|
||||
SECONDS_PER_TICK
|
||||
);
|
||||
assert_eq!(
|
||||
ClockSpeed::TicksPerMinute(TICKS_PER_MINUTE).as_ticks_per_second(),
|
||||
TICKS_PER_SECOND
|
||||
);
|
||||
assert_eq!(
|
||||
ClockSpeed::TicksPerMinute(TICKS_PER_MINUTE).as_ticks_per_minute(),
|
||||
TICKS_PER_MINUTE
|
||||
);
|
||||
}
|
||||
Vendored
+81
@@ -0,0 +1,81 @@
|
||||
use std::sync::{Arc, atomic::Ordering};
|
||||
|
||||
use crate::command::handle_param_setters;
|
||||
|
||||
use super::{ClockId, ClockShared, ClockSpeed, ClockTime, CommandWriters};
|
||||
|
||||
/// Controls a clock.
|
||||
///
|
||||
/// When a [`ClockHandle`] is dropped, the corresponding clock
|
||||
/// will be removed.
|
||||
#[derive(Debug)]
|
||||
pub struct ClockHandle {
|
||||
pub(crate) id: ClockId,
|
||||
pub(crate) shared: Arc<ClockShared>,
|
||||
pub(crate) command_writers: CommandWriters,
|
||||
}
|
||||
|
||||
impl ClockHandle {
|
||||
/// Returns the unique identifier for the clock.
|
||||
#[must_use]
|
||||
pub fn id(&self) -> ClockId {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// Returns `true` if the clock is currently ticking
|
||||
/// and `false` if not.
|
||||
#[must_use]
|
||||
pub fn ticking(&self) -> bool {
|
||||
self.shared.ticking()
|
||||
}
|
||||
|
||||
/// Returns the current time of the clock.
|
||||
#[must_use]
|
||||
pub fn time(&self) -> ClockTime {
|
||||
ClockTime {
|
||||
clock: self.id,
|
||||
ticks: self.shared.ticks(),
|
||||
fraction: self.shared.fractional_position(),
|
||||
}
|
||||
}
|
||||
|
||||
handle_param_setters! {
|
||||
/// Sets the speed of the clock.
|
||||
speed: ClockSpeed,
|
||||
}
|
||||
|
||||
/// Starts or resumes the clock.
|
||||
pub fn start(&mut self) {
|
||||
self.command_writers.set_ticking.write(true)
|
||||
}
|
||||
|
||||
/// Pauses the clock.
|
||||
pub fn pause(&mut self) {
|
||||
self.command_writers.set_ticking.write(false)
|
||||
}
|
||||
|
||||
/// Stops and resets the clock.
|
||||
pub fn stop(&mut self) {
|
||||
self.command_writers.set_ticking.write(false);
|
||||
self.command_writers.reset.write(());
|
||||
// store 0 as the current time so any reads that happen
|
||||
// immediately after a stop don't errantly get the previous
|
||||
// clock time
|
||||
self.shared.ticks.store(0, Ordering::SeqCst);
|
||||
self.shared
|
||||
.fractional_position
|
||||
.store(0.0f64.to_bits(), Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ClockHandle {
|
||||
fn drop(&mut self) {
|
||||
self.shared.mark_for_removal();
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&ClockHandle> for ClockId {
|
||||
fn from(handle: &ClockHandle) -> Self {
|
||||
handle.id()
|
||||
}
|
||||
}
|
||||
Vendored
+209
@@ -0,0 +1,209 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use atomic_arena::Arena;
|
||||
|
||||
use crate::{StartTime, Tween, Value, clock::ClockTime, info::MockInfoBuilder};
|
||||
|
||||
use super::{Clock, ClockId, ClockSpeed};
|
||||
|
||||
/// Tests that a `Clock` is stopped when it's first created.
|
||||
#[test]
|
||||
fn initially_stopped() {
|
||||
let (mut clock, handle) = Clock::new(
|
||||
Value::Fixed(ClockSpeed::SecondsPerTick(1.0)),
|
||||
fake_clock_id(),
|
||||
);
|
||||
for _ in 0..3 {
|
||||
assert!(!handle.ticking());
|
||||
assert_eq!(handle.time().ticks, 0);
|
||||
clock.update(1.0, &MockInfoBuilder::new().build());
|
||||
clock.on_start_processing();
|
||||
}
|
||||
}
|
||||
|
||||
/// Tests that a `Clock` ticks.
|
||||
#[test]
|
||||
fn basic_behavior() {
|
||||
let (mut clock, mut handle) = Clock::new(
|
||||
Value::Fixed(ClockSpeed::SecondsPerTick(1.0)),
|
||||
fake_clock_id(),
|
||||
);
|
||||
handle.start();
|
||||
clock.on_start_processing();
|
||||
for i in 0..3 {
|
||||
assert!(handle.ticking());
|
||||
assert_eq!(handle.time().ticks, i);
|
||||
assert_eq!(
|
||||
clock.update(1.0, &MockInfoBuilder::new().build()),
|
||||
Some(i + 1)
|
||||
);
|
||||
clock.on_start_processing();
|
||||
}
|
||||
}
|
||||
|
||||
/// Tests that a `Clock` can be paused.
|
||||
#[test]
|
||||
fn pause() {
|
||||
let (mut clock, mut handle) = Clock::new(
|
||||
Value::Fixed(ClockSpeed::SecondsPerTick(1.0)),
|
||||
fake_clock_id(),
|
||||
);
|
||||
handle.start();
|
||||
clock.on_start_processing();
|
||||
clock.update(1.5, &MockInfoBuilder::new().build());
|
||||
clock.on_start_processing();
|
||||
assert_eq!(handle.time().ticks, 1);
|
||||
handle.pause();
|
||||
clock.on_start_processing();
|
||||
// the clock should not be ticking
|
||||
for _ in 0..3 {
|
||||
clock.update(1.0, &MockInfoBuilder::new().build());
|
||||
clock.on_start_processing();
|
||||
assert!(!handle.ticking());
|
||||
assert_eq!(handle.time().ticks, 1);
|
||||
}
|
||||
handle.start();
|
||||
clock.on_start_processing();
|
||||
// make sure we've preserved the fractional position from before
|
||||
// pausing
|
||||
clock.update(0.4, &MockInfoBuilder::new().build());
|
||||
clock.on_start_processing();
|
||||
assert_eq!(handle.time().ticks, 1);
|
||||
clock.update(0.1, &MockInfoBuilder::new().build());
|
||||
clock.on_start_processing();
|
||||
assert_eq!(handle.time().ticks, 2);
|
||||
}
|
||||
|
||||
/// Tests that a `Clock` can be stopped.
|
||||
#[test]
|
||||
fn stop() {
|
||||
let (mut clock, mut handle) = Clock::new(
|
||||
Value::Fixed(ClockSpeed::SecondsPerTick(1.0)),
|
||||
fake_clock_id(),
|
||||
);
|
||||
handle.start();
|
||||
clock.on_start_processing();
|
||||
clock.update(1.5, &MockInfoBuilder::new().build());
|
||||
clock.on_start_processing();
|
||||
handle.stop();
|
||||
clock.on_start_processing();
|
||||
// the clock should not be ticking
|
||||
for _ in 0..3 {
|
||||
clock.update(1.0, &MockInfoBuilder::new().build());
|
||||
clock.on_start_processing();
|
||||
assert!(!handle.ticking());
|
||||
assert_eq!(handle.time().ticks, 0);
|
||||
}
|
||||
handle.start();
|
||||
clock.on_start_processing();
|
||||
// make sure the fractional position has been reset
|
||||
clock.update(0.9, &MockInfoBuilder::new().build());
|
||||
clock.on_start_processing();
|
||||
assert_eq!(handle.time().ticks, 0);
|
||||
clock.update(0.1, &MockInfoBuilder::new().build());
|
||||
clock.on_start_processing();
|
||||
assert_eq!(handle.time().ticks, 1);
|
||||
}
|
||||
|
||||
/// Tests that the speed of a [`Clock`] can be changed after creation.
|
||||
#[test]
|
||||
fn set_speed() {
|
||||
let (mut clock, mut handle) = Clock::new(
|
||||
Value::Fixed(ClockSpeed::SecondsPerTick(1.0)),
|
||||
fake_clock_id(),
|
||||
);
|
||||
handle.start();
|
||||
clock.on_start_processing();
|
||||
handle.set_speed(
|
||||
Value::Fixed(ClockSpeed::SecondsPerTick(0.5)),
|
||||
Tween {
|
||||
duration: Duration::ZERO,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
clock.on_start_processing();
|
||||
clock.update(1.0, &MockInfoBuilder::new().build());
|
||||
clock.on_start_processing();
|
||||
assert_eq!(handle.time().ticks, 2);
|
||||
clock.update(1.0, &MockInfoBuilder::new().build());
|
||||
clock.on_start_processing();
|
||||
assert_eq!(handle.time().ticks, 4);
|
||||
}
|
||||
|
||||
/// Tests that a clock speed tween properly responds to ticks from
|
||||
/// other clocks when the start time is set to a clock time.
|
||||
#[test]
|
||||
fn set_speed_with_clock_time_start() {
|
||||
let mut info_builder = MockInfoBuilder::new();
|
||||
let clock_id = info_builder.add_clock(true, 0, 0.0);
|
||||
let info = info_builder.build();
|
||||
|
||||
let (mut clock, mut handle) = Clock::new(
|
||||
Value::Fixed(ClockSpeed::SecondsPerTick(1.0)),
|
||||
fake_clock_id(),
|
||||
);
|
||||
handle.start();
|
||||
clock.on_start_processing();
|
||||
handle.set_speed(
|
||||
Value::Fixed(ClockSpeed::SecondsPerTick(0.5)),
|
||||
Tween {
|
||||
duration: Duration::ZERO,
|
||||
start_time: StartTime::ClockTime(ClockTime {
|
||||
clock: clock_id,
|
||||
ticks: 1,
|
||||
fraction: 0.0,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
clock.on_start_processing();
|
||||
|
||||
clock.update(1.0, &info);
|
||||
clock.on_start_processing();
|
||||
assert_eq!(handle.time().ticks, 1);
|
||||
clock.update(1.0, &info);
|
||||
clock.on_start_processing();
|
||||
assert_eq!(handle.time().ticks, 2);
|
||||
|
||||
let info = {
|
||||
let mut builder = MockInfoBuilder::new();
|
||||
builder.add_clock(true, 1, 0.0);
|
||||
builder.build()
|
||||
};
|
||||
|
||||
clock.update(1.0, &info);
|
||||
clock.on_start_processing();
|
||||
assert_eq!(handle.time().ticks, 4);
|
||||
clock.update(1.0, &info);
|
||||
clock.on_start_processing();
|
||||
assert_eq!(handle.time().ticks, 6);
|
||||
}
|
||||
|
||||
/// Tests that a clock correctly reports its fractional position.
|
||||
#[test]
|
||||
fn fractional_position() {
|
||||
let (mut clock, mut handle) = Clock::new(
|
||||
Value::Fixed(ClockSpeed::SecondsPerTick(1.0)),
|
||||
fake_clock_id(),
|
||||
);
|
||||
assert_eq!(handle.time().fraction, 0.0);
|
||||
// the clock is not started yet, so the fractional position should remain at 0
|
||||
clock.update(1.0, &MockInfoBuilder::new().build());
|
||||
clock.on_start_processing();
|
||||
assert_eq!(handle.time().fraction, 0.0);
|
||||
// start the clock
|
||||
handle.start();
|
||||
clock.on_start_processing();
|
||||
clock.update(0.5, &MockInfoBuilder::new().build());
|
||||
clock.on_start_processing();
|
||||
assert_eq!(handle.time().fraction, 0.5);
|
||||
clock.update(0.75, &MockInfoBuilder::new().build());
|
||||
clock.on_start_processing();
|
||||
assert_eq!(handle.time().fraction, 0.25);
|
||||
}
|
||||
|
||||
fn fake_clock_id() -> ClockId {
|
||||
let mut arena = Arena::new(1);
|
||||
let key = arena.insert(()).unwrap();
|
||||
ClockId(key)
|
||||
}
|
||||
Vendored
+212
@@ -0,0 +1,212 @@
|
||||
#[cfg(test)]
|
||||
mod test;
|
||||
|
||||
use std::{
|
||||
cmp::Ordering,
|
||||
ops::{Add, AddAssign, Sub, SubAssign},
|
||||
};
|
||||
|
||||
use super::ClockId;
|
||||
|
||||
/**
|
||||
An instant in time associated with a clock.
|
||||
|
||||
`ClockTime`s implement [`PartialOrd`]. They can be compared as long
|
||||
as both times are associated with the same clock. If the clocks are
|
||||
different, `a.cmp(b)` will return `None`.
|
||||
*/
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ClockTime {
|
||||
/// The clock this time is associated with.
|
||||
pub clock: ClockId,
|
||||
/// The elapsed time in whole ticks.
|
||||
pub ticks: u64,
|
||||
/// The amount of time since the last tick as a fraction of a tick.
|
||||
///
|
||||
/// This will always be in the range of `0.0` (inclusive) to `1.0` (exclusive).
|
||||
pub fraction: f64,
|
||||
}
|
||||
|
||||
impl ClockTime {
|
||||
/**
|
||||
Creates a [`ClockTime`] for a clock with the specified number of
|
||||
whole ticks.
|
||||
|
||||
# Example
|
||||
|
||||
```no_run
|
||||
use kira::{
|
||||
AudioManager,
|
||||
AudioManagerSettings,
|
||||
DefaultBackend,
|
||||
clock::{ClockSpeed, ClockTime},
|
||||
};
|
||||
|
||||
let mut manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default())?;
|
||||
let clock = manager.add_clock(ClockSpeed::TicksPerMinute(120.0))?;
|
||||
assert_eq!(
|
||||
ClockTime::from_ticks_u64(&clock, 3),
|
||||
ClockTime {
|
||||
clock: clock.id(),
|
||||
ticks: 3,
|
||||
fraction: 0.0,
|
||||
},
|
||||
);
|
||||
# Ok::<(), Box<dyn std::error::Error>>(())
|
||||
```
|
||||
*/
|
||||
pub fn from_ticks_u64(clock: impl Into<ClockId>, ticks: u64) -> Self {
|
||||
Self {
|
||||
clock: clock.into(),
|
||||
ticks,
|
||||
fraction: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Creates a [`ClockTime`] for a clock with the specified number of
|
||||
ticks.
|
||||
|
||||
# Example
|
||||
|
||||
```no_run
|
||||
use kira::{
|
||||
AudioManager,
|
||||
AudioManagerSettings,
|
||||
DefaultBackend,
|
||||
clock::{ClockSpeed, ClockTime},
|
||||
};
|
||||
|
||||
let mut manager = AudioManager::<DefaultBackend>::new(AudioManagerSettings::default())?;
|
||||
let clock = manager.add_clock(ClockSpeed::TicksPerMinute(120.0))?;
|
||||
assert_eq!(
|
||||
ClockTime::from_ticks_f64(&clock, 3.5),
|
||||
ClockTime {
|
||||
clock: clock.id(),
|
||||
ticks: 3,
|
||||
fraction: 0.5,
|
||||
},
|
||||
);
|
||||
# Ok::<(), Box<dyn std::error::Error>>(())
|
||||
```
|
||||
*/
|
||||
pub fn from_ticks_f64(clock: impl Into<ClockId>, ticks: f64) -> Self {
|
||||
Self {
|
||||
clock: clock.into(),
|
||||
ticks: ticks as u64,
|
||||
fraction: ticks.fract(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Add<u64> for ClockTime {
|
||||
type Output = ClockTime;
|
||||
|
||||
fn add(self, ticks: u64) -> Self::Output {
|
||||
Self {
|
||||
clock: self.clock,
|
||||
ticks: self.ticks + ticks,
|
||||
fraction: self.fraction,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AddAssign<u64> for ClockTime {
|
||||
fn add_assign(&mut self, ticks: u64) {
|
||||
self.ticks += ticks;
|
||||
}
|
||||
}
|
||||
|
||||
impl Add<f64> for ClockTime {
|
||||
type Output = ClockTime;
|
||||
|
||||
fn add(self, ticks: f64) -> Self::Output {
|
||||
debug_assert!(
|
||||
ticks.is_finite(),
|
||||
"Cannot add a non-finite amount to ClockTime: {ticks:?}"
|
||||
);
|
||||
|
||||
// This implementation does not allow adding negative numbers, use the Sub<f64> impl instead
|
||||
if ticks.is_sign_negative() {
|
||||
return self.sub(-ticks);
|
||||
}
|
||||
|
||||
let fraction = (self.fraction + ticks).fract();
|
||||
let ticks = self.ticks + (self.fraction + ticks).trunc() as u64;
|
||||
|
||||
Self {
|
||||
clock: self.clock,
|
||||
ticks,
|
||||
fraction,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AddAssign<f64> for ClockTime {
|
||||
fn add_assign(&mut self, ticks: f64) {
|
||||
*self = *self + ticks;
|
||||
}
|
||||
}
|
||||
|
||||
impl Sub<u64> for ClockTime {
|
||||
type Output = ClockTime;
|
||||
|
||||
fn sub(self, ticks: u64) -> Self::Output {
|
||||
Self {
|
||||
clock: self.clock,
|
||||
ticks: self.ticks - ticks,
|
||||
fraction: self.fraction,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SubAssign<u64> for ClockTime {
|
||||
fn sub_assign(&mut self, ticks: u64) {
|
||||
self.ticks -= ticks;
|
||||
}
|
||||
}
|
||||
|
||||
impl Sub<f64> for ClockTime {
|
||||
type Output = ClockTime;
|
||||
|
||||
fn sub(self, ticks: f64) -> Self::Output {
|
||||
debug_assert!(
|
||||
ticks.is_finite(),
|
||||
"Cannot subtract a non-finite amount from ClockTime: {ticks:?}"
|
||||
);
|
||||
|
||||
// This implementation does not allow subtracting negative numbers, use the Add<f64> impl instead
|
||||
if ticks.is_sign_negative() {
|
||||
return self.add(-ticks);
|
||||
}
|
||||
|
||||
let fraction = ((self.fraction - ticks).fract() + 1.0) % 1.0;
|
||||
let ticks = self
|
||||
.ticks
|
||||
.saturating_sub((ticks - self.fraction).ceil() as u64);
|
||||
|
||||
Self {
|
||||
clock: self.clock,
|
||||
ticks,
|
||||
fraction,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SubAssign<f64> for ClockTime {
|
||||
fn sub_assign(&mut self, ticks: f64) {
|
||||
*self = *self - ticks;
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for ClockTime {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
if self.clock != other.clock {
|
||||
return None;
|
||||
}
|
||||
match self.ticks.cmp(&other.ticks) {
|
||||
Ordering::Equal => self.fraction.partial_cmp(&other.fraction),
|
||||
ordering => Some(ordering),
|
||||
}
|
||||
}
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use approx::assert_relative_eq;
|
||||
use atomic_arena::Arena;
|
||||
|
||||
use crate::clock::{ClockId, ClockTime};
|
||||
|
||||
#[test]
|
||||
fn from_ticks_u64() {
|
||||
let id = fake_clock_id();
|
||||
assert_eq!(
|
||||
ClockTime::from_ticks_u64(id, 3),
|
||||
ClockTime {
|
||||
clock: id,
|
||||
ticks: 3,
|
||||
fraction: 0.0
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_ticks_f64() {
|
||||
let id = fake_clock_id();
|
||||
assert_eq!(
|
||||
ClockTime::from_ticks_f64(id, 3.5),
|
||||
ClockTime {
|
||||
clock: id,
|
||||
ticks: 3,
|
||||
fraction: 0.5
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_u64() {
|
||||
let id = fake_clock_id();
|
||||
assert_eq!(
|
||||
ClockTime {
|
||||
clock: id,
|
||||
ticks: 2,
|
||||
fraction: 0.0,
|
||||
} + 3,
|
||||
ClockTime {
|
||||
clock: id,
|
||||
ticks: 5,
|
||||
fraction: 0.0,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
ClockTime {
|
||||
clock: id,
|
||||
ticks: 2,
|
||||
fraction: 0.5,
|
||||
} + 2,
|
||||
ClockTime {
|
||||
clock: id,
|
||||
ticks: 4,
|
||||
fraction: 0.5,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_u64() {
|
||||
let id = fake_clock_id();
|
||||
assert_eq!(
|
||||
ClockTime {
|
||||
clock: id,
|
||||
ticks: 5,
|
||||
fraction: 0.0,
|
||||
} - 3,
|
||||
ClockTime {
|
||||
clock: id,
|
||||
ticks: 2,
|
||||
fraction: 0.0,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
ClockTime {
|
||||
clock: id,
|
||||
ticks: 4,
|
||||
fraction: 0.5,
|
||||
} - 2,
|
||||
ClockTime {
|
||||
clock: id,
|
||||
ticks: 2,
|
||||
fraction: 0.5,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_f64() {
|
||||
let id = fake_clock_id();
|
||||
let time = ClockTime {
|
||||
clock: id,
|
||||
ticks: 2,
|
||||
fraction: 0.5,
|
||||
} + 3.7;
|
||||
assert_eq!(time.ticks, 6);
|
||||
assert_relative_eq!(time.fraction, 0.2);
|
||||
let time = ClockTime {
|
||||
clock: id,
|
||||
ticks: 2,
|
||||
fraction: 0.5,
|
||||
} + 3.5;
|
||||
assert_eq!(time.ticks, 6);
|
||||
assert_relative_eq!(time.fraction, 0.0);
|
||||
let id = fake_clock_id();
|
||||
let time = ClockTime {
|
||||
clock: id,
|
||||
ticks: 5,
|
||||
fraction: 0.5,
|
||||
} + (-3.7);
|
||||
assert_eq!(time.ticks, 1);
|
||||
assert_relative_eq!(time.fraction, 0.8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_f64() {
|
||||
let id = fake_clock_id();
|
||||
let time = ClockTime {
|
||||
clock: id,
|
||||
ticks: 5,
|
||||
fraction: 0.5,
|
||||
} - 3.7;
|
||||
assert_eq!(time.ticks, 1);
|
||||
assert_relative_eq!(time.fraction, 0.8);
|
||||
let time = ClockTime {
|
||||
clock: id,
|
||||
ticks: 5,
|
||||
fraction: 0.5,
|
||||
} - 3.5;
|
||||
assert_eq!(time.ticks, 2);
|
||||
assert_relative_eq!(time.fraction, 0.0);
|
||||
let id = fake_clock_id();
|
||||
let time = ClockTime {
|
||||
clock: id,
|
||||
ticks: 2,
|
||||
fraction: 0.5,
|
||||
} - (-3.7);
|
||||
assert_eq!(time.ticks, 6);
|
||||
assert_relative_eq!(time.fraction, 0.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_cmp() {
|
||||
let ids = fake_clock_ids(2);
|
||||
assert_eq!(
|
||||
ClockTime {
|
||||
clock: ids[0],
|
||||
ticks: 5,
|
||||
fraction: 0.0
|
||||
}
|
||||
.partial_cmp(&ClockTime {
|
||||
clock: ids[0],
|
||||
ticks: 5,
|
||||
fraction: 0.0
|
||||
}),
|
||||
Some(Ordering::Equal)
|
||||
);
|
||||
assert_eq!(
|
||||
ClockTime {
|
||||
clock: ids[0],
|
||||
ticks: 4,
|
||||
fraction: 0.0
|
||||
}
|
||||
.partial_cmp(&ClockTime {
|
||||
clock: ids[0],
|
||||
ticks: 5,
|
||||
fraction: 0.0
|
||||
}),
|
||||
Some(Ordering::Less)
|
||||
);
|
||||
assert_eq!(
|
||||
ClockTime {
|
||||
clock: ids[0],
|
||||
ticks: 6,
|
||||
fraction: 0.0
|
||||
}
|
||||
.partial_cmp(&ClockTime {
|
||||
clock: ids[0],
|
||||
ticks: 5,
|
||||
fraction: 0.0
|
||||
}),
|
||||
Some(Ordering::Greater)
|
||||
);
|
||||
assert_eq!(
|
||||
ClockTime {
|
||||
clock: ids[0],
|
||||
ticks: 5,
|
||||
fraction: 0.0
|
||||
}
|
||||
.partial_cmp(&ClockTime {
|
||||
clock: ids[0],
|
||||
ticks: 5,
|
||||
fraction: 0.5
|
||||
}),
|
||||
Some(Ordering::Less)
|
||||
);
|
||||
assert_eq!(
|
||||
ClockTime {
|
||||
clock: ids[0],
|
||||
ticks: 5,
|
||||
fraction: 0.5
|
||||
}
|
||||
.partial_cmp(&ClockTime {
|
||||
clock: ids[0],
|
||||
ticks: 5,
|
||||
fraction: 0.0
|
||||
}),
|
||||
Some(Ordering::Greater)
|
||||
);
|
||||
assert_eq!(
|
||||
ClockTime {
|
||||
clock: ids[0],
|
||||
ticks: 5,
|
||||
fraction: 0.5
|
||||
}
|
||||
.partial_cmp(&ClockTime {
|
||||
clock: ids[1],
|
||||
ticks: 5,
|
||||
fraction: 0.0
|
||||
}),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
ClockTime {
|
||||
clock: ids[0],
|
||||
ticks: 5,
|
||||
fraction: 0.5
|
||||
}
|
||||
.partial_cmp(&ClockTime {
|
||||
clock: ids[0],
|
||||
ticks: 5,
|
||||
fraction: f64::NAN,
|
||||
}),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
fn fake_clock_id() -> ClockId {
|
||||
let mut arena = Arena::new(1);
|
||||
let key = arena.insert(()).unwrap();
|
||||
ClockId(key)
|
||||
}
|
||||
|
||||
fn fake_clock_ids(count: usize) -> Vec<ClockId> {
|
||||
let mut arena = Arena::new(count);
|
||||
(0..count)
|
||||
.map(|_| arena.insert(()).unwrap())
|
||||
.map(ClockId)
|
||||
.collect()
|
||||
}
|
||||
Vendored
+180
@@ -0,0 +1,180 @@
|
||||
/*!
|
||||
Helpers for sending commands from the gameplay thread to the audio thread.
|
||||
You'll only need to use this if you're making your own implementation of
|
||||
[`Sound`](crate::sound::Sound), [`Effect`](crate::effect::Effect),
|
||||
or [`Modulator`](crate::modulator::Modulator).
|
||||
|
||||
This module provides a [`CommandWriter`] and [`CommandReader`] that can
|
||||
transfer data from one thread to another without blocking or waiting on either
|
||||
thread. `CommandReader::read` will return the latest data that was written to
|
||||
the corresponding `CommandWriter` or return `None` if no new data was written
|
||||
since the last read.
|
||||
|
||||
Keep in mind that if multiple values are written to a `CommandWriter`, any newer
|
||||
values will overwrite older values that haven't been read yet. Therefore, this
|
||||
is only suitable when the reader only cares about the most recent value that
|
||||
has been written; i.e. new values supercede all older values. If you need a
|
||||
realtime-safe FIFO queue of multiple values, consider using a ring buffer, such as
|
||||
[`RingBuffer`](rtrb::RingBuffer) from the [rtrb](https://crates.io/crates/rtrb) crate,
|
||||
which Kira uses internally.
|
||||
*/
|
||||
|
||||
use triple_buffer::{Input, Output, triple_buffer};
|
||||
|
||||
use crate::{Tween, Value};
|
||||
|
||||
/** Writes values that can be sent to a [`CommandReader`]. */
|
||||
#[derive(Debug)]
|
||||
pub struct CommandWriter<T: Send + Copy>(Input<Option<T>>);
|
||||
|
||||
impl<T: Send + Copy> CommandWriter<T> {
|
||||
/** Writes a new value, overwriting any previous values. */
|
||||
pub fn write(&mut self, command: T) {
|
||||
self.0.write(Some(command))
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads values that were written to a [`CommandWriter`]. */
|
||||
#[derive(Debug)]
|
||||
pub struct CommandReader<T: Send + Copy>(Output<Option<T>>);
|
||||
|
||||
impl<T: Send + Copy> CommandReader<T> {
|
||||
/**
|
||||
* Returns the latest value that was written to the [`CommandWriter`],
|
||||
* or `None` if no new values were written since the last read.
|
||||
*/
|
||||
#[must_use]
|
||||
pub fn read(&mut self) -> Option<T> {
|
||||
if self.0.update() {
|
||||
*self.0.output_buffer_mut()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates a command writer/reader pair. */
|
||||
#[must_use]
|
||||
pub fn command_writer_and_reader<T: Send + Copy>() -> (CommandWriter<T>, CommandReader<T>) {
|
||||
let (input, output) = triple_buffer(&None);
|
||||
(CommandWriter(input), CommandReader(output))
|
||||
}
|
||||
|
||||
/**
|
||||
* A command that holds a target [`Value`] and a [`Tween`].
|
||||
*
|
||||
* Setting something to a [`Value`] with a given [`Tween`] is a common
|
||||
* pattern in Kira.
|
||||
*
|
||||
* `CommandReader<ValueChangeCommand>`s can be passed to [`Parameter`](crate::Parameter)s
|
||||
* to quickly set the parameter to a new value read from the [`CommandReader`].
|
||||
*/
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ValueChangeCommand<T> {
|
||||
/// The new value to set something to.
|
||||
pub target: Value<T>,
|
||||
/// The tween to use to smoothly transition the value.
|
||||
pub tween: Tween,
|
||||
}
|
||||
|
||||
/**
|
||||
Creates a set of command writers and readers and a constructor for them.
|
||||
You'll only need to use this if you're making your own implementation of
|
||||
[`Sound`](crate::sound::Sound), [`Effect`](crate::effect::Effect),
|
||||
or [`Modulator`](crate::modulator::Modulator).
|
||||
|
||||
# Example
|
||||
|
||||
This macro call...
|
||||
|
||||
```ignore
|
||||
command_writers_and_readers! {
|
||||
set_phase: f64,
|
||||
set_frequency: ValueChangeCommand<f64>,
|
||||
}
|
||||
```
|
||||
|
||||
...will produce this code:
|
||||
|
||||
```ignore
|
||||
pub(crate) struct CommandWriters {
|
||||
set_phase: kira::command::CommandWriter<f64>,
|
||||
set_frequency: kira::command::CommandWriter<ValueChangeCommand<f64>>,
|
||||
}
|
||||
|
||||
pub(crate) struct CommandReaders {
|
||||
set_phase: kira::command::CommandReader<f64>,
|
||||
set_frequency: kira::command::CommandReader<ValueChangeCommand<f64>>,
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn command_writers_and_readers() -> (CommandWriters, CommandReaders) {
|
||||
let (set_phase_writer, set_phase_reader) = kira::command::command_writer_and_reader();
|
||||
let (set_frequency_writer, set_frequency_reader) = kira::command::command_writer_and_reader();
|
||||
let command_writers = CommandWriters {
|
||||
set_phase: set_phase_writer,
|
||||
set_frequency: set_frequency_writer,
|
||||
};
|
||||
let command_readers = CommandReaders {
|
||||
set_phase: set_phase_reader,
|
||||
set_frequency: set_frequency_reader,
|
||||
};
|
||||
(command_writers, command_readers)
|
||||
}
|
||||
```
|
||||
*/
|
||||
#[macro_export]
|
||||
macro_rules! command_writers_and_readers {
|
||||
($($field_name:ident: $type:ty),*$(,)?) => {
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct CommandWriters {
|
||||
$($field_name: $crate::command::CommandWriter<$type>),*
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct CommandReaders {
|
||||
$($field_name: $crate::command::CommandReader<$type>),*
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn command_writers_and_readers() -> (CommandWriters, CommandReaders) {
|
||||
$crate::pastey::paste! {
|
||||
$(let ([<$field_name _writer>], [<$field_name _reader>]) = $crate::command::command_writer_and_reader();)*
|
||||
let command_writers = CommandWriters {
|
||||
$($field_name: [<$field_name _writer>]),*
|
||||
};
|
||||
let command_readers = CommandReaders {
|
||||
$($field_name: [<$field_name _reader>]),*
|
||||
};
|
||||
(command_writers, command_readers)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! read_commands_into_parameters {
|
||||
($self:ident, $($parameter_name:ident),*$(,)?) => {
|
||||
$crate::pastey::paste! {
|
||||
$($self.$parameter_name.read_command(&mut $self.command_readers.[<set_ $parameter_name>]);)*
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! handle_param_setters {
|
||||
($($(#[$m:meta])* $name:ident: $type:ty),*$(,)?) => {
|
||||
$crate::pastey::paste! {
|
||||
$(
|
||||
$(#[$m])*
|
||||
pub fn [<set_ $name>](&mut self, $name: impl Into<$crate::Value<$type>>, tween: $crate::tween::Tween) {
|
||||
self.command_writers.[<set_ $name>].write($crate::command::ValueChangeCommand {
|
||||
target: $name.into(),
|
||||
tween,
|
||||
})
|
||||
}
|
||||
)*
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use handle_param_setters;
|
||||
pub(crate) use read_commands_into_parameters;
|
||||
Vendored
+118
@@ -0,0 +1,118 @@
|
||||
use std::ops::{Add, AddAssign, Sub, SubAssign};
|
||||
|
||||
use crate::{Value, tween::Tweenable};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
#[cfg_attr(feature = "serde", serde(transparent))]
|
||||
/// Represents a change in volume.
|
||||
///
|
||||
/// Higher values increase the volume and lower values decrease it.
|
||||
/// Setting the volume of a sound to -60dB or lower makes it silent.
|
||||
pub struct Decibels(pub f32);
|
||||
|
||||
impl Decibels {
|
||||
/// The minimum decibel value at which a sound is considered
|
||||
/// silent.
|
||||
pub const SILENCE: Self = Self(-60.0);
|
||||
/// The decibel value that produces no change in volume.
|
||||
pub const IDENTITY: Self = Self(0.0);
|
||||
|
||||
/// Converts decibels to amplitude, a linear volume measurement.
|
||||
///
|
||||
/// This returns a number from `0.0`-`1.0` that you can multiply
|
||||
/// a singal by to change its volume.
|
||||
pub fn as_amplitude(self) -> f32 {
|
||||
// adding a special case for db == 0.0 improves
|
||||
// performance in the sound playback benchmarks
|
||||
// by about 7%
|
||||
if self == Self(0.0) {
|
||||
return 1.0;
|
||||
}
|
||||
if self <= Self::SILENCE {
|
||||
return 0.0;
|
||||
}
|
||||
10.0f32.powf(self.0 / 20.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Decibels {
|
||||
fn default() -> Self {
|
||||
Self::IDENTITY
|
||||
}
|
||||
}
|
||||
|
||||
impl Tweenable for Decibels {
|
||||
fn interpolate(a: Self, b: Self, amount: f64) -> Self {
|
||||
Self(Tweenable::interpolate(a.0, b.0, amount))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f32> for Decibels {
|
||||
fn from(value: f32) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f32> for Value<Decibels> {
|
||||
fn from(value: f32) -> Self {
|
||||
Value::Fixed(Decibels(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Decibels> for Value<Decibels> {
|
||||
fn from(value: Decibels) -> Self {
|
||||
Value::Fixed(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl Add<Decibels> for Decibels {
|
||||
type Output = Decibels;
|
||||
|
||||
fn add(self, rhs: Decibels) -> Self::Output {
|
||||
Self(self.0 + rhs.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl AddAssign<Decibels> for Decibels {
|
||||
fn add_assign(&mut self, rhs: Decibels) {
|
||||
self.0 += rhs.0;
|
||||
}
|
||||
}
|
||||
|
||||
impl Sub<Decibels> for Decibels {
|
||||
type Output = Decibels;
|
||||
|
||||
fn sub(self, rhs: Decibels) -> Self::Output {
|
||||
Self(self.0 - rhs.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl SubAssign<Decibels> for Decibels {
|
||||
fn sub_assign(&mut self, rhs: Decibels) {
|
||||
self.0 -= rhs.0;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
#[allow(clippy::float_cmp)]
|
||||
fn test() {
|
||||
/// A table of dB values to the corresponding amplitudes.
|
||||
// Data gathered from https://www.silisoftware.com/tools/db.php
|
||||
const TEST_CALCULATIONS: [(Decibels, f32); 6] = [
|
||||
(Decibels::IDENTITY, 1.0),
|
||||
(Decibels(3.0), 1.4125376),
|
||||
(Decibels(12.0), 3.9810717),
|
||||
(Decibels(-3.0), 0.70794576),
|
||||
(Decibels(-12.0), 0.25118864),
|
||||
(Decibels::SILENCE, 0.0),
|
||||
];
|
||||
|
||||
for (decibels, amplitude) in TEST_CALCULATIONS {
|
||||
assert!((decibels.as_amplitude() - amplitude).abs() < 0.00001);
|
||||
}
|
||||
|
||||
// test some special cases
|
||||
assert_eq!((Decibels::SILENCE - Decibels(100.0)).as_amplitude(), 0.0);
|
||||
}
|
||||
Vendored
+55
@@ -0,0 +1,55 @@
|
||||
/*!
|
||||
Modifies audio signals.
|
||||
|
||||
Any type that implements [`EffectBuilder`] can be added to a mixer track by
|
||||
using [`TrackBuilder::add_effect`](crate::track::TrackBuilder::add_effect). Kira
|
||||
comes with a number of commonly used effects.
|
||||
|
||||
If needed, you can create custom effects by implementing the [`EffectBuilder`]
|
||||
and [`Effect`] traits.
|
||||
*/
|
||||
|
||||
pub mod compressor;
|
||||
pub mod delay;
|
||||
pub mod distortion;
|
||||
pub mod eq_filter;
|
||||
pub mod filter;
|
||||
pub mod panning_control;
|
||||
pub mod reverb;
|
||||
pub mod volume_control;
|
||||
|
||||
use crate::{frame::Frame, info::Info};
|
||||
|
||||
/// Configures an effect.
|
||||
pub trait EffectBuilder {
|
||||
/// Allows the user to control the effect from gameplay code.
|
||||
type Handle;
|
||||
|
||||
/// Creates the effect and a handle to the effect.
|
||||
#[must_use]
|
||||
fn build(self) -> (Box<dyn Effect>, Self::Handle);
|
||||
}
|
||||
|
||||
/// Receives input audio from a mixer track and outputs modified audio.
|
||||
///
|
||||
/// For performance reasons, avoid allocating and deallocating memory in any methods
|
||||
/// of this trait besides [`on_change_sample_rate`](Effect::on_change_sample_rate).
|
||||
#[allow(unused_variables)]
|
||||
pub trait Effect: Send {
|
||||
/// Called when the effect is first sent to the renderer.
|
||||
fn init(&mut self, sample_rate: u32, internal_buffer_size: usize) {}
|
||||
|
||||
/// Called when the sample rate of the renderer is changed.
|
||||
fn on_change_sample_rate(&mut self, sample_rate: u32) {}
|
||||
|
||||
/// Called whenever a new batch of audio samples is requested by the backend.
|
||||
///
|
||||
/// This is a good place to put code that needs to run fairly frequently,
|
||||
/// but not for every single audio sample.
|
||||
fn on_start_processing(&mut self) {}
|
||||
|
||||
/// Transforms a slice of input [`Frame`]s.
|
||||
///
|
||||
/// `dt` is the time between each frame (in seconds).
|
||||
fn process(&mut self, input: &mut [Frame], dt: f64, info: &Info);
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
//! Reduces (or increases) the dynamic range of audio.
|
||||
|
||||
// Loosely based on https://www.musicdsp.org/en/latest/Effects/204-simple-compressor-class-c.html
|
||||
|
||||
mod builder;
|
||||
mod handle;
|
||||
|
||||
pub use builder::*;
|
||||
pub use handle::*;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::{
|
||||
Decibels, Mix, Parameter,
|
||||
command::{ValueChangeCommand, read_commands_into_parameters},
|
||||
command_writers_and_readers,
|
||||
frame::Frame,
|
||||
info::Info,
|
||||
};
|
||||
|
||||
use super::Effect;
|
||||
|
||||
struct Compressor {
|
||||
command_readers: CommandReaders,
|
||||
threshold: Parameter,
|
||||
ratio: Parameter,
|
||||
attack_duration: Parameter<Duration>,
|
||||
release_duration: Parameter<Duration>,
|
||||
makeup_gain: Parameter<Decibels>,
|
||||
mix: Parameter<Mix>,
|
||||
envelope_follower: [f32; 2],
|
||||
}
|
||||
|
||||
impl Compressor {
|
||||
#[must_use]
|
||||
fn new(builder: CompressorBuilder, command_readers: CommandReaders) -> Self {
|
||||
Self {
|
||||
command_readers,
|
||||
threshold: Parameter::new(builder.threshold, CompressorBuilder::DEFAULT_THRESHOLD),
|
||||
ratio: Parameter::new(builder.ratio, CompressorBuilder::DEFAULT_RATIO),
|
||||
attack_duration: Parameter::new(
|
||||
builder.attack_duration,
|
||||
CompressorBuilder::DEFAULT_ATTACK_DURATION,
|
||||
),
|
||||
release_duration: Parameter::new(
|
||||
builder.release_duration,
|
||||
CompressorBuilder::DEFAULT_RELEASE_DURATION,
|
||||
),
|
||||
makeup_gain: Parameter::new(
|
||||
builder.makeup_gain,
|
||||
CompressorBuilder::DEFAULT_MAKEUP_GAIN,
|
||||
),
|
||||
mix: Parameter::new(builder.mix, CompressorBuilder::DEFAULT_MIX),
|
||||
envelope_follower: [0.0; 2],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Effect for Compressor {
|
||||
fn on_start_processing(&mut self) {
|
||||
read_commands_into_parameters!(
|
||||
self,
|
||||
threshold,
|
||||
ratio,
|
||||
attack_duration,
|
||||
release_duration,
|
||||
makeup_gain,
|
||||
mix,
|
||||
);
|
||||
}
|
||||
|
||||
fn process(&mut self, input: &mut [Frame], dt: f64, info: &Info) {
|
||||
self.threshold.update(dt * input.len() as f64, info);
|
||||
self.ratio.update(dt * input.len() as f64, info);
|
||||
self.attack_duration.update(dt * input.len() as f64, info);
|
||||
self.release_duration.update(dt * input.len() as f64, info);
|
||||
self.makeup_gain.update(dt * input.len() as f64, info);
|
||||
self.mix.update(dt * input.len() as f64, info);
|
||||
|
||||
let threshold = self.threshold.value() as f32;
|
||||
let ratio = self.ratio.value() as f32;
|
||||
let attack_duration = self.attack_duration.value();
|
||||
let release_duration = self.release_duration.value();
|
||||
|
||||
let num_frames = input.len();
|
||||
for (i, frame) in input.iter_mut().enumerate() {
|
||||
let time_in_chunk = (i + 1) as f64 / num_frames as f64;
|
||||
let makeup_gain = self.makeup_gain.interpolated_value(time_in_chunk);
|
||||
let mix = self.mix.interpolated_value(time_in_chunk).0.clamp(0.0, 1.0);
|
||||
|
||||
let input_decibels = [
|
||||
20.0 * frame.left.abs().log10(),
|
||||
20.0 * frame.right.abs().log10(),
|
||||
];
|
||||
let over_decibels = input_decibels.map(|input| (input - threshold).max(0.0));
|
||||
for (i, envelope_follower) in self.envelope_follower.iter_mut().enumerate() {
|
||||
let duration = if *envelope_follower > over_decibels[i] {
|
||||
release_duration
|
||||
} else {
|
||||
attack_duration
|
||||
};
|
||||
let speed = (-1.0 / (duration.as_secs_f64() / dt)).exp();
|
||||
*envelope_follower =
|
||||
over_decibels[i] + speed as f32 * (*envelope_follower - over_decibels[i]);
|
||||
}
|
||||
let gain_reduction = self
|
||||
.envelope_follower
|
||||
.map(|envelope_follower| envelope_follower * ((1.0 / ratio) - 1.0));
|
||||
let amplitude =
|
||||
gain_reduction.map(|gain_reduction| 10.0f32.powf(gain_reduction / 20.0));
|
||||
let makeup_gain_linear = 10.0f32.powf(makeup_gain.0 / 20.0);
|
||||
let output = Frame {
|
||||
left: amplitude[0] * frame.left,
|
||||
right: amplitude[1] * frame.right,
|
||||
} * makeup_gain_linear;
|
||||
|
||||
*frame = output * mix.sqrt() + *frame * (1.0 - mix).sqrt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
command_writers_and_readers! {
|
||||
set_threshold: ValueChangeCommand<f64>,
|
||||
set_ratio: ValueChangeCommand<f64>,
|
||||
set_attack_duration: ValueChangeCommand<Duration>,
|
||||
set_release_duration: ValueChangeCommand<Duration>,
|
||||
set_makeup_gain: ValueChangeCommand<Decibels>,
|
||||
set_mix: ValueChangeCommand<Mix>,
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::{
|
||||
Decibels, Mix, Value,
|
||||
effect::{Effect, EffectBuilder},
|
||||
};
|
||||
|
||||
use super::{Compressor, CompressorHandle, command_writers_and_readers};
|
||||
|
||||
/// Configures a compressor.
|
||||
pub struct CompressorBuilder {
|
||||
/// The volume above which volume will start to be decreased (in decibels).
|
||||
pub threshold: Value<f64>,
|
||||
/// How much the signal will be compressed.
|
||||
///
|
||||
/// A ratio of `2.0` (or 2 to 1) means an increase of 3dB will
|
||||
/// become an increase of 1.5dB. Ratios between `0.0` and `1.0`
|
||||
/// will actually expand the audio.
|
||||
pub ratio: Value<f64>,
|
||||
/// How much time it takes for the volume attenuation to ramp up once
|
||||
/// the input volume exceeds the threshold.
|
||||
pub attack_duration: Value<Duration>,
|
||||
/// How much time it takes for the volume attenuation to relax once
|
||||
/// the input volume dips below the threshold.
|
||||
pub release_duration: Value<Duration>,
|
||||
/// The amount to change the volume after processing (in dB).
|
||||
///
|
||||
/// This can be used to compensate for the decrease in volume resulting
|
||||
/// from compression. This is only applied to the wet signal, nto the
|
||||
/// dry signal.
|
||||
pub makeup_gain: Value<Decibels>,
|
||||
/// How much dry (unprocessed) signal should be blended
|
||||
/// with the wet (processed) signal.
|
||||
pub mix: Value<Mix>,
|
||||
}
|
||||
|
||||
impl CompressorBuilder {
|
||||
pub(crate) const DEFAULT_THRESHOLD: f64 = 0.0;
|
||||
pub(crate) const DEFAULT_RATIO: f64 = 1.0;
|
||||
pub(crate) const DEFAULT_ATTACK_DURATION: Duration = Duration::from_millis(10);
|
||||
pub(crate) const DEFAULT_RELEASE_DURATION: Duration = Duration::from_millis(100);
|
||||
pub(crate) const DEFAULT_MAKEUP_GAIN: Decibels = Decibels(0.0);
|
||||
pub(crate) const DEFAULT_MIX: Mix = Mix::WET;
|
||||
|
||||
/// Creates a new [`CompressorBuilder`] with the default settings.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
threshold: Value::Fixed(Self::DEFAULT_THRESHOLD),
|
||||
ratio: Value::Fixed(Self::DEFAULT_RATIO),
|
||||
attack_duration: Value::Fixed(Self::DEFAULT_ATTACK_DURATION),
|
||||
release_duration: Value::Fixed(Self::DEFAULT_RELEASE_DURATION),
|
||||
makeup_gain: Value::Fixed(Self::DEFAULT_MAKEUP_GAIN),
|
||||
mix: Value::Fixed(Self::DEFAULT_MIX),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the volume above which volume will start to be decreased (in decibels).
|
||||
#[must_use = "This method consumes self and returns a modified CompressorBuilder, so the return value should be used"]
|
||||
pub fn threshold(self, threshold: impl Into<Value<f64>>) -> Self {
|
||||
Self {
|
||||
threshold: threshold.into(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets how much the signal will be compressed.
|
||||
///
|
||||
/// A ratio of `2.0` (or 2 to 1) means an increase of 3dB will
|
||||
/// become an increase of 1.5dB. Ratios between `0.0` and `1.0`
|
||||
/// will actually expand the audio.
|
||||
#[must_use = "This method consumes self and returns a modified CompressorBuilder, so the return value should be used"]
|
||||
pub fn ratio(self, ratio: impl Into<Value<f64>>) -> Self {
|
||||
Self {
|
||||
ratio: ratio.into(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets how much time it takes for the volume attenuation to ramp up once
|
||||
/// the input volume exceeds the threshold.
|
||||
#[must_use = "This method consumes self and returns a modified CompressorBuilder, so the return value should be used"]
|
||||
pub fn attack_duration(self, attack_duration: impl Into<Value<Duration>>) -> Self {
|
||||
Self {
|
||||
attack_duration: attack_duration.into(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets how much time it takes for the volume attenuation to relax once
|
||||
/// the input volume dips below the threshold.
|
||||
#[must_use = "This method consumes self and returns a modified CompressorBuilder, so the return value should be used"]
|
||||
pub fn release_duration(self, release_duration: impl Into<Value<Duration>>) -> Self {
|
||||
Self {
|
||||
release_duration: release_duration.into(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the amount to change the volume after processing (in decibels).
|
||||
///
|
||||
/// This can be used to compensate for the decrease in volume resulting
|
||||
/// from compression. This is only applied to the wet signal, nto the
|
||||
/// dry signal.
|
||||
#[must_use = "This method consumes self and returns a modified CompressorBuilder, so the return value should be used"]
|
||||
pub fn makeup_gain(self, makeup_gain: impl Into<Value<Decibels>>) -> Self {
|
||||
Self {
|
||||
makeup_gain: makeup_gain.into(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets how much dry (unprocessed) signal should be blended
|
||||
/// with the wet (processed) signal.
|
||||
#[must_use = "This method consumes self and returns a modified CompressorBuilder, so the return value should be used"]
|
||||
pub fn mix(self, mix: impl Into<Value<Mix>>) -> Self {
|
||||
Self {
|
||||
mix: mix.into(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CompressorBuilder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl EffectBuilder for CompressorBuilder {
|
||||
type Handle = CompressorHandle;
|
||||
|
||||
fn build(self) -> (Box<dyn Effect>, Self::Handle) {
|
||||
let (command_writers, command_readers) = command_writers_and_readers();
|
||||
(
|
||||
Box::new(Compressor::new(self, command_readers)),
|
||||
CompressorHandle { command_writers },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::{Decibels, Mix, command::handle_param_setters};
|
||||
|
||||
use super::CommandWriters;
|
||||
|
||||
/// Controls a compressor.
|
||||
#[derive(Debug)]
|
||||
pub struct CompressorHandle {
|
||||
pub(super) command_writers: CommandWriters,
|
||||
}
|
||||
|
||||
impl CompressorHandle {
|
||||
handle_param_setters! {
|
||||
/// Sets the volume above which volume will start to be decreased (in decibels).
|
||||
threshold: f64,
|
||||
|
||||
/// Sets how much the signal will be compressed.
|
||||
///
|
||||
/// A ratio of `2.0` (or 2 to 1) means an increase of 3dB will
|
||||
/// become an increase of 1.5dB. Ratios between `0.0` and `1.0`
|
||||
/// will actually expand the audio.
|
||||
ratio: f64,
|
||||
|
||||
/// Sets how much time it takes for the volume attenuation to ramp up once
|
||||
/// the input volume exceeds the threshold.
|
||||
attack_duration: Duration,
|
||||
|
||||
/// Sets how much time it takes for the volume attenuation to relax once
|
||||
/// the input volume dips below the threshold.
|
||||
release_duration: Duration,
|
||||
|
||||
/// Sets the amount to change the volume after processing (in dB).
|
||||
///
|
||||
/// This can be used to compensate for the decrease in volume resulting
|
||||
/// from compression. This is only applied to the wet signal, nto the
|
||||
/// dry signal.
|
||||
makeup_gain: Decibels,
|
||||
|
||||
/// Sets how much dry (unprocessed) signal should be blended
|
||||
/// with the wet (processed) signal.
|
||||
mix: Mix,
|
||||
}
|
||||
}
|
||||
Vendored
+114
@@ -0,0 +1,114 @@
|
||||
//! Adds echoes to a sound.
|
||||
|
||||
mod builder;
|
||||
mod handle;
|
||||
|
||||
pub use builder::*;
|
||||
pub use handle::*;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::{
|
||||
Decibels, Mix, Parameter,
|
||||
command::{ValueChangeCommand, read_commands_into_parameters},
|
||||
command_writers_and_readers,
|
||||
frame::Frame,
|
||||
info::Info,
|
||||
};
|
||||
|
||||
use super::Effect;
|
||||
|
||||
struct Delay {
|
||||
command_readers: CommandReaders,
|
||||
delay_time: Duration,
|
||||
feedback: Parameter<Decibels>,
|
||||
mix: Parameter<Mix>,
|
||||
buffer: Vec<Frame>,
|
||||
feedback_effects: Vec<Box<dyn Effect>>,
|
||||
temp_buffer: Vec<Frame>,
|
||||
}
|
||||
|
||||
impl Delay {
|
||||
/// Creates a new delay effect.
|
||||
#[must_use]
|
||||
fn new(builder: DelayBuilder, command_readers: CommandReaders) -> Self {
|
||||
Self {
|
||||
command_readers,
|
||||
delay_time: builder.delay_time,
|
||||
feedback: Parameter::new(builder.feedback, Decibels(-6.0)),
|
||||
mix: Parameter::new(builder.mix, Mix(0.5)),
|
||||
buffer: Vec::with_capacity(0),
|
||||
feedback_effects: builder.feedback_effects,
|
||||
temp_buffer: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Effect for Delay {
|
||||
fn init(&mut self, sample_rate: u32, internal_buffer_size: usize) {
|
||||
let delay_time_frames = (self.delay_time.as_secs_f64() * sample_rate as f64) as usize;
|
||||
self.buffer = vec![Frame::ZERO; delay_time_frames];
|
||||
self.temp_buffer = vec![Frame::ZERO; internal_buffer_size];
|
||||
for effect in &mut self.feedback_effects {
|
||||
effect.init(sample_rate, internal_buffer_size);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_change_sample_rate(&mut self, sample_rate: u32) {
|
||||
let delay_time_frames = (self.delay_time.as_secs_f64() * sample_rate as f64) as usize;
|
||||
self.buffer = vec![Frame::ZERO; delay_time_frames];
|
||||
for effect in &mut self.feedback_effects {
|
||||
effect.on_change_sample_rate(sample_rate);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_start_processing(&mut self) {
|
||||
read_commands_into_parameters!(self, feedback, mix);
|
||||
for effect in &mut self.feedback_effects {
|
||||
effect.on_start_processing();
|
||||
}
|
||||
}
|
||||
|
||||
fn process(&mut self, input: &mut [Frame], dt: f64, info: &Info) {
|
||||
self.feedback.update(dt * input.len() as f64, info);
|
||||
self.mix.update(dt * input.len() as f64, info);
|
||||
|
||||
for input in input.chunks_mut(self.buffer.len()) {
|
||||
let num_frames = input.len();
|
||||
|
||||
// read from the beginning of the buffer and apply effects and feedback gain
|
||||
self.temp_buffer[..input.len()].copy_from_slice(&self.buffer[..input.len()]);
|
||||
for effect in &mut self.feedback_effects {
|
||||
effect.process(&mut self.temp_buffer[..input.len()], dt, info);
|
||||
}
|
||||
for (i, frame) in self.temp_buffer[..input.len()].iter_mut().enumerate() {
|
||||
let time_in_chunk = (i + 1) as f64 / num_frames as f64;
|
||||
let feedback = self.feedback.interpolated_value(time_in_chunk);
|
||||
*frame *= feedback.as_amplitude();
|
||||
}
|
||||
|
||||
// write input + read buffer to the end of the buffer
|
||||
self.buffer.copy_within(input.len().., 0);
|
||||
let write_range = self.buffer.len() - input.len()..;
|
||||
for ((out, input), read) in self.buffer[write_range]
|
||||
.iter_mut()
|
||||
.zip(input.iter())
|
||||
.zip(&mut self.temp_buffer[..input.len()])
|
||||
{
|
||||
*out = *input + *read;
|
||||
}
|
||||
|
||||
// output mix of input and read buffer
|
||||
for (i, frame) in input.iter_mut().enumerate() {
|
||||
let time_in_chunk = (i + 1) as f64 / num_frames as f64;
|
||||
let mix = self.mix.interpolated_value(time_in_chunk).0.clamp(0.0, 1.0);
|
||||
*frame = self.temp_buffer[i] * mix.sqrt() + *frame * (1.0 - mix).sqrt()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
command_writers_and_readers! {
|
||||
set_feedback: ValueChangeCommand<Decibels>,
|
||||
set_mix: ValueChangeCommand<Mix>,
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::{
|
||||
Decibels, Mix, Value,
|
||||
effect::{Effect, EffectBuilder},
|
||||
};
|
||||
|
||||
use super::{Delay, DelayHandle, command_writers_and_readers};
|
||||
|
||||
/// Configures a delay effect.
|
||||
pub struct DelayBuilder {
|
||||
/// The amount of time the input audio is delayed by.
|
||||
pub(super) delay_time: Duration,
|
||||
/// The amount of feedback.
|
||||
pub(super) feedback: Value<Decibels>,
|
||||
/// Effects that should be applied in the feedback loop.
|
||||
pub(super) feedback_effects: Vec<Box<dyn Effect>>,
|
||||
/// How much dry (unprocessed) signal should be blended
|
||||
/// with the wet (processed) signal.
|
||||
pub(super) mix: Value<Mix>,
|
||||
}
|
||||
|
||||
impl DelayBuilder {
|
||||
/// Creates a new [`DelayBuilder`] with the default settings.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Sets the amount of time the input audio is delayed by.
|
||||
#[must_use = "This method consumes self and returns a modified DelayBuilder, so the return value should be used"]
|
||||
pub fn delay_time(self, delay_time: Duration) -> Self {
|
||||
Self { delay_time, ..self }
|
||||
}
|
||||
|
||||
/// Sets the amount of feedback.
|
||||
#[must_use = "This method consumes self and returns a modified DelayBuilder, so the return value should be used"]
|
||||
pub fn feedback(self, feedback: impl Into<Value<Decibels>>) -> Self {
|
||||
Self {
|
||||
feedback: feedback.into(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds an effect to the feedback loop.
|
||||
pub fn add_feedback_effect<B: EffectBuilder>(&mut self, builder: B) -> B::Handle {
|
||||
let (effect, handle) = builder.build();
|
||||
self.feedback_effects.push(effect);
|
||||
handle
|
||||
}
|
||||
|
||||
/// Adds an effect to the feedback loop and returns the [`DelayBuilder`].
|
||||
///
|
||||
/// If you need a handle to the newly added effect, use [`DelayBuilder::add_feedback_effect`].
|
||||
pub fn with_feedback_effect<B: EffectBuilder>(mut self, builder: B) -> Self {
|
||||
self.add_feedback_effect(builder);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets how much dry (unprocessed) signal should be blended
|
||||
/// with the wet (processed) signal. `0.0` means only the dry
|
||||
/// signal will be heard. `1.0` means only the wet signal will
|
||||
/// be heard.
|
||||
#[must_use = "This method consumes self and returns a modified DelayBuilder, so the return value should be used"]
|
||||
pub fn mix(self, mix: impl Into<Value<Mix>>) -> Self {
|
||||
Self {
|
||||
mix: mix.into(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DelayBuilder {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
delay_time: Duration::from_millis(500),
|
||||
feedback: Value::Fixed(Decibels(-6.0)),
|
||||
feedback_effects: vec![],
|
||||
mix: Value::Fixed(Mix(0.5)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EffectBuilder for DelayBuilder {
|
||||
type Handle = DelayHandle;
|
||||
|
||||
fn build(self) -> (Box<dyn Effect>, Self::Handle) {
|
||||
let (command_writers, command_readers) = command_writers_and_readers();
|
||||
(
|
||||
Box::new(Delay::new(self, command_readers)),
|
||||
DelayHandle { command_writers },
|
||||
)
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
use crate::{Decibels, Mix, command::handle_param_setters};
|
||||
|
||||
use super::CommandWriters;
|
||||
|
||||
/// Controls a delay effect.
|
||||
#[derive(Debug)]
|
||||
pub struct DelayHandle {
|
||||
pub(super) command_writers: CommandWriters,
|
||||
}
|
||||
|
||||
impl DelayHandle {
|
||||
handle_param_setters! {
|
||||
/// Sets the amount of feedback.
|
||||
feedback: Decibels,
|
||||
|
||||
/// Sets how much dry (unprocessed) signal should be blended
|
||||
/// with the wet (processed) signal.
|
||||
mix: Mix,
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
//! Makes a sound harsher and noisier.
|
||||
|
||||
mod builder;
|
||||
mod handle;
|
||||
|
||||
pub use builder::*;
|
||||
pub use handle::*;
|
||||
|
||||
use crate::{
|
||||
Decibels, Mix, Parameter,
|
||||
command::{ValueChangeCommand, read_commands_into_parameters},
|
||||
command_writers_and_readers,
|
||||
effect::Effect,
|
||||
frame::Frame,
|
||||
info::Info,
|
||||
};
|
||||
|
||||
/// Different types of distortion.
|
||||
#[derive(Debug, Copy, Clone, PartialEq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum DistortionKind {
|
||||
/// The signal will be clamped to the -1.0 to 1.0 range.
|
||||
///
|
||||
/// This creates a harsh distortion when the signal leaves
|
||||
/// the -1.0 to 1.0 range.
|
||||
HardClip,
|
||||
/// The signal will be kept in the -1.0 to 1.0 range,
|
||||
/// and the slope will gradually decrease as it reaches
|
||||
/// -1.0 or 1.0.
|
||||
///
|
||||
/// This creates a smoother distortion that gradually
|
||||
/// becomes more prominent as the signal becomes louder.
|
||||
SoftClip,
|
||||
}
|
||||
|
||||
impl Default for DistortionKind {
|
||||
fn default() -> Self {
|
||||
Self::HardClip
|
||||
}
|
||||
}
|
||||
|
||||
struct Distortion {
|
||||
command_readers: CommandReaders,
|
||||
kind: DistortionKind,
|
||||
drive: Parameter<Decibels>,
|
||||
mix: Parameter<Mix>,
|
||||
}
|
||||
|
||||
impl Effect for Distortion {
|
||||
fn on_start_processing(&mut self) {
|
||||
if let Some(kind) = self.command_readers.set_kind.read() {
|
||||
self.kind = kind;
|
||||
}
|
||||
read_commands_into_parameters!(self, drive, mix);
|
||||
}
|
||||
|
||||
fn process(&mut self, input: &mut [Frame], dt: f64, info: &Info) {
|
||||
self.drive.update(dt * input.len() as f64, info);
|
||||
self.mix.update(dt * input.len() as f64, info);
|
||||
|
||||
let num_frames = input.len();
|
||||
for (i, frame) in input.iter_mut().enumerate() {
|
||||
let time_in_chunk = (i + 1) as f64 / num_frames as f64;
|
||||
let drive = self.drive.interpolated_value(time_in_chunk).as_amplitude();
|
||||
let mix = self.mix.interpolated_value(time_in_chunk).0.clamp(0.0, 1.0);
|
||||
|
||||
let mut output = *frame * drive;
|
||||
output = match self.kind {
|
||||
DistortionKind::HardClip => {
|
||||
Frame::new(output.left.clamp(-1.0, 1.0), output.right.clamp(-1.0, 1.0))
|
||||
}
|
||||
DistortionKind::SoftClip => Frame::new(
|
||||
output.left / (1.0 + output.left.abs()),
|
||||
output.right / (1.0 + output.right.abs()),
|
||||
),
|
||||
};
|
||||
output /= drive;
|
||||
|
||||
*frame = output * mix.sqrt() + *frame * (1.0 - mix).sqrt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
command_writers_and_readers! {
|
||||
set_kind: DistortionKind,
|
||||
set_drive: ValueChangeCommand<Decibels>,
|
||||
set_mix: ValueChangeCommand<Mix>,
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use crate::{
|
||||
Decibels, Mix, Parameter, Value,
|
||||
effect::{Effect, EffectBuilder},
|
||||
};
|
||||
|
||||
use super::{Distortion, DistortionKind, command_writers_and_readers, handle::DistortionHandle};
|
||||
|
||||
/// Configures a distortion effect.
|
||||
#[derive(Debug, Copy, Clone, PartialEq)]
|
||||
pub struct DistortionBuilder {
|
||||
/// The kind of distortion to use.
|
||||
pub kind: DistortionKind,
|
||||
/// The factor to multiply the signal by before applying
|
||||
/// the distortion.
|
||||
pub drive: Value<Decibels>,
|
||||
/// How much dry (unprocessed) signal should be blended
|
||||
/// with the wet (processed) signal.
|
||||
pub mix: Value<Mix>,
|
||||
}
|
||||
|
||||
impl DistortionBuilder {
|
||||
/// Creates a new [`DistortionBuilder`] with the default settings.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Sets the kind of distortion to use.
|
||||
#[must_use = "This method consumes self and returns a modified DistortionBuilder, so the return value should be used"]
|
||||
pub fn kind(self, kind: DistortionKind) -> Self {
|
||||
Self { kind, ..self }
|
||||
}
|
||||
|
||||
/// Sets the factor to multiply the signal by before applying
|
||||
/// the distortion.
|
||||
#[must_use = "This method consumes self and returns a modified DistortionBuilder, so the return value should be used"]
|
||||
pub fn drive(self, drive: impl Into<Value<Decibels>>) -> Self {
|
||||
Self {
|
||||
drive: drive.into(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets how much dry (unprocessed) signal should be blended
|
||||
/// with the wet (processed) signal. `0.0` means only the dry
|
||||
/// signal will be heard. `1.0` means only the wet signal will
|
||||
/// be heard.
|
||||
#[must_use = "This method consumes self and returns a modified DistortionBuilder, so the return value should be used"]
|
||||
pub fn mix(self, mix: impl Into<Value<Mix>>) -> Self {
|
||||
Self {
|
||||
mix: mix.into(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DistortionBuilder {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
kind: Default::default(),
|
||||
drive: Value::Fixed(Decibels::IDENTITY),
|
||||
mix: Value::Fixed(Mix::WET),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EffectBuilder for DistortionBuilder {
|
||||
type Handle = DistortionHandle;
|
||||
|
||||
fn build(self) -> (Box<dyn Effect>, Self::Handle) {
|
||||
let (command_writers, command_readers) = command_writers_and_readers();
|
||||
(
|
||||
Box::new(Distortion {
|
||||
command_readers,
|
||||
kind: self.kind,
|
||||
drive: Parameter::new(self.drive, Decibels::IDENTITY),
|
||||
mix: Parameter::new(self.mix, Mix::WET),
|
||||
}),
|
||||
DistortionHandle { command_writers },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use crate::{Decibels, Mix, command::handle_param_setters};
|
||||
|
||||
use super::{CommandWriters, DistortionKind};
|
||||
|
||||
/// Controls a distortion effect.
|
||||
#[derive(Debug)]
|
||||
pub struct DistortionHandle {
|
||||
pub(super) command_writers: CommandWriters,
|
||||
}
|
||||
|
||||
impl DistortionHandle {
|
||||
/// Sets the kind of distortion to use.
|
||||
pub fn set_kind(&mut self, kind: DistortionKind) {
|
||||
self.command_writers.set_kind.write(kind)
|
||||
}
|
||||
|
||||
handle_param_setters! {
|
||||
/// Sets how much distortion should be applied.
|
||||
drive: Decibels,
|
||||
|
||||
/// Sets how much dry (unprocessed) signal should be blended
|
||||
/// with the wet (processed) signal.
|
||||
mix: Mix,
|
||||
}
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
//! Adjusts the volume of a frequency range of a sound.
|
||||
|
||||
// Code is based on https://cytomic.com/files/dsp/SvfLinearTrapOptimised2.pdf
|
||||
|
||||
mod builder;
|
||||
mod handle;
|
||||
|
||||
pub use builder::*;
|
||||
pub use handle::*;
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::{
|
||||
Decibels, Parameter,
|
||||
command::{ValueChangeCommand, read_commands_into_parameters},
|
||||
command_writers_and_readers,
|
||||
frame::Frame,
|
||||
info::Info,
|
||||
};
|
||||
|
||||
use super::Effect;
|
||||
|
||||
const MIN_Q: f64 = 0.01;
|
||||
|
||||
struct EqFilter {
|
||||
command_readers: CommandReaders,
|
||||
kind: EqFilterKind,
|
||||
frequency: Parameter,
|
||||
gain: Parameter<Decibels>,
|
||||
q: Parameter,
|
||||
ic1eq: Frame,
|
||||
ic2eq: Frame,
|
||||
}
|
||||
|
||||
impl EqFilter {
|
||||
#[must_use]
|
||||
fn new(builder: EqFilterBuilder, command_readers: CommandReaders) -> Self {
|
||||
Self {
|
||||
command_readers,
|
||||
kind: builder.kind,
|
||||
frequency: Parameter::new(builder.frequency, 500.0),
|
||||
gain: Parameter::new(builder.gain, Decibels::IDENTITY),
|
||||
q: Parameter::new(builder.q, 1.0),
|
||||
ic1eq: Frame::ZERO,
|
||||
ic2eq: Frame::ZERO,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Effect for EqFilter {
|
||||
fn on_start_processing(&mut self) {
|
||||
if let Some(kind) = self.command_readers.set_kind.read() {
|
||||
self.kind = kind;
|
||||
}
|
||||
read_commands_into_parameters!(self, frequency, gain, q);
|
||||
}
|
||||
|
||||
fn process(&mut self, input: &mut [Frame], dt: f64, info: &Info) {
|
||||
self.frequency.update(dt * input.len() as f64, info);
|
||||
self.gain.update(dt * input.len() as f64, info);
|
||||
self.q.update(dt * input.len() as f64, info);
|
||||
|
||||
let num_frames = input.len();
|
||||
for (i, frame) in input.iter_mut().enumerate() {
|
||||
let time_in_chunk = (i + 1) as f64 / num_frames as f64;
|
||||
let frequency = self.frequency.interpolated_value(time_in_chunk);
|
||||
let q = self.q.interpolated_value(time_in_chunk);
|
||||
let gain = self.gain.interpolated_value(time_in_chunk);
|
||||
|
||||
let Coefficients {
|
||||
a1,
|
||||
a2,
|
||||
a3,
|
||||
m0,
|
||||
m1,
|
||||
m2,
|
||||
} = Coefficients::calculate(self.kind, frequency, q, gain, dt);
|
||||
let v3 = *frame - self.ic2eq;
|
||||
let v1 = self.ic1eq * (a1 as f32) + v3 * (a2 as f32);
|
||||
let v2 = self.ic2eq + self.ic1eq * (a2 as f32) + v3 * (a3 as f32);
|
||||
self.ic1eq = v1 * 2.0 - self.ic1eq;
|
||||
self.ic2eq = v2 * 2.0 - self.ic2eq;
|
||||
*frame = *frame * (m0 as f32) + v1 * (m1 as f32) + v2 * (m2 as f32)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The shape of the frequency adjustment curve.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum EqFilterKind {
|
||||
/// Frequencies around the user-defined frequency are adjusted.
|
||||
Bell,
|
||||
/// Frequencies around and lower than the user-defined frequency are adjusted.
|
||||
LowShelf,
|
||||
/// Frequencies around and higher than the user-defined frequency are adjusted.
|
||||
HighShelf,
|
||||
}
|
||||
|
||||
struct Coefficients {
|
||||
a1: f64,
|
||||
a2: f64,
|
||||
a3: f64,
|
||||
m0: f64,
|
||||
m1: f64,
|
||||
m2: f64,
|
||||
}
|
||||
|
||||
impl Coefficients {
|
||||
#[must_use]
|
||||
fn calculate(kind: EqFilterKind, frequency: f64, q: f64, gain: Decibels, dt: f64) -> Self {
|
||||
// In my testing, the filter goes unstable when the frequency exceeds half the sample rate,
|
||||
// so I'm clamping this value to 0.5
|
||||
let relative_frequency = (frequency * dt).clamp(0.0001, 0.5);
|
||||
let q = q.max(MIN_Q);
|
||||
match kind {
|
||||
EqFilterKind::Bell => {
|
||||
let a = 10.0f64.powf(gain.0 as f64 / 40.0);
|
||||
let g = (PI * relative_frequency).tan();
|
||||
let k = 1.0 / (q * a);
|
||||
let a1 = 1.0 / (1.0 + g * (g + k));
|
||||
let a2 = g * a1;
|
||||
let a3 = g * a2;
|
||||
let m0 = 1.0;
|
||||
let m1 = k * (a * a - 1.0);
|
||||
let m2 = 0.0;
|
||||
Self {
|
||||
a1,
|
||||
a2,
|
||||
a3,
|
||||
m0,
|
||||
m1,
|
||||
m2,
|
||||
}
|
||||
}
|
||||
EqFilterKind::LowShelf => {
|
||||
let a = 10.0f64.powf(gain.0 as f64 / 40.0);
|
||||
let g = (PI * relative_frequency).tan() / a.sqrt();
|
||||
let k = 1.0 / q;
|
||||
let a1 = 1.0 / (1.0 + g * (g + k));
|
||||
let a2 = g * a1;
|
||||
let a3 = g * a2;
|
||||
let m0 = 1.0;
|
||||
let m1 = k * (a - 1.0);
|
||||
let m2 = a * a - 1.0;
|
||||
Self {
|
||||
a1,
|
||||
a2,
|
||||
a3,
|
||||
m0,
|
||||
m1,
|
||||
m2,
|
||||
}
|
||||
}
|
||||
EqFilterKind::HighShelf => {
|
||||
let a = 10.0f64.powf(gain.0 as f64 / 40.0);
|
||||
let g = (PI * relative_frequency).tan() * a.sqrt();
|
||||
let k = 1.0 / q;
|
||||
let a1 = 1.0 / (1.0 + g * (g + k));
|
||||
let a2 = g * a1;
|
||||
let a3 = g * a2;
|
||||
let m0 = a * a;
|
||||
let m1 = k * (1.0 - a) * a;
|
||||
let m2 = 1.0 - a * a;
|
||||
Self {
|
||||
a1,
|
||||
a2,
|
||||
a3,
|
||||
m0,
|
||||
m1,
|
||||
m2,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
command_writers_and_readers! {
|
||||
set_kind: EqFilterKind,
|
||||
set_frequency: ValueChangeCommand<f64>,
|
||||
set_gain: ValueChangeCommand<Decibels>,
|
||||
set_q: ValueChangeCommand<f64>,
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use crate::{Decibels, Value, effect::EffectBuilder};
|
||||
|
||||
use super::{EqFilter, EqFilterHandle, EqFilterKind, command_writers_and_readers};
|
||||
|
||||
/// Configures an EQ filter.
|
||||
pub struct EqFilterBuilder {
|
||||
/// The shape of the frequency adjustment curve.
|
||||
pub kind: EqFilterKind,
|
||||
/// The "center" or "corner" of the frequency range to adjust in Hz
|
||||
/// (for bell or shelf curves, respectively).
|
||||
pub frequency: Value<f64>,
|
||||
/// The volume adjustment for frequencies in the specified range (in decibels).
|
||||
pub gain: Value<Decibels>,
|
||||
/// The width of the frequency range to adjust.
|
||||
///
|
||||
/// A higher Q value results in a narrower range of frequencies being adjusted.
|
||||
/// The value should be greater than `0.0`.
|
||||
pub q: Value<f64>,
|
||||
}
|
||||
|
||||
impl EqFilterBuilder {
|
||||
/// Creates a new `EqFilterBuilder`.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
kind: EqFilterKind,
|
||||
frequency: impl Into<Value<f64>>,
|
||||
gain: impl Into<Value<Decibels>>,
|
||||
q: impl Into<Value<f64>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
frequency: frequency.into(),
|
||||
gain: gain.into(),
|
||||
q: q.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EffectBuilder for EqFilterBuilder {
|
||||
type Handle = EqFilterHandle;
|
||||
|
||||
fn build(self) -> (Box<dyn crate::effect::Effect>, Self::Handle) {
|
||||
let (command_writers, command_readers) = command_writers_and_readers();
|
||||
(
|
||||
Box::new(EqFilter::new(self, command_readers)),
|
||||
EqFilterHandle { command_writers },
|
||||
)
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
use crate::{Decibels, command::handle_param_setters};
|
||||
|
||||
use super::{CommandWriters, EqFilterKind};
|
||||
|
||||
/// Controls an EQ filter.
|
||||
#[derive(Debug)]
|
||||
pub struct EqFilterHandle {
|
||||
pub(super) command_writers: CommandWriters,
|
||||
}
|
||||
|
||||
impl EqFilterHandle {
|
||||
/// Sets the shape of the frequency adjustment curve.
|
||||
pub fn set_kind(&mut self, kind: EqFilterKind) {
|
||||
self.command_writers.set_kind.write(kind)
|
||||
}
|
||||
|
||||
handle_param_setters! {
|
||||
/// Sets the "center" or "corner" of the frequency range to adjust in Hz
|
||||
/// (for bell or shelf curves, respectively).
|
||||
frequency: f64,
|
||||
|
||||
/// Sets the volume adjustment for frequencies in the specified range (in decibels).
|
||||
gain: Decibels,
|
||||
|
||||
/// Sets the width of the frequency range to adjust.
|
||||
///
|
||||
/// A higher Q value results in a narrower range of frequencies being adjusted.
|
||||
/// The value should be greater than `0.0`.
|
||||
q: f64,
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
//! Removes frequencies from a sound.
|
||||
|
||||
mod builder;
|
||||
mod handle;
|
||||
|
||||
pub use builder::*;
|
||||
pub use handle::*;
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::{
|
||||
Mix, Parameter,
|
||||
command::{ValueChangeCommand, read_commands_into_parameters},
|
||||
command_writers_and_readers,
|
||||
effect::Effect,
|
||||
frame::Frame,
|
||||
info::Info,
|
||||
};
|
||||
|
||||
// This filter code is based on the filter code from baseplug:
|
||||
// https://github.com/wrl/baseplug/blob/trunk/examples/svf/svf_simper.rs
|
||||
|
||||
/// The frequencies that the filter will remove.
|
||||
#[derive(Debug, Copy, Clone, PartialEq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum FilterMode {
|
||||
/// Removes frequencies above the cutoff frequency.
|
||||
LowPass,
|
||||
/// Removes frequencies above and below the cutoff frequency.
|
||||
BandPass,
|
||||
/// Removes frequencies below the cutoff frequency.
|
||||
HighPass,
|
||||
/// Removes frequencies around the cutoff frequency.
|
||||
Notch,
|
||||
}
|
||||
|
||||
struct Filter {
|
||||
command_readers: CommandReaders,
|
||||
mode: FilterMode,
|
||||
cutoff: Parameter,
|
||||
resonance: Parameter,
|
||||
mix: Parameter<Mix>,
|
||||
ic1eq: Frame,
|
||||
ic2eq: Frame,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Creates a new filter.
|
||||
#[must_use]
|
||||
fn new(builder: FilterBuilder, command_readers: CommandReaders) -> Self {
|
||||
Self {
|
||||
command_readers,
|
||||
mode: builder.mode,
|
||||
cutoff: Parameter::new(builder.cutoff, 1000.0),
|
||||
resonance: Parameter::new(builder.resonance, 0.0),
|
||||
mix: Parameter::new(builder.mix, Mix(1.0)),
|
||||
ic1eq: Frame::ZERO,
|
||||
ic2eq: Frame::ZERO,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Effect for Filter {
|
||||
fn on_start_processing(&mut self) {
|
||||
if let Some(mode) = self.command_readers.set_mode.read() {
|
||||
self.mode = mode;
|
||||
}
|
||||
read_commands_into_parameters!(self, cutoff, resonance, mix);
|
||||
}
|
||||
|
||||
fn process(&mut self, input: &mut [Frame], dt: f64, info: &Info) {
|
||||
self.cutoff.update(dt * input.len() as f64, info);
|
||||
self.resonance.update(dt * input.len() as f64, info);
|
||||
self.mix.update(dt * input.len() as f64, info);
|
||||
|
||||
let num_frames = input.len();
|
||||
for (i, frame) in input.iter_mut().enumerate() {
|
||||
let time_in_chunk = (i + 1) as f64 / num_frames as f64;
|
||||
let cutoff = self.cutoff.interpolated_value(time_in_chunk);
|
||||
let resonance = self
|
||||
.resonance
|
||||
.interpolated_value(time_in_chunk)
|
||||
.clamp(0.0, 1.0);
|
||||
let mix = self.mix.interpolated_value(time_in_chunk).0.clamp(0.0, 1.0);
|
||||
|
||||
let sample_rate = 1.0 / dt;
|
||||
let g = (PI * (cutoff / sample_rate).clamp(0.0001, 0.5)).tan();
|
||||
let k = 2.0 - (1.9 * resonance);
|
||||
let a1 = 1.0 / (1.0 + (g * (g + k)));
|
||||
let a2 = g * a1;
|
||||
let a3 = g * a2;
|
||||
let v3 = *frame - self.ic2eq;
|
||||
let v1 = (self.ic1eq * (a1 as f32)) + (v3 * (a2 as f32));
|
||||
let v2 = self.ic2eq + (self.ic1eq * (a2 as f32)) + (v3 * (a3 as f32));
|
||||
self.ic1eq = (v1 * 2.0) - self.ic1eq;
|
||||
self.ic2eq = (v2 * 2.0) - self.ic2eq;
|
||||
let output = match self.mode {
|
||||
FilterMode::LowPass => v2,
|
||||
FilterMode::BandPass => v1,
|
||||
FilterMode::HighPass => *frame - v1 * (k as f32) - v2,
|
||||
FilterMode::Notch => *frame - v1 * (k as f32),
|
||||
};
|
||||
*frame = output * mix.sqrt() + *frame * (1.0 - mix).sqrt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
command_writers_and_readers!(
|
||||
set_mode: FilterMode,
|
||||
set_cutoff: ValueChangeCommand<f64>,
|
||||
set_resonance: ValueChangeCommand<f64>,
|
||||
set_mix: ValueChangeCommand<Mix>,
|
||||
);
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
use crate::{
|
||||
Mix, Value,
|
||||
effect::{Effect, EffectBuilder},
|
||||
};
|
||||
|
||||
use super::{Filter, FilterHandle, FilterMode, command_writers_and_readers};
|
||||
|
||||
/// Configures a filter effect.
|
||||
#[derive(Debug, Copy, Clone, PartialEq)]
|
||||
pub struct FilterBuilder {
|
||||
/// The frequencies that the filter will remove.
|
||||
pub mode: FilterMode,
|
||||
/// The cutoff frequency of the filter (in hertz).
|
||||
pub cutoff: Value<f64>,
|
||||
/// The resonance of the filter.
|
||||
///
|
||||
/// The resonance is a feedback effect that produces
|
||||
/// a distinctive "ringing" sound.
|
||||
pub resonance: Value<f64>,
|
||||
/// How much dry (unprocessed) signal should be blended
|
||||
/// with the wet (processed) signal.
|
||||
pub mix: Value<Mix>,
|
||||
}
|
||||
|
||||
impl FilterBuilder {
|
||||
/// Creates a new [`FilterBuilder`] with the default settings.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Sets the frequencies that the filter will remove.
|
||||
#[must_use = "This method consumes self and returns a modified FilterBuilder, so the return value should be used"]
|
||||
pub fn mode(self, mode: FilterMode) -> Self {
|
||||
Self { mode, ..self }
|
||||
}
|
||||
|
||||
/// Sets the cutoff frequency of the filter (in hertz).
|
||||
#[must_use = "This method consumes self and returns a modified FilterBuilder, so the return value should be used"]
|
||||
pub fn cutoff(self, cutoff: impl Into<Value<f64>>) -> Self {
|
||||
Self {
|
||||
cutoff: cutoff.into(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the resonance of the filter.
|
||||
#[must_use = "This method consumes self and returns a modified FilterBuilder, so the return value should be used"]
|
||||
pub fn resonance(self, resonance: impl Into<Value<f64>>) -> Self {
|
||||
Self {
|
||||
resonance: resonance.into(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets how much dry (unprocessed) signal should be blended
|
||||
/// with the wet (processed) signal. `0.0` means only the dry
|
||||
/// signal will be heard. `1.0` means only the wet signal will
|
||||
/// be heard.
|
||||
#[must_use = "This method consumes self and returns a modified FilterBuilder, so the return value should be used"]
|
||||
pub fn mix(self, mix: impl Into<Value<Mix>>) -> Self {
|
||||
Self {
|
||||
mix: mix.into(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FilterBuilder {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: FilterMode::LowPass,
|
||||
cutoff: Value::Fixed(1000.0),
|
||||
resonance: Value::Fixed(0.0),
|
||||
mix: Value::Fixed(Mix::WET),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EffectBuilder for FilterBuilder {
|
||||
type Handle = FilterHandle;
|
||||
|
||||
fn build(self) -> (Box<dyn Effect>, Self::Handle) {
|
||||
let (command_writers, command_readers) = command_writers_and_readers();
|
||||
(
|
||||
Box::new(Filter::new(self, command_readers)),
|
||||
FilterHandle { command_writers },
|
||||
)
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
use crate::{Mix, command::handle_param_setters};
|
||||
|
||||
use super::{CommandWriters, FilterMode};
|
||||
|
||||
/// Controls a filter effect.
|
||||
#[derive(Debug)]
|
||||
pub struct FilterHandle {
|
||||
pub(super) command_writers: CommandWriters,
|
||||
}
|
||||
|
||||
impl FilterHandle {
|
||||
/// Sets the frequencies that the filter will remove.
|
||||
pub fn set_mode(&mut self, mode: FilterMode) {
|
||||
self.command_writers.set_mode.write(mode)
|
||||
}
|
||||
|
||||
handle_param_setters! {
|
||||
/// Sets the cutoff frequency of the filter (in hertz).
|
||||
cutoff: f64,
|
||||
|
||||
/// Sets the resonance of the filter.
|
||||
resonance: f64,
|
||||
|
||||
/// Sets how much dry (unprocessed) signal should be blended
|
||||
/// with the wet (processed) signal.
|
||||
mix: Mix,
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
//! Adjusts the panning of audio.
|
||||
|
||||
mod builder;
|
||||
mod handle;
|
||||
|
||||
pub use builder::*;
|
||||
pub use handle::*;
|
||||
|
||||
use crate::{
|
||||
Panning, Parameter,
|
||||
command::{ValueChangeCommand, read_commands_into_parameters},
|
||||
command_writers_and_readers,
|
||||
frame::Frame,
|
||||
info::Info,
|
||||
};
|
||||
|
||||
use super::Effect;
|
||||
|
||||
struct PanningControl {
|
||||
command_readers: CommandReaders,
|
||||
panning: Parameter<Panning>,
|
||||
}
|
||||
|
||||
impl PanningControl {
|
||||
#[must_use]
|
||||
fn new(builder: PanningControlBuilder, command_readers: CommandReaders) -> Self {
|
||||
Self {
|
||||
command_readers,
|
||||
panning: Parameter::new(builder.0, Panning::CENTER),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Effect for PanningControl {
|
||||
fn on_start_processing(&mut self) {
|
||||
read_commands_into_parameters!(self, panning);
|
||||
}
|
||||
|
||||
fn process(&mut self, input: &mut [Frame], dt: f64, info: &Info) {
|
||||
self.panning.update(dt * input.len() as f64, info);
|
||||
let num_frames = input.len();
|
||||
for (i, frame) in input.iter_mut().enumerate() {
|
||||
let time_in_chunk = (i + 1) as f64 / num_frames as f64;
|
||||
*frame = frame.panned(self.panning.interpolated_value(time_in_chunk))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
command_writers_and_readers! {
|
||||
set_panning: ValueChangeCommand<Panning>,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use crate::{Panning, Value, effect::EffectBuilder};
|
||||
|
||||
use super::{PanningControl, PanningControlHandle, command_writers_and_readers};
|
||||
|
||||
/// Configures a panning control effect.
|
||||
#[derive(Debug, Copy, Clone, PartialEq)]
|
||||
pub struct PanningControlBuilder(pub Value<Panning>);
|
||||
|
||||
impl Default for PanningControlBuilder {
|
||||
fn default() -> Self {
|
||||
Self(Value::Fixed(Panning::CENTER))
|
||||
}
|
||||
}
|
||||
|
||||
impl EffectBuilder for PanningControlBuilder {
|
||||
type Handle = PanningControlHandle;
|
||||
|
||||
fn build(self) -> (Box<dyn crate::effect::Effect>, Self::Handle) {
|
||||
let (command_writers, command_readers) = command_writers_and_readers();
|
||||
(
|
||||
Box::new(PanningControl::new(self, command_readers)),
|
||||
PanningControlHandle { command_writers },
|
||||
)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user