From 158b6c6d1ea91b3c460f2ba72c205f016fc4dcdb Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 10 Aug 2026 08:22:33 -0700 Subject: [PATCH 01/11] Skip event dispatch for unsubscribed events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path stepping emits SEGMENT_ENTERED/EXITED for every segment of every active character, and each emission built an owned CallerKey — a String clone plus, for waypoints, a Vec clone — before a linear scan of the character's event table decided there was nothing registered. On rings that scan and its memcmp traffic was ~45% of run time. EventHandler now keeps a bitmask of the event kinds it has registrations for, and emission sites test it before constructing the key. Registration order and lookup semantics are unchanged; the mask only short-circuits lookups that could not have matched. rings -25%, binarypath -5%. --- src/effects/thunderstorm.rs | 2 +- src/engine/ctx.rs | 63 +++++++++++++++++++++++++++---------- src/engine/events.rs | 37 +++++++++++++++++++++- src/engine/particles.rs | 2 +- 4 files changed, 84 insertions(+), 20 deletions(-) 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/ctx.rs b/src/engine/ctx.rs index 2924593..639aa85 100644 --- a/src/engine/ctx.rs +++ b/src/engine/ctx.rs @@ -122,6 +122,13 @@ impl EngineCtx { /// 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. + /// 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) + } + pub fn handle_event( &mut self, hooks: &mut dyn EffectHooks, @@ -157,7 +164,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; } @@ -278,7 +285,9 @@ 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, &CallerKey::Path(path_id.to_string())); + } } /// Path.step on the given path of `id`. Index-based segment walk with @@ -333,22 +342,34 @@ 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, &CallerKey::Waypoint(seg_end_key)); + } 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, &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)); + } } } i += 1; @@ -416,7 +437,9 @@ impl EngineCtx { }; 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, &CallerKey::Path(active_path_id.to_string())); + } self.terminal.arena[id.0 as usize] .motion .paths @@ -443,7 +466,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, &CallerKey::Path(active_path_id.to_string())); + } } } } @@ -491,7 +516,9 @@ impl EngineCtx { 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, &CallerKey::Scene(scene_id.to_string())); + } } /// Animation.deactivate_scene. @@ -622,7 +649,9 @@ impl EngineCtx { looping }; let _ = is_looping; - self.handle_event(hooks, id, Event::SceneComplete, &CallerKey::Scene(scene_id.to_string())); + if self.observes_event(id, Event::SceneComplete) { + self.handle_event(hooks, id, Event::SceneComplete, &CallerKey::Scene(scene_id.to_string())); + } } // ------------------------------------------------------------------ diff --git a/src/engine/events.rs b/src/engine/events.rs index d87d16b..5d4c4fa 100644 --- a/src/engine/events.rs +++ b/src/engine/events.rs @@ -20,6 +20,13 @@ 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. @@ -72,9 +79,15 @@ 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<((Event, CallerKey), Vec)>, + subscribed: u8, } impl EventHandler { @@ -91,10 +104,32 @@ impl EventHandler { } else { self.registered_events.push((key, vec![action])); } + self.subscribed |= event.bit(); Ok(()) } + /// 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: &CallerKey) -> Option { + if !self.subscribes(event) { + return None; + } self.registered_events.iter().position(|((e, c), _)| *e == event && c == caller) } + + #[inline] + pub fn actions(&self, index: usize) -> &[EventAction] { + &self.registered_events[index].1 + } + + pub fn clear(&mut self) { + self.registered_events.clear(); + self.subscribed = 0; + } } 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(); From 4eba5ff487175fd7de08c99d88436931e9880453 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 10 Aug 2026 08:31:39 -0700 Subject: [PATCH 02/11] Resolve active path and scene lookups by pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every tick looks up the active path and the active scene by string id several times per character — in Path.step's segment walk, in Motion.move, in step_animation, and again in the is_active sweep at the end of update. Each lookup ran a memcmp; together they were ~9% of run time. OrderedMap now keys on Rc and hands out its own key allocation via shared_key. Motion::active_path and Animation::active_scene hold that handle, so the lookup's cached-position check settles on a pointer compare and never reaches memcmp. Non-aliased keys still compare by value, so lookup results are unchanged. 10% off the benchmark total: rings -32%, binarypath -15%, fireworks -12%. --- src/effects/swarm.rs | 2 +- src/engine/ctx.rs | 11 +++++------ src/utils/ordered_map.rs | 41 +++++++++++++++++++++++++++++++--------- 3 files changed, 38 insertions(+), 16 deletions(-) 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/engine/ctx.rs b/src/engine/ctx.rs index 639aa85..0d72b54 100644 --- a/src/engine/ctx.rs +++ b/src/engine/ctx.rs @@ -9,7 +9,6 @@ //! by id after every emission point, and segment walks are index-based so //! reentrant list mutation behaves like Python list iteration. -use std::rc::Rc; use std::time::Instant; use crate::engine::active_characters::ActiveCharacters; @@ -119,9 +118,6 @@ impl EngineCtx { // event dispatch (EventHandler._handle_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. /// Whether an emission of `event` on `id` can have any observable effect — /// false lets hot emission sites skip building the CallerKey entirely. #[inline] @@ -129,6 +125,9 @@ impl EngineCtx { 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. pub fn handle_event( &mut self, hooks: &mut dyn EffectHooks, @@ -263,7 +262,7 @@ impl EngineCtx { ); 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 { @@ -512,7 +511,7 @@ 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; } diff --git a/src/utils/ordered_map.rs b/src/utils/ordered_map.rs index c6e91d3..c5541b5 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,16 @@ 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)) + } + pub fn values(&self) -> impl Iterator { self.entries.iter().map(|(_, v)| v) } @@ -77,7 +94,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 +124,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 +160,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)); From cb9b144502da84b4d5b898a10fee59dadad0ce40 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 10 Aug 2026 08:37:51 -0700 Subject: [PATCH 03/11] Compare waypoint event keys by coordinate first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Effects that subscribe to SEGMENT_ENTERED/EXITED pay a linear scan of the character's event table per emission, and the derived comparison on WaypointKey led with waypoint_id — a String, so every candidate cost a memcmp. On rings that was 12% of run time. Coordinates are at least as selective and compare as two integers, so leading with them rejects non-matches before the string is touched. Equality is the same relation, just reached sooner. rings -11%, waves -13%. --- src/engine/events.rs | 6 +++++- src/engine/motion.rs | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/engine/events.rs b/src/engine/events.rs index 5d4c4fa..8040edc 100644 --- a/src/engine/events.rs +++ b/src/engine/events.rs @@ -30,10 +30,14 @@ impl Event { /// 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 waypoint_id: String, pub bezier_control: Option>, } diff --git a/src/engine/motion.rs b/src/engine/motion.rs index 7935528..74a0d35 100644 --- a/src/engine/motion.rs +++ b/src/engine/motion.rs @@ -20,8 +20,8 @@ pub struct Waypoint { 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(), } } From e6302ef73707c89c141829ea6d725b69639db1de Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 10 Aug 2026 08:37:51 -0700 Subject: [PATCH 04/11] Leave the arena for the kernel to reclaim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At exit, dropping EngineCtx walks tens of thousands of characters and frees each one's scenes, frames, paths and formatted symbols individually. Nothing in the crate implements Drop and stdout is flushed before the engine goes out of scope, so that walk buys nothing but exit latency — around 4% of a binarypath run, and more on short effects. Forget the engine after the run and let process teardown reclaim it. thunderstorm -15%, sweep -16%, wipe -10%. --- src/main.rs | 8 ++++++++ 1 file changed, 8 insertions(+) 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() { From e462b5fc98ab321dca7fe73233e7746d78d9fde0 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 10 Aug 2026 08:49:32 -0700 Subject: [PATCH 05/11] Store formatted symbols inline and assemble them once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two costs sat on the frame writer. Emitting a cell called push_str with a couple of dozen bytes, so a memcpy call dominated the copy itself — 8% of run time across the suite. And every CharacterVisual built its SGR string with a fresh String, so effects that restyle characters each frame paid an allocation per character per frame. FormattedSymbol keeps the bytes inline when they fit in 63, which covers a 24-bit foreground/background pair plus the reset, and the writer copies the whole fixed block before advancing by the real length. Assembly moves to a reused thread-local scratch buffer, so building a visual no longer allocates. Symbols too long to inline still go to the heap and copy the old way. 9% off the benchmark total: overflow -30%, vhstape -28%, sweep -22%, spotlights -20%. --- src/engine/animation.rs | 101 +++++++++++++++++++++++++++++++++++----- src/engine/terminal.rs | 13 +++--- tests/engine_traces.rs | 2 +- 3 files changed, 98 insertions(+), 18 deletions(-) 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/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/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()), )); } } From 1f12a885ea746b78ec4d9cc5e0dceba77324d78e Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 10 Aug 2026 08:58:09 -0700 Subject: [PATCH 06/11] Resolve the stepped path and scene once per tick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticking a character looked its active scene up four or five times — for the frames check, for sync/ease, for the step itself, and again inside the completion check — and its active path once per segment of the walk. Every one of those repeated the same map lookup. OrderedMap now exposes entry slots, so a tick resolves the slot once and reuses it. Only a reentrant event action can move or drop an entry, so path stepping re-resolves after each emission and keeps the same "path removed mid-step" failure. Together -5.5%: randomsequence -15%, sweep -5%. --- src/engine/ctx.rs | 89 ++++++++++++++++++++++------------------ src/utils/ordered_map.rs | 18 ++++++++ 2 files changed, 66 insertions(+), 41 deletions(-) diff --git a/src/engine/ctx.rs b/src/engine/ctx.rs index 0d72b54..ae47ba6 100644 --- a/src/engine/ctx.rs +++ b/src/engine/ctx.rs @@ -291,22 +291,28 @@ impl EngineCtx { /// 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") }; } @@ -345,6 +351,7 @@ impl EngineCtx { 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)); + resolve_slot!(); } else { path_mut!().segments[i].enter_event_triggered = true; } @@ -364,10 +371,12 @@ impl EngineCtx { if !enter_triggered { path_mut!().segments[i].enter_event_triggered = true; self.handle_event(hooks, id, Event::SegmentEntered, &CallerKey::Waypoint(seg_end_key.clone())); + resolve_slot!(); } if !exit_triggered { path_mut!().segments[i].exit_event_triggered = true; self.handle_event(hooks, id, Event::SegmentExited, &CallerKey::Waypoint(seg_end_key)); + resolve_slot!(); } } } @@ -426,12 +435,9 @@ 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 { @@ -448,12 +454,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 { @@ -534,39 +535,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| { @@ -575,7 +581,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 @@ -605,9 +611,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); @@ -630,26 +636,27 @@ 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; + } if self.observes_event(id, Event::SceneComplete) { - self.handle_event(hooks, id, Event::SceneComplete, &CallerKey::Scene(scene_id.to_string())); + let scene_id = self.terminal.arena[id.0 as usize].animation.scenes.key_at(scene_slot).to_string(); + self.handle_event(hooks, id, Event::SceneComplete, &CallerKey::Scene(scene_id)); } } diff --git a/src/utils/ordered_map.rs b/src/utils/ordered_map.rs index c5541b5..4eb1121 100644 --- a/src/utils/ordered_map.rs +++ b/src/utils/ordered_map.rs @@ -86,6 +86,24 @@ impl OrderedMap { 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) } From 29cec386e3a25fd18b941af6f94f36346a5d7bab Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 10 Aug 2026 09:18:39 -0700 Subject: [PATCH 07/11] Reference-count waypoint identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Waypoint clone allocated twice: once for its id string and once for its bezier control points. Activating a path clones the first waypoint, builds a synthetic origin waypoint with a fresh "origin" string, and clones the resulting segment twice — and effects that loop paths do that every cycle. Segment crossings clone again to build event keys. Both owned fields are now Rc, so a clone is two refcount bumps, and the origin id is a single shared allocation. Equality still compares by value, so event keys match exactly as before. rings -8%, binarypath -7%, swarm -4%. --- src/engine/ctx.rs | 8 +++++++- src/engine/events.rs | 4 ++-- src/engine/motion.rs | 21 ++++++++++++--------- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/engine/ctx.rs b/src/engine/ctx.rs index ae47ba6..4dd80b1 100644 --- a/src/engine/ctx.rs +++ b/src/engine/ctx.rs @@ -9,6 +9,7 @@ //! by id after every emission point, and segment walks are index-based so //! reentrant list mutation behaves like Python list iteration. +use std::rc::Rc; use std::time::Instant; use crate::engine::active_characters::ActiveCharacters; @@ -23,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)] @@ -256,7 +262,7 @@ 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, ); diff --git a/src/engine/events.rs b/src/engine/events.rs index 8040edc..db643d1 100644 --- a/src/engine/events.rs +++ b/src/engine/events.rs @@ -37,8 +37,8 @@ impl Event { #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct WaypointKey { pub coord: Coord, - pub waypoint_id: String, - 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__). diff --git a/src/engine/motion.rs b/src/engine/motion.rs index 74a0d35..fd80476 100644 --- a/src/engine/motion.rs +++ b/src/engine/motion.rs @@ -10,11 +10,14 @@ 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 { @@ -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}")) } } From f5ae0fbb8344f8763a3ec1ffde0a0dcd34f4aa9e Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 10 Aug 2026 09:28:42 -0700 Subject: [PATCH 08/11] Write SGR color sequences without core::fmt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restyling a character reassembles its escape sequence, and effects that shift color do that for every character every frame. Building the sequence through write! dragged in the formatting machinery — Display for u8, a Formatter, and a dynamic write_str per fragment — for what is at most three decimal digits per channel. Emitting the digits directly cuts that out. 5% off the benchmark total: spotlights -14%, highlight -12%, colorshift -11%, waves -11%. --- src/utils/ansi.rs | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) 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) { From dc82e4b6bb9d45ecdf203b1eb88a713e8ecf0935 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 10 Aug 2026 09:37:32 -0700 Subject: [PATCH 09/11] Ask the cheap half of is_active first The active-character sweep runs is_active for every active character every frame. It ORs two predicates: one is a null check on the active path, the other looks the active scene up in a map. Testing the null check first short-circuits the lookup for every character that is still moving. randomsequence -3%, highlight -3%; -1% overall. --- src/engine/character.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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() } } From c3b6b818d76e771340f47f5efc9d3abe9134984d Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 10 Aug 2026 09:41:17 -0700 Subject: [PATCH 10/11] Match event callers without building a key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firing an event allocated a String for the caller id purely so the lookup had something to compare against — and a looping scene fires SCENE_COMPLETE on every tick of every character it owns. Emission sites already hold the id, so the lookup now takes a borrowed CallerRef and compares against the table in place. Registered keys are still owned and still compare by value. rings -6%, highlight -8%. --- src/engine/ctx.rs | 28 ++++++++++++++-------------- src/engine/events.rs | 26 ++++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/src/engine/ctx.rs b/src/engine/ctx.rs index 4dd80b1..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}; @@ -139,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; @@ -153,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() @@ -291,7 +291,7 @@ impl EngineCtx { self.terminal.arena[id.0 as usize].layer = layer; } if self.observes_event(id, Event::PathActivated) { - self.handle_event(hooks, id, Event::PathActivated, &CallerKey::Path(path_id.to_string())); + self.handle_event(hooks, id, Event::PathActivated, CallerRef::Path(path_id)); } } @@ -356,7 +356,7 @@ impl EngineCtx { 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, &CallerKey::Waypoint(seg_end_key)); + self.handle_event(hooks, id, Event::SegmentEntered, CallerRef::Waypoint(&seg_end_key)); resolve_slot!(); } else { path_mut!().segments[i].enter_event_triggered = true; @@ -376,12 +376,12 @@ impl EngineCtx { 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())); + 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, &CallerKey::Waypoint(seg_end_key)); + self.handle_event(hooks, id, Event::SegmentExited, CallerRef::Waypoint(&seg_end_key)); resolve_slot!(); } } @@ -449,7 +449,7 @@ impl EngineCtx { if current_step == max_steps { if hold_time != 0 && hold_time_remaining == hold_time { if self.observes_event(id, Event::PathHolding) { - self.handle_event(hooks, id, Event::PathHolding, &CallerKey::Path(active_path_id.to_string())); + self.handle_event(hooks, id, Event::PathHolding, CallerRef::Path(&active_path_id)); } self.terminal.arena[id.0 as usize] .motion @@ -473,7 +473,7 @@ impl EngineCtx { motion.deactivate_path(Some(&active_path_id)); } if self.observes_event(id, Event::PathComplete) { - self.handle_event(hooks, id, Event::PathComplete, &CallerKey::Path(active_path_id.to_string())); + self.handle_event(hooks, id, Event::PathComplete, CallerRef::Path(&active_path_id)); } } } @@ -523,7 +523,7 @@ impl EngineCtx { ch.animation.current_character_visual = visual; } if self.observes_event(id, Event::SceneActivated) { - self.handle_event(hooks, id, Event::SceneActivated, &CallerKey::Scene(scene_id.to_string())); + self.handle_event(hooks, id, Event::SceneActivated, CallerRef::Scene(scene_id)); } } @@ -661,8 +661,8 @@ impl EngineCtx { } } if self.observes_event(id, Event::SceneComplete) { - let scene_id = self.terminal.arena[id.0 as usize].animation.scenes.key_at(scene_slot).to_string(); - self.handle_event(hooks, id, Event::SceneComplete, &CallerKey::Scene(scene_id)); + 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 db643d1..fe99bde 100644 --- a/src/engine/events.rs +++ b/src/engine/events.rs @@ -49,6 +49,28 @@ 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), +} + +impl CallerKey { + #[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 { @@ -120,11 +142,11 @@ impl EventHandler { } #[inline] - pub fn actions_index(&self, event: Event, caller: &CallerKey) -> Option { + pub fn actions_index(&self, event: Event, caller: CallerRef<'_>) -> Option { if !self.subscribes(event) { return None; } - self.registered_events.iter().position(|((e, c), _)| *e == event && c == caller) + self.registered_events.iter().position(|((e, c), _)| *e == event && c.matches(caller)) } #[inline] From 1b10e3a956005286888e6919aa25bf51352562b9 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 10 Aug 2026 10:03:05 -0700 Subject: [PATCH 11/11] Fingerprint event table entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Characters that register many events — rings gives each character a registration per ring it passes through — turned every dispatch into a walk of the table comparing caller ids string by string. That scan was a tenth of a rings run. Each entry now carries a small hash of its caller, so a lookup hashes the query once and rejects the rest on an integer compare. Tables with a couple of entries skip the hash and compare directly, which is cheaper there. Registration order and match semantics are unchanged. rings -16%, randomsequence -9%. --- src/engine/events.rs | 81 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 72 insertions(+), 9 deletions(-) diff --git a/src/engine/events.rs b/src/engine/events.rs index fe99bde..c339606 100644 --- a/src/engine/events.rs +++ b/src/engine/events.rs @@ -59,7 +59,44 @@ pub enum CallerRef<'a> { 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) { @@ -112,23 +149,38 @@ pub enum EventAction { /// nothing could match. #[derive(Debug, Clone, Default)] pub struct EventHandler { - 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 { /// register_event with the duplicate check (upstream raises /// 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(()) @@ -146,12 +198,23 @@ impl EventHandler { if !self.subscribes(event) { return None; } - self.registered_events.iter().position(|((e, c), _)| *e == event && c.matches(caller)) + // 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].1 + &self.registered_events[index].actions } pub fn clear(&mut self) {