diff --git a/src/effects/swarm.rs b/src/effects/swarm.rs index c9ca149..0c56355 100644 --- a/src/effects/swarm.rs +++ b/src/effects/swarm.rs @@ -416,7 +416,7 @@ impl Effect for Swarm { .map_err(EngineError::Other)?; let all_paths: Vec = { let ch = &ctx.terminal.arena[id.0 as usize]; - ch.motion.paths.keys().cloned().collect() + ch.motion.paths.keys().map(|key| key.to_string()).collect() }; ctx.chain_paths(id, &all_paths, false).map_err(EngineError::Other)?; } diff --git a/src/effects/thunderstorm.rs b/src/effects/thunderstorm.rs index d56696f..3ec715d 100644 --- a/src/effects/thunderstorm.rs +++ b/src/effects/thunderstorm.rs @@ -330,7 +330,7 @@ impl Thunderstorm { let strike_char = self.available_strike_chars.pop().unwrap(); let ch = &mut ctx.terminal.arena[strike_char.0 as usize]; ch.animation.scenes.clear(); - ch.event_handler.registered_events.clear(); + ch.event_handler.clear(); strike_char } diff --git a/src/engine/animation.rs b/src/engine/animation.rs index e34bb4e..b0eeec8 100644 --- a/src/engine/animation.rs +++ b/src/engine/animation.rs @@ -25,6 +25,80 @@ pub enum SyncMetric { Step, } +thread_local! { + /// Reused assembly buffer for CharacterVisual::new's SGR string. + static FORMAT_SCRATCH: std::cell::RefCell = const { std::cell::RefCell::new(String::new()) }; +} + +/// Inline capacity for a formatted symbol. A 24-bit foreground and background +/// pair plus a reset is 42 bytes, so all but pathological styling fits. +const INLINE_SYMBOL_CAPACITY: usize = 63; + +/// The precomputed ANSI string for one cell, stored inline when it fits. +/// +/// The frame writer emits one of these per visible cell — millions of times +/// over a run — and a `str` copy of a couple of dozen bytes is dominated by the +/// memcpy call itself. An inline buffer of fixed size lets the writer copy the +/// whole block unconditionally and then advance by the real length. +#[derive(Debug, Clone)] +pub enum FormattedSymbol { + Inline { bytes: [u8; INLINE_SYMBOL_CAPACITY], len: u8 }, + Heap(Box), +} + +impl FormattedSymbol { + fn new(text: &str) -> Self { + if text.len() <= INLINE_SYMBOL_CAPACITY { + let mut bytes = [0u8; INLINE_SYMBOL_CAPACITY]; + bytes[..text.len()].copy_from_slice(text.as_bytes()); + FormattedSymbol::Inline { bytes, len: text.len() as u8 } + } else { + FormattedSymbol::Heap(text.into()) + } + } + + #[inline] + pub fn as_str(&self) -> &str { + match self { + FormattedSymbol::Inline { bytes, len } => { + // SAFETY: built from a &str prefix, so the range is valid UTF-8. + unsafe { std::str::from_utf8_unchecked(&bytes[..*len as usize]) } + } + FormattedSymbol::Heap(text) => text, + } + } + + /// Append to a UTF-8 byte buffer, copying the whole inline block in one go. + #[inline] + pub fn append_to(&self, out: &mut Vec) { + match self { + FormattedSymbol::Inline { bytes, len } => { + if out.len() + INLINE_SYMBOL_CAPACITY > out.capacity() { + out.reserve(INLINE_SYMBOL_CAPACITY); + } + let start = out.len(); + // SAFETY: the reserve above guarantees room for the whole block; + // only the first `len` bytes are published as initialized. + unsafe { + std::ptr::copy_nonoverlapping( + bytes.as_ptr(), + out.as_mut_ptr().add(start), + INLINE_SYMBOL_CAPACITY, + ); + out.set_len(start + *len as usize); + } + } + FormattedSymbol::Heap(text) => out.extend_from_slice(text.as_bytes()), + } + } +} + +impl PartialEq for FormattedSymbol { + fn eq(&self, other: &Self) -> bool { + self.as_str() == other.as_str() + } +} + /// animation.CharacterVisual with the formatted ANSI string precomputed. #[derive(Debug, Clone, PartialEq)] pub struct CharacterVisual { @@ -40,7 +114,7 @@ pub struct CharacterVisual { pub colors: Option, pub fg_color_code: Option, pub bg_color_code: Option, - pub formatted_symbol: String, + pub formatted_symbol: FormattedSymbol, } #[derive(Debug, Clone, Default)] @@ -73,9 +147,16 @@ impl CharacterVisual { colors: p.colors, fg_color_code: p.fg_color_code, bg_color_code: p.bg_color_code, - formatted_symbol: String::new(), + formatted_symbol: FormattedSymbol::Inline { bytes: [0; INLINE_SYMBOL_CAPACITY], len: 0 }, }; - vis.formatted_symbol = vis.format_symbol(); + // Effects rebuild visuals every frame, so the SGR string is assembled in + // a reused scratch buffer rather than a fresh allocation per visual. + FORMAT_SCRATCH.with(|scratch| { + let mut scratch = scratch.borrow_mut(); + scratch.clear(); + vis.format_symbol_into(&mut scratch); + vis.formatted_symbol = FormattedSymbol::new(&scratch); + }); vis } @@ -85,8 +166,7 @@ impl CharacterVisual { /// SGR emission in upstream's fixed order; `dim` intentionally omitted; /// bare symbol when nothing applies. - fn format_symbol(&self) -> String { - let mut fmt = String::new(); + fn format_symbol_into(&self, fmt: &mut String) { if self.bold { fmt.push_str(ansi::BOLD); } @@ -109,15 +189,14 @@ impl CharacterVisual { fmt.push_str(ansi::STRIKETHROUGH); } if let Some(code) = &self.fg_color_code { - ansi::fg(code, &mut fmt); + ansi::fg(code, fmt); } if let Some(code) = &self.bg_color_code { - ansi::bg(code, &mut fmt); + ansi::bg(code, fmt); } - if fmt.is_empty() { - self.symbol.clone() - } else { - format!("{fmt}{}{}", self.symbol, ansi::RESET_ALL) + fmt.push_str(&self.symbol); + if fmt.len() != self.symbol.len() { + fmt.push_str(ansi::RESET_ALL); } } } diff --git a/src/engine/character.rs b/src/engine/character.rs index b7e40ca..fd677a2 100644 --- a/src/engine/character.rs +++ b/src/engine/character.rs @@ -71,6 +71,8 @@ impl EffectCharacter { /// incomplete OR motion has an active path. Note looping scenes report /// complete, so loop-only characters read as inactive (faithful quirk). pub fn is_active(&self) -> bool { - !self.animation.active_scene_is_complete() || !self.motion.movement_is_complete() + // Movement is a null check; scene completion is a map lookup. Same + // answer either way, so ask the cheap question first. + !self.motion.movement_is_complete() || !self.animation.active_scene_is_complete() } } diff --git a/src/engine/ctx.rs b/src/engine/ctx.rs index 2924593..261402f 100644 --- a/src/engine/ctx.rs +++ b/src/engine/ctx.rs @@ -16,7 +16,7 @@ use crate::engine::active_characters::ActiveCharacters; use crate::engine::animation::SyncMetric; use crate::engine::character::CharId; use crate::engine::error::EngineError; -use crate::engine::events::{CallerKey, EffectCallback, Event, EventAction}; +use crate::engine::events::{CallerKey, CallerRef, EffectCallback, Event, EventAction}; use crate::engine::motion::Segment; use crate::engine::motion::Waypoint; use crate::engine::terminal::{Terminal, TerminalConfig}; @@ -24,6 +24,11 @@ use crate::utils::geometry::{self, Coord}; use crate::utils::pycompat::round_half_even; use crate::utils::rng::Rng; +thread_local! { + /// The synthetic origin waypoint's id, shared by every activation. + static ORIGIN_WAYPOINT_ID: Rc = Rc::from("origin"); +} + /// Virtual/real clock (plan.md §4.7). Matrix reads wall time, thunderstorm /// reads monotonic time; the parity harness swaps in the virtual variant. #[derive(Debug)] @@ -119,6 +124,13 @@ impl EngineCtx { // event dispatch (EventHandler._handle_event) // ------------------------------------------------------------------ + /// Whether an emission of `event` on `id` can have any observable effect — + /// false lets hot emission sites skip building the CallerKey entirely. + #[inline] + fn observes_event(&self, id: CharId, event: Event) -> bool { + self.event_log.is_some() || self.terminal.arena[id.0 as usize].event_handler.subscribes(event) + } + /// Execute all actions registered for (event, caller) on `id`, in /// registration order, inline and reentrantly. The action list is indexed /// per iteration because a callback may append more actions to it. @@ -127,7 +139,7 @@ impl EngineCtx { hooks: &mut dyn EffectHooks, id: CharId, event: Event, - caller: &CallerKey, + caller: CallerRef<'_>, ) { if self.event_log.is_some() { let character_id = self.terminal.arena[id.0 as usize].character_id; @@ -141,9 +153,9 @@ impl EngineCtx { Event::SceneComplete => "SCENE_COMPLETE", }; let caller_label = match caller { - CallerKey::Path(pid) => format!("path:{pid}"), - CallerKey::Waypoint(wp) => format!("wp:{}", wp.waypoint_id), - CallerKey::Scene(sid) => format!("scene:{sid}"), + CallerRef::Path(pid) => format!("path:{pid}"), + CallerRef::Waypoint(wp) => format!("wp:{}", wp.waypoint_id), + CallerRef::Scene(sid) => format!("scene:{sid}"), }; self.event_log .as_mut() @@ -157,7 +169,7 @@ impl EngineCtx { loop { let action = { let handler = &self.terminal.arena[id.0 as usize].event_handler; - let actions = &handler.registered_events[entry_index].1; + let actions = handler.actions(entry_index); if action_index >= actions.len() { break; } @@ -250,13 +262,13 @@ impl EngineCtx { None => geometry::find_length_of_line(current_coord, first_waypoint.coord, true), }; let new_origin_segment = Segment::new( - Waypoint { waypoint_id: "origin".to_string(), coord: current_coord, bezier_control: None }, + Waypoint { waypoint_id: ORIGIN_WAYPOINT_ID.with(Rc::clone), coord: current_coord, bezier_control: None }, first_waypoint, distance_to_first_waypoint, ); let layer = { let ch = &mut self.terminal.arena[id.0 as usize]; - ch.motion.active_path = Some(Rc::from(path_id)); + ch.motion.active_path = ch.motion.paths.shared_key(path_id); let path = ch.motion.paths.get_mut(path_id).unwrap(); path.total_distance += distance_to_first_waypoint; if let Some(origin) = &path.origin_segment { @@ -278,27 +290,35 @@ impl EngineCtx { if let Some(layer) = layer { self.terminal.arena[id.0 as usize].layer = layer; } - self.handle_event(hooks, id, Event::PathActivated, &CallerKey::Path(path_id.to_string())); + if self.observes_event(id, Event::PathActivated) { + self.handle_event(hooks, id, Event::PathActivated, CallerRef::Path(path_id)); + } } /// Path.step on the given path of `id`. Index-based segment walk with /// re-borrow per access so reentrant mutation behaves like Python. + /// + /// The path's slot is resolved once and re-resolved after every emission, + /// since only a reentrant action can move or drop it. fn path_step(&mut self, hooks: &mut dyn EffectHooks, id: CharId, path_id: &str) -> Coord { + let mut slot = + self.terminal.arena[id.0 as usize].motion.paths.slot(path_id).expect("path_step: path removed mid-step"); macro_rules! path { () => { - self.terminal.arena[id.0 as usize] - .motion - .paths - .get(path_id) - .expect("path_step: path removed mid-step") + self.terminal.arena[id.0 as usize].motion.paths.at(slot) }; } macro_rules! path_mut { () => { - self.terminal.arena[id.0 as usize] + self.terminal.arena[id.0 as usize].motion.paths.at_mut(slot) + }; + } + macro_rules! resolve_slot { + () => { + slot = self.terminal.arena[id.0 as usize] .motion .paths - .get_mut(path_id) + .slot(path_id) .expect("path_step: path removed mid-step") }; } @@ -333,22 +353,37 @@ impl EngineCtx { if distance_to_travel <= seg_distance { active_segment_index = Some(i); if !enter_triggered { - let seg_end_key = path!().segments[i].end.key(); - path_mut!().segments[i].enter_event_triggered = true; - self.handle_event(hooks, id, Event::SegmentEntered, &CallerKey::Waypoint(seg_end_key)); + if self.observes_event(id, Event::SegmentEntered) { + let seg_end_key = path!().segments[i].end.key(); + path_mut!().segments[i].enter_event_triggered = true; + self.handle_event(hooks, id, Event::SegmentEntered, CallerRef::Waypoint(&seg_end_key)); + resolve_slot!(); + } else { + path_mut!().segments[i].enter_event_triggered = true; + } } break; } distance_to_travel -= seg_distance; if !enter_triggered || !exit_triggered { - let seg_end_key = path!().segments[i].end.key(); - if !enter_triggered { - path_mut!().segments[i].enter_event_triggered = true; - self.handle_event(hooks, id, Event::SegmentEntered, &CallerKey::Waypoint(seg_end_key.clone())); - } - if !exit_triggered { - path_mut!().segments[i].exit_event_triggered = true; - self.handle_event(hooks, id, Event::SegmentExited, &CallerKey::Waypoint(seg_end_key)); + let observes = self.observes_event(id, Event::SegmentEntered) + || self.observes_event(id, Event::SegmentExited); + if !observes { + let seg = &mut path_mut!().segments[i]; + seg.enter_event_triggered = true; + seg.exit_event_triggered = true; + } else { + let seg_end_key = path!().segments[i].end.key(); + if !enter_triggered { + path_mut!().segments[i].enter_event_triggered = true; + self.handle_event(hooks, id, Event::SegmentEntered, CallerRef::Waypoint(&seg_end_key)); + resolve_slot!(); + } + if !exit_triggered { + path_mut!().segments[i].exit_event_triggered = true; + self.handle_event(hooks, id, Event::SegmentExited, CallerRef::Waypoint(&seg_end_key)); + resolve_slot!(); + } } } i += 1; @@ -406,17 +441,16 @@ impl EngineCtx { .active_path .clone() .expect("active path cleared mid-move (would be an upstream crash)"); + let slot = self.terminal.arena[id.0 as usize].motion.paths.slot(&active_path_id).expect("active path missing"); let (current_step, max_steps, hold_time, hold_time_remaining, loop_, segment_count) = { - let p = self.terminal.arena[id.0 as usize] - .motion - .paths - .get(&active_path_id) - .expect("active path missing"); + let p = self.terminal.arena[id.0 as usize].motion.paths.at(slot); (p.current_step, p.max_steps, p.hold_time, p.hold_time_remaining, p.loop_, p.segments.len()) }; if current_step == max_steps { if hold_time != 0 && hold_time_remaining == hold_time { - self.handle_event(hooks, id, Event::PathHolding, &CallerKey::Path(active_path_id.to_string())); + if self.observes_event(id, Event::PathHolding) { + self.handle_event(hooks, id, Event::PathHolding, CallerRef::Path(&active_path_id)); + } self.terminal.arena[id.0 as usize] .motion .paths @@ -426,12 +460,7 @@ impl EngineCtx { return; } if hold_time_remaining != 0 { - self.terminal.arena[id.0 as usize] - .motion - .paths - .get_mut(&active_path_id) - .unwrap() - .hold_time_remaining -= 1; + self.terminal.arena[id.0 as usize].motion.paths.at_mut(slot).hold_time_remaining -= 1; return; } if loop_ && segment_count > 1 { @@ -443,7 +472,9 @@ impl EngineCtx { motion.completed_path = Some(active_path_id.clone()); motion.deactivate_path(Some(&active_path_id)); } - self.handle_event(hooks, id, Event::PathComplete, &CallerKey::Path(active_path_id.to_string())); + if self.observes_event(id, Event::PathComplete) { + self.handle_event(hooks, id, Event::PathComplete, CallerRef::Path(&active_path_id)); + } } } } @@ -487,11 +518,13 @@ impl EngineCtx { .expect("activate_scene: scene not found") .activate() .expect("activate_scene: empty scene"); - ch.animation.active_scene = Some(Rc::from(scene_id)); + ch.animation.active_scene = ch.animation.scenes.shared_key(scene_id); ch.animation.active_scene_current_step = 0; ch.animation.current_character_visual = visual; } - self.handle_event(hooks, id, Event::SceneActivated, &CallerKey::Scene(scene_id.to_string())); + if self.observes_event(id, Event::SceneActivated) { + self.handle_event(hooks, id, Event::SceneActivated, CallerRef::Scene(scene_id)); + } } /// Animation.deactivate_scene. @@ -508,39 +541,44 @@ impl EngineCtx { } /// Animation.step_animation. + /// + /// Nothing between here and complete_scene_if_finished can add or remove a + /// scene, so the active scene's slot is resolved once and reused instead of + /// looking the id up again at every step. pub fn step_animation(&mut self, hooks: &mut dyn EffectHooks, id: CharId) { - let Some(scene_id) = ({ + let Some(scene_slot) = ({ let anim = &self.terminal.arena[id.0 as usize].animation; match &anim.active_scene { - Some(sid) if !anim.scenes.get(sid).expect("active scene missing").frames.is_empty() => { - Some(sid.clone()) + Some(sid) => { + let slot = anim.scenes.slot(sid).expect("active scene missing"); + (!anim.scenes.at(slot).frames.is_empty()).then_some(slot) } - _ => None, + None => None, } }) else { return; }; let (sync, ease) = { - let scene = self.terminal.arena[id.0 as usize].animation.scenes.get(&scene_id).unwrap(); + let scene = self.terminal.arena[id.0 as usize].animation.scenes.at(scene_slot); (scene.sync, scene.ease) }; if sync.is_some() { - self.step_synced_scene(id, &scene_id, sync.unwrap()); + self.step_synced_scene(id, scene_slot, sync.unwrap()); } else if ease.is_some() { - self.step_eased_scene(id, &scene_id, ease.unwrap()); + self.step_eased_scene(id, scene_slot, ease.unwrap()); } else { let ch = &mut self.terminal.arena[id.0 as usize]; - let visual = ch.animation.scenes.get_mut(&scene_id).unwrap().get_next_visual(); + let visual = ch.animation.scenes.at_mut(scene_slot).get_next_visual(); ch.animation.current_character_visual = visual; } - self.complete_scene_if_finished(hooks, id, &scene_id); + self.complete_scene_if_finished(hooks, id, scene_slot); } /// Animation._step_synced_scene + _synced_scene_frame_index. - fn step_synced_scene(&mut self, id: CharId, scene_id: &str, sync: SyncMetric) { + fn step_synced_scene(&mut self, id: CharId, scene_slot: usize, sync: SyncMetric) { let active_path_state = { let ch = &self.terminal.arena[id.0 as usize]; ch.motion.active_path.as_ref().map(|pid| { @@ -549,7 +587,7 @@ impl EngineCtx { }) }; let ch = &mut self.terminal.arena[id.0 as usize]; - let scene = ch.animation.scenes.get_mut(scene_id).unwrap(); + let scene = ch.animation.scenes.at_mut(scene_slot); match active_path_state { None => { // no active path: jump to final frame and force-complete @@ -579,9 +617,9 @@ impl EngineCtx { } /// Animation._step_eased_scene (+ _ease_animation). - fn step_eased_scene(&mut self, id: CharId, scene_id: &str, ease: crate::utils::easing::Easing) { + fn step_eased_scene(&mut self, id: CharId, scene_slot: usize, ease: crate::utils::easing::Easing) { let ch = &mut self.terminal.arena[id.0 as usize]; - let scene = ch.animation.scenes.get_mut(scene_id).unwrap(); + let scene = ch.animation.scenes.at_mut(scene_slot); let elapsed_step_ratio = scene.easing_current_step as f64 / scene.easing_total_steps as f64; let easing_factor = ease.ease(elapsed_step_ratio); let final_frame_index = (scene.easing_total_steps - 1).max(0); @@ -604,25 +642,28 @@ impl EngineCtx { /// Animation._complete_scene_if_finished: fires SCENE_COMPLETE every tick /// for looping scenes, faithfully. - fn complete_scene_if_finished(&mut self, hooks: &mut dyn EffectHooks, id: CharId, scene_id: &str) { + fn complete_scene_if_finished(&mut self, hooks: &mut dyn EffectHooks, id: CharId, scene_slot: usize) { { + // The stepping above cannot clear active_scene, so the slot still + // holds it and active_scene_is_complete reduces to its scene test. let anim = &self.terminal.arena[id.0 as usize].animation; - if !anim.active_scene_is_complete() { + let scene = anim.scenes.at(scene_slot); + if !(scene.frames.is_empty() || scene.is_looping) { return; } } - let is_looping = { + { let anim = &mut self.terminal.arena[id.0 as usize].animation; - let scene = anim.scenes.get_mut(scene_id).unwrap(); - let looping = scene.is_looping; - if !looping { + let scene = anim.scenes.at_mut(scene_slot); + if !scene.is_looping { scene.reset_scene(); anim.active_scene = None; } - looping - }; - let _ = is_looping; - self.handle_event(hooks, id, Event::SceneComplete, &CallerKey::Scene(scene_id.to_string())); + } + if self.observes_event(id, Event::SceneComplete) { + let scene_id = Rc::clone(self.terminal.arena[id.0 as usize].animation.scenes.key_at(scene_slot)); + self.handle_event(hooks, id, Event::SceneComplete, CallerRef::Scene(&scene_id)); + } } // ------------------------------------------------------------------ diff --git a/src/engine/events.rs b/src/engine/events.rs index d87d16b..c339606 100644 --- a/src/engine/events.rs +++ b/src/engine/events.rs @@ -20,14 +20,25 @@ pub enum Event { SceneComplete, } +impl Event { + #[inline] + fn bit(self) -> u8 { + 1 << (self as u8) + } +} + /// Waypoint identity for event keying: upstream Waypoint is a frozen dataclass /// hashed/compared by ALL fields (id, coord, bezier controls) — two waypoints /// with identical fields in different paths collide, faithfully. +/// +/// Field order is load-bearing for speed, not for meaning: the derived +/// comparison short-circuits in declaration order, and `coord` rejects +/// non-matches with two integer compares instead of a string memcmp. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct WaypointKey { - pub waypoint_id: String, pub coord: Coord, - pub bezier_control: Option>, + pub waypoint_id: std::rc::Rc, + pub bezier_control: Option>, } /// Event caller identity. Scene/Path compare by id (their upstream __eq__). @@ -38,6 +49,65 @@ pub enum CallerKey { Waypoint(WaypointKey), } +/// A caller identity borrowed for the duration of a lookup. Emission sites +/// already hold the id they are firing for, so matching against the table costs +/// nothing — building an owned CallerKey per emission did. +#[derive(Debug, Clone, Copy)] +pub enum CallerRef<'a> { + Scene(&'a str), + Path(&'a str), + Waypoint(&'a WaypointKey), +} + +/// FNV-1a over a byte run, seeded so the three caller kinds cannot collide. +#[inline] +fn fnv(mut hash: u32, bytes: &[u8]) -> u32 { + for &byte in bytes { + hash ^= byte as u32; + hash = hash.wrapping_mul(16_777_619); + } + hash +} + +impl CallerRef<'_> { + /// A cheap digest of the caller's identity. Registered entries store theirs, + /// so a lookup hashes once and then rejects candidates with an integer + /// compare instead of a string compare each. + #[inline] + fn fingerprint(self) -> u32 { + match self { + CallerRef::Scene(id) => fnv(0x0100_0193, id.as_bytes()), + CallerRef::Path(id) => fnv(0x0200_0193, id.as_bytes()), + CallerRef::Waypoint(key) => { + let hash = fnv(0x0300_0193, &key.coord.column.to_le_bytes()); + let hash = fnv(hash, &key.coord.row.to_le_bytes()); + fnv(hash, key.waypoint_id.as_bytes()) + } + } + } +} + +impl CallerKey { + #[inline] + fn as_ref(&self) -> CallerRef<'_> { + match self { + CallerKey::Scene(id) => CallerRef::Scene(id), + CallerKey::Path(id) => CallerRef::Path(id), + CallerKey::Waypoint(key) => CallerRef::Waypoint(key), + } + } + + #[inline] + fn matches(&self, caller: CallerRef<'_>) -> bool { + match (self, caller) { + (CallerKey::Scene(a), CallerRef::Scene(b)) => **a == *b, + (CallerKey::Path(a), CallerRef::Path(b)) => **a == *b, + (CallerKey::Waypoint(a), CallerRef::Waypoint(b)) => a == b, + _ => false, + } + } +} + /// Typed payload values for effect callbacks (upstream Callback *args). #[derive(Debug, Clone, PartialEq)] pub enum CallbackValue { @@ -72,9 +142,23 @@ pub enum EventAction { } /// Per-character event table: insertion-ordered (event, caller) -> actions. +/// +/// `subscribed` mirrors the table as a bitmask of registered event kinds. Most +/// characters register a handful of events while the engine emits thousands, +/// so callers test it first and skip building the (allocating) CallerKey when +/// nothing could match. #[derive(Debug, Clone, Default)] pub struct EventHandler { - pub registered_events: Vec<((Event, CallerKey), Vec)>, + registered_events: Vec, + subscribed: u8, +} + +#[derive(Debug, Clone)] +struct RegisteredEvent { + event: Event, + fingerprint: u32, + caller: CallerKey, + actions: Vec, } impl EventHandler { @@ -82,19 +166,59 @@ impl EventHandler { /// DuplicateEventRegistrationError). Caller/target id resolution and type /// validation happen in EngineCtx::register_event, which has arena access. pub fn push(&mut self, event: Event, caller: CallerKey, action: EventAction) -> Result<(), String> { - let key = (event, caller); - if let Some(entry) = self.registered_events.iter_mut().find(|(k, _)| *k == key) { - if entry.1.contains(&action) { - return Err(format!("duplicate event registration: {:?} {:?}", entry.0, action)); + let fingerprint = caller.as_ref().fingerprint(); + let existing = self.registered_events.iter_mut().find(|entry| { + entry.event == event && entry.fingerprint == fingerprint && entry.caller == caller + }); + if let Some(entry) = existing { + if entry.actions.contains(&action) { + return Err(format!( + "duplicate event registration: {:?} {:?}", + (entry.event, &entry.caller), + action + )); } - entry.1.push(action); + entry.actions.push(action); } else { - self.registered_events.push((key, vec![action])); + self.registered_events.push(RegisteredEvent { event, fingerprint, caller, actions: vec![action] }); } + self.subscribed |= event.bit(); Ok(()) } - pub fn actions_index(&self, event: Event, caller: &CallerKey) -> Option { - self.registered_events.iter().position(|((e, c), _)| *e == event && c == caller) + /// True when at least one action is registered for this event kind, for any + /// caller. A false answer means `actions_index` cannot match. + #[inline] + pub fn subscribes(&self, event: Event) -> bool { + self.subscribed & event.bit() != 0 + } + + #[inline] + pub fn actions_index(&self, event: Event, caller: CallerRef<'_>) -> Option { + if !self.subscribes(event) { + return None; + } + // Hashing the query only pays once there is a run of candidates to + // reject; a table with a couple of entries compares them directly. + if self.registered_events.len() <= 2 { + return self + .registered_events + .iter() + .position(|entry| entry.event == event && entry.caller.matches(caller)); + } + let fingerprint = caller.fingerprint(); + self.registered_events.iter().position(|entry| { + entry.event == event && entry.fingerprint == fingerprint && entry.caller.matches(caller) + }) + } + + #[inline] + pub fn actions(&self, index: usize) -> &[EventAction] { + &self.registered_events[index].actions + } + + pub fn clear(&mut self) { + self.registered_events.clear(); + self.subscribed = 0; } } diff --git a/src/engine/motion.rs b/src/engine/motion.rs index 7935528..fd80476 100644 --- a/src/engine/motion.rs +++ b/src/engine/motion.rs @@ -10,18 +10,21 @@ use crate::utils::geometry::{self, Coord}; use crate::utils::ordered_map::OrderedMap; use crate::utils::pycompat::round_half_even; +/// Waypoints are cloned constantly — into segments, into origin segments on +/// every path activation, and into event keys — so both owned fields are +/// reference counted and a clone is two refcount bumps. #[derive(Debug, Clone, PartialEq)] pub struct Waypoint { - pub waypoint_id: String, + pub waypoint_id: Rc, pub coord: Coord, - pub bezier_control: Option>, + pub bezier_control: Option>, } impl Waypoint { pub fn key(&self) -> WaypointKey { WaypointKey { - waypoint_id: self.waypoint_id.clone(), coord: self.coord, + waypoint_id: self.waypoint_id.clone(), bezier_control: self.bezier_control.clone(), } } @@ -99,23 +102,23 @@ impl Path { bezier_control: Option>, waypoint_id: &str, ) -> Result { - let waypoint_id = if waypoint_id.is_empty() { + let waypoint_id: Rc = if waypoint_id.is_empty() { let mut current_id = self.waypoints.len(); loop { let candidate = current_id.to_string(); - if !self.waypoints.iter().any(|w| w.waypoint_id == candidate) { - break candidate; + if !self.waypoints.iter().any(|w| *w.waypoint_id == *candidate) { + break Rc::from(candidate); } current_id += 1; } } else { - if self.waypoints.iter().any(|w| w.waypoint_id == waypoint_id) { + if self.waypoints.iter().any(|w| *w.waypoint_id == *waypoint_id) { return Err(format!("duplicate waypoint id: {waypoint_id}")); } - waypoint_id.to_string() + Rc::from(waypoint_id) }; // Python: empty tuple bezier_control is falsy -> None - let bezier_control = bezier_control.filter(|v| !v.is_empty()); + let bezier_control = bezier_control.filter(|v| !v.is_empty()).map(Rc::from); let waypoint = Waypoint { waypoint_id, coord, bezier_control }; self.add_waypoint_to_path(waypoint.clone()); Ok(waypoint) @@ -141,7 +144,7 @@ impl Path { pub fn query_waypoint(&self, waypoint_id: &str) -> Result<&Waypoint, String> { self.waypoints .iter() - .find(|w| w.waypoint_id == waypoint_id) + .find(|w| *w.waypoint_id == *waypoint_id) .ok_or_else(|| format!("waypoint not found: {waypoint_id}")) } } diff --git a/src/engine/particles.rs b/src/engine/particles.rs index 4678c67..1c300c3 100644 --- a/src/engine/particles.rs +++ b/src/engine/particles.rs @@ -122,7 +122,7 @@ impl ParticlePool { ch.animation.scenes.clear(); } if reset.clear_events { - ch.event_handler.registered_events.clear(); + ch.event_handler.clear(); } if reset.reset_appearance { let input_symbol = ch.input_symbol.clone(); diff --git a/src/engine/terminal.rs b/src/engine/terminal.rs index cd7ec23..2223915 100644 --- a/src/engine/terminal.rs +++ b/src/engine/terminal.rs @@ -560,7 +560,7 @@ impl Terminal { if cell == EMPTY_RENDER_CELL { row.push(' '); } else { - row.push_str(&arena[cell as usize].animation.current_character_visual.formatted_symbol); + row.push_str(arena[cell as usize].animation.current_character_visual.formatted_symbol.as_str()); } } } @@ -573,7 +573,7 @@ impl Terminal { .checked_mul(height) .and_then(|cells| cells.checked_add(height.saturating_sub(1))) .expect("terminal canvas is too large"); - let mut out = std::mem::take(&mut self.output_buffer); + let mut out = std::mem::take(&mut self.output_buffer).into_bytes(); out.clear(); if out.capacity() < minimum_capacity { out.reserve(minimum_capacity); @@ -581,17 +581,18 @@ impl Terminal { let arena = &self.arena; for row_index in (0..height).rev() { if row_index + 1 < height { - out.push('\n'); + out.push(b'\n'); } for &cell in &self.render_cells[row_index * width..(row_index + 1) * width] { if cell == EMPTY_RENDER_CELL { - out.push(' '); + out.push(b' '); } else { - out.push_str(&arena[cell as usize].animation.current_character_visual.formatted_symbol); + arena[cell as usize].animation.current_character_visual.formatted_symbol.append_to(&mut out); } } } - out + // SAFETY: every appended run is a whole formatted symbol, which is UTF-8. + unsafe { String::from_utf8_unchecked(out) } } pub(crate) fn recycle_output_string(&mut self, mut output: String) { diff --git a/src/main.rs b/src/main.rs index 31b6717..4df1261 100644 --- a/src/main.rs +++ b/src/main.rs @@ -136,6 +136,14 @@ fn main() -> ExitCode { ttfx::install_sigint_handler(); ttfx::engine::effect::run_effect(effect.as_mut(), &mut ctx) }; + // Output is already flushed, and nothing in the engine has a Drop impl that + // does work. Freeing an arena of tens of thousands of characters, each with + // its own scenes, paths and frames, is pure exit latency — on binarypath it + // is ~4% of the run. Hand it to the kernel instead. + std::mem::forget(effect); + std::mem::forget(ctx); + std::mem::forget(input_data); + match result { Ok(()) => { if ttfx::interrupted() { diff --git a/src/utils/ansi.rs b/src/utils/ansi.rs index ff37ae5..bd0298e 100644 --- a/src/utils/ansi.rs +++ b/src/utils/ansi.rs @@ -32,20 +32,43 @@ pub enum ColorCode { Xterm(u8), } +/// Decimal digits of a byte, without going through core::fmt. Every restyled +/// character reassembles its SGR sequence, so the formatting machinery shows up +/// in profiles. +#[inline] +fn push_decimal(out: &mut String, value: u8) { + if value >= 100 { + out.push((b'0' + value / 100) as char); + } + if value >= 10 { + out.push((b'0' + (value / 10) % 10) as char); + } + out.push((b'0' + value % 10) as char); +} + /// colorterm._color: fg selector 38, bg selector 48. fn sgr_color(code: &ColorCode, location: u8, out: &mut String) { + out.push_str("\x1b["); + push_decimal(out, location); match code { ColorCode::Rgb(hex) => { let s = hex.trim_matches('#'); let r = u8::from_str_radix(&s[0..2], 16).unwrap(); let g = u8::from_str_radix(&s[2..4], 16).unwrap(); let b = u8::from_str_radix(&s[4..6], 16).unwrap(); - write!(out, "\x1b[{location};2;{r};{g};{b}m").unwrap(); + out.push_str(";2;"); + push_decimal(out, r); + out.push(';'); + push_decimal(out, g); + out.push(';'); + push_decimal(out, b); } ColorCode::Xterm(n) => { - write!(out, "\x1b[{location};5;{n}m").unwrap(); + out.push_str(";5;"); + push_decimal(out, *n); } } + out.push('m'); } pub fn fg(code: &ColorCode, out: &mut String) { diff --git a/src/utils/ordered_map.rs b/src/utils/ordered_map.rs index c6e91d3..4eb1121 100644 --- a/src/utils/ordered_map.rs +++ b/src/utils/ordered_map.rs @@ -5,17 +5,27 @@ use std::cell::Cell; use std::collections::HashMap; +use std::rc::Rc; const INDEX_THRESHOLD: usize = 8; const NO_CACHED_LOOKUP: usize = usize::MAX; +/// Keys are shared so that long-lived handles (Motion::active_path, +/// Animation::active_scene) can hold the map's own key allocation; lookups +/// then settle on a pointer compare instead of a memcmp. #[derive(Debug, Clone)] pub struct OrderedMap { - entries: Vec<(String, V)>, - index: Option>>, + entries: Vec<(Rc, V)>, + index: Option, usize>>>, last_lookup: Cell, } +/// Same string, same allocation — true only for keys handed out by this map. +#[inline] +fn same_allocation(entry: &str, key: &str) -> bool { + std::ptr::eq(entry.as_ptr(), key.as_ptr()) && entry.len() == key.len() +} + impl OrderedMap { pub fn new() -> Self { OrderedMap { entries: Vec::new(), index: None, last_lookup: Cell::new(NO_CACHED_LOOKUP) } @@ -34,7 +44,8 @@ impl OrderedMap { } /// Python dict semantics: overwriting an existing key keeps its position. - pub fn insert(&mut self, key: String, value: V) { + pub fn insert(&mut self, key: impl Into>, value: V) { + let key: Rc = key.into(); if let Some(position) = self.position(&key) { self.entries[position].1 = value; return; @@ -65,10 +76,34 @@ impl OrderedMap { Some(&mut self.entries[position].1) } - pub fn keys(&self) -> impl Iterator { + pub fn keys(&self) -> impl Iterator> { self.entries.iter().map(|(k, _)| k) } + /// The map's own handle for `key`, for callers that want later lookups to + /// hit the pointer fast path. + pub fn shared_key(&self, key: &str) -> Option> { + self.position(key).map(|position| Rc::clone(&self.entries[position].0)) + } + + /// Entry slot for `key`, for callers that read the same entry several times + /// in a row. Slots stay valid until an entry is removed. + pub fn slot(&self, key: &str) -> Option { + self.position(key) + } + + pub fn at(&self, slot: usize) -> &V { + &self.entries[slot].1 + } + + pub fn at_mut(&mut self, slot: usize) -> &mut V { + &mut self.entries[slot].1 + } + + pub fn key_at(&self, slot: usize) -> &Rc { + &self.entries[slot].0 + } + pub fn values(&self) -> impl Iterator { self.entries.iter().map(|(_, v)| v) } @@ -77,7 +112,7 @@ impl OrderedMap { self.entries.iter_mut().map(|(_, v)| v) } - pub fn iter(&self) -> impl Iterator { + pub fn iter(&self) -> impl Iterator, &V)> { self.entries.iter().map(|(k, v)| (k, v)) } @@ -107,12 +142,18 @@ impl OrderedMap { fn position(&self, key: &str) -> Option { let cached = self.last_lookup.get(); - if cached < self.entries.len() && self.entries[cached].0 == key { - return Some(cached); + if cached < self.entries.len() { + let entry_key = &self.entries[cached].0; + if same_allocation(entry_key, key) || **entry_key == *key { + return Some(cached); + } } let position = match &self.index { Some(index) => index.get(key).copied(), - None => self.entries.iter().position(|(entry_key, _)| entry_key == key), + None => self + .entries + .iter() + .position(|(entry_key, _)| same_allocation(entry_key, key) || **entry_key == *key), }; self.last_lookup.set(position.unwrap_or(NO_CACHED_LOOKUP)); position @@ -137,7 +178,7 @@ mod tests { } map.insert("3".to_string(), 30); assert_eq!(map.get("3"), Some(&30)); - assert_eq!(map.keys().map(String::as_str).collect::>()[..5], ["0", "1", "2", "3", "4"]); + assert_eq!(map.keys().map(|key| &**key).collect::>()[..5], ["0", "1", "2", "3", "4"]); assert_eq!(map.remove("4"), Some(4)); assert_eq!(map.get("5"), Some(&5)); diff --git a/tests/engine_traces.rs b/tests/engine_traces.rs index 36d6d6f..dc65c56 100644 --- a/tests/engine_traces.rs +++ b/tests/engine_traces.rs @@ -57,7 +57,7 @@ fn snapshot(ctx: &mut EngineCtx, log: &mut Vec, tick: i64, ids: &[CharId ch.motion.current_coord.column, ch.motion.current_coord.row, ch.layer, - esc(&ch.animation.current_character_visual.formatted_symbol), + esc(ch.animation.current_character_visual.formatted_symbol.as_str()), )); } }