Skip to main content

rustapi/docks/
data.rs

1use crate::docks::data_undo::*;
2use crate::editor::RUSTERIX;
3use crate::prelude::*;
4use rusterix::PixelSource;
5use rusterix::avatar_builder::AvatarRuntimeBuilder;
6use rusterix::server::data::{apply_entity_data, apply_item_data};
7use theframework::prelude::*;
8use theframework::theui::thewidget::thetextedit::TheTextEditState;
9use toml::Table;
10
11/// Unique identifier for entities being edited
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
13pub enum EntityKey {
14    RegionSettings(Uuid),
15    Character(Uuid),
16    CharacterPreviewRigging(Uuid),
17    Item(Uuid),
18    ProjectSettings,
19    GameRules,
20    GameLocales,
21    GameAudioFx,
22    ScreenWidget(Uuid, Uuid), // (screen_id, widget_id)
23}
24
25#[derive(Clone, Debug)]
26struct CharacterPreviewRigging {
27    animation: Option<String>,
28    perspective: AvatarDirection,
29    fixed_frame: usize,
30    play: bool,
31    speed: f32,
32    debug: bool,
33    slots: FxHashMap<String, String>,
34    slot_overrides: FxHashMap<String, CharacterPreviewSlotOverride>,
35    attrs: FxHashMap<String, Value>,
36}
37
38#[derive(Clone, Debug, Default)]
39struct CharacterPreviewSlotOverride {
40    rig_scale: Option<f32>,
41    rig_pivot: Option<[f32; 2]>,
42    rig_layer: Option<String>,
43}
44
45pub struct DataDock {
46    // Per-entity undo stacks
47    entity_undos: FxHashMap<EntityKey, DataUndo>,
48    current_entity: Option<EntityKey>,
49    max_undo: usize,
50    prev_state: Option<TheTextEditState>,
51    validation_signatures: FxHashMap<EntityKey, String>,
52}
53
54impl Dock for DataDock {
55    fn new() -> Self
56    where
57        Self: Sized,
58    {
59        Self {
60            entity_undos: FxHashMap::default(),
61            current_entity: None,
62            max_undo: 30,
63            prev_state: None,
64            validation_signatures: FxHashMap::default(),
65        }
66    }
67
68    fn setup(&mut self, _ctx: &mut TheContext) -> TheCanvas {
69        let mut center = TheCanvas::new();
70
71        let mut toolbar_canvas = TheCanvas::default();
72        toolbar_canvas.set_widget(TheTraybar::new(TheId::empty()));
73        let mut toolbar_hlayout = TheHLayout::new(TheId::empty());
74        toolbar_hlayout.set_background_color(None);
75        toolbar_hlayout.set_margin(Vec4::new(10, 1, 5, 1));
76        toolbar_hlayout.set_padding(3);
77
78        let mut play = TheTraybarButton::new(TheId::named("Audio FX Preview Play"));
79        play.set_text("Play".to_string());
80        play.set_status_text("Preview the audio effect under the cursor");
81        toolbar_hlayout.add_widget(Box::new(play));
82
83        toolbar_canvas.set_layout(toolbar_hlayout);
84        center.set_top(toolbar_canvas);
85
86        let mut textedit = TheTextAreaEdit::new(TheId::named("DockDataEditor"));
87        if let Some(bytes) = crate::Embedded::get("parser/TOML.sublime-syntax") {
88            if let Ok(source) = std::str::from_utf8(bytes.data.as_ref()) {
89                textedit.add_syntax_from_string(source);
90                textedit.set_code_type("TOML");
91            }
92        }
93
94        if let Some(bytes) = crate::Embedded::get("parser/gruvbox-dark.tmTheme") {
95            if let Ok(source) = std::str::from_utf8(bytes.data.as_ref()) {
96                textedit.add_theme_from_string(source);
97                textedit.set_code_theme("Gruvbox Dark");
98            }
99        }
100
101        textedit.set_continuous(true);
102        textedit.display_line_number(true);
103        // textedit.set_code_theme("base16-eighties.dark");
104        textedit.use_global_statusbar(true);
105        textedit.set_font_size(14.0);
106        // Tell the widget we handle undo/redo manually here
107        textedit.set_supports_undo(false);
108        center.set_widget(textedit);
109
110        center
111    }
112
113    fn activate(
114        &mut self,
115        ui: &mut TheUI,
116        ctx: &mut TheContext,
117        project: &Project,
118        server_ctx: &mut ServerContext,
119    ) {
120        if let Some(id) = server_ctx.pc.id() {
121            if server_ctx.pc.is_region() {
122                if let Some(region) = project.get_region(&id) {
123                    ui.set_widget_value(
124                        "DockDataEditor",
125                        ctx,
126                        TheValue::Text(region.config.clone()),
127                    );
128                    // Switch to this entity's undo stack
129                    self.switch_to_entity(EntityKey::RegionSettings(id), ctx);
130                }
131            } else if server_ctx.pc.is_character() {
132                if let Some(character) = project.characters.get(&id) {
133                    match server_ctx.pc {
134                        ProjectContext::CharacterPreviewRigging(_) => {
135                            ui.set_widget_value(
136                                "DockDataEditor",
137                                ctx,
138                                TheValue::Text(character.preview_rigging.clone()),
139                            );
140                            self.switch_to_entity(EntityKey::CharacterPreviewRigging(id), ctx);
141                        }
142                        _ => {
143                            ui.set_widget_value(
144                                "DockDataEditor",
145                                ctx,
146                                TheValue::Text(character.data.clone()),
147                            );
148                            self.switch_to_entity(EntityKey::Character(id), ctx);
149                        }
150                    }
151                }
152            } else if server_ctx.pc.is_item() {
153                if let Some(item) = project.items.get(&id) {
154                    ui.set_widget_value("DockDataEditor", ctx, TheValue::Text(item.data.clone()));
155                    // Switch to this entity's undo stack
156                    self.switch_to_entity(EntityKey::Item(id), ctx);
157                }
158            } else if let ProjectContext::ScreenWidget(screen_id, widget_id) = server_ctx.pc {
159                if let Some(screen) = project.screens.get(&screen_id) {
160                    for sector in &screen.map.sectors {
161                        if sector.creator_id == widget_id {
162                            let data = sector.properties.get_str_default("data", "".into());
163                            ui.set_widget_value("DockDataEditor", ctx, TheValue::Text(data));
164                            // Switch to this entity's undo stack
165                            self.switch_to_entity(
166                                EntityKey::ScreenWidget(screen_id, widget_id),
167                                ctx,
168                            );
169                            break;
170                        }
171                    }
172                }
173            }
174        } else if server_ctx.pc.is_project_settings() {
175            ui.set_widget_value(
176                "DockDataEditor",
177                ctx,
178                TheValue::Text(project.config.clone()),
179            );
180            // Switch to this entity's undo stack
181            self.switch_to_entity(EntityKey::ProjectSettings, ctx);
182        } else if server_ctx.pc.is_game_rules() {
183            ui.set_widget_value("DockDataEditor", ctx, TheValue::Text(project.rules.clone()));
184            self.switch_to_entity(EntityKey::GameRules, ctx);
185        } else if server_ctx.pc.is_game_locales() {
186            ui.set_widget_value(
187                "DockDataEditor",
188                ctx,
189                TheValue::Text(project.locales.clone()),
190            );
191            self.switch_to_entity(EntityKey::GameLocales, ctx);
192        } else if server_ctx.pc.is_game_audio_fx() {
193            ui.set_widget_value(
194                "DockDataEditor",
195                ctx,
196                TheValue::Text(project.audio_fx.clone()),
197            );
198            self.switch_to_entity(EntityKey::GameAudioFx, ctx);
199        }
200
201        self.sync_audio_fx_toolbar(ctx, server_ctx);
202        self.validate_project_documents(project);
203
204        // Store initial state for undo
205        if let Some(edit) = ui.get_text_area_edit("DockDataEditor") {
206            self.prev_state = Some(edit.get_state());
207        }
208    }
209
210    fn handle_event(
211        &mut self,
212        event: &TheEvent,
213        ui: &mut TheUI,
214        ctx: &mut TheContext,
215        project: &mut Project,
216        server_ctx: &mut ServerContext,
217    ) -> bool {
218        let mut redraw = false;
219
220        match event {
221            TheEvent::ValueChanged(id, value) => {
222                if id.name == "DockDataEditor" {
223                    if let Some(edit) = ui.get_text_area_edit("DockDataEditor") {
224                        // Add undo atom before applying the change
225                        if let Some(prev) = &self.prev_state {
226                            let current_state = edit.get_state();
227                            let atom = DataUndoAtom::TextEdit(prev.clone(), current_state.clone());
228                            self.add_undo(atom, ctx);
229                            self.prev_state = Some(current_state);
230                        }
231                    }
232
233                    if let Some(id) = server_ctx.pc.id() {
234                        if server_ctx.pc.is_region() {
235                            if let Some(code) = value.to_string() {
236                                if let Some(region) = project.get_region_mut(&id) {
237                                    region.config = code;
238                                    redraw = true;
239                                }
240                            }
241                            if let Ok(changed) =
242                                crate::utils::update_region_settings(project, server_ctx)
243                            {
244                                if changed {
245                                    ctx.ui.send(TheEvent::Custom(
246                                        TheId::named("Update Minimap"),
247                                        TheValue::Empty,
248                                    ));
249
250                                    RUSTERIX.write().unwrap().set_dirty();
251
252                                    ctx.ui.send(TheEvent::Custom(
253                                        TheId::named("Render SceneManager Map"),
254                                        TheValue::Empty,
255                                    ));
256                                }
257                            }
258                        } else if server_ctx.pc.is_character() {
259                            if let Some(code) = value.to_string() {
260                                if let Some(character) = project.characters.get_mut(&id) {
261                                    match server_ctx.pc {
262                                        ProjectContext::CharacterPreviewRigging(_) => {
263                                            character.preview_rigging = code;
264                                            ctx.ui.send(TheEvent::Custom(
265                                                TheId::named("Update Minimap"),
266                                                TheValue::Empty,
267                                            ));
268                                        }
269                                        _ => character.data = code,
270                                    }
271                                    redraw = true;
272                                }
273                            }
274                        } else if server_ctx.pc.is_item() {
275                            if let Some(code) = value.to_string() {
276                                if let Some(item) = project.items.get_mut(&id) {
277                                    item.data = code;
278                                    redraw = true;
279                                }
280                            }
281                        } else if let ProjectContext::ScreenWidget(screen_id, widget_id) =
282                            server_ctx.pc
283                        {
284                            if let Some(code) = value.to_string() {
285                                if let Some(screen) = project.screens.get_mut(&screen_id) {
286                                    for sector in &mut screen.map.sectors {
287                                        if sector.creator_id == widget_id {
288                                            sector.properties.set("data".into(), Value::Str(code));
289                                            redraw = true;
290                                            break;
291                                        }
292                                    }
293                                }
294                            }
295                        }
296                    } else if server_ctx.pc.is_project_settings() {
297                        if let Some(code) = value.to_string() {
298                            _ = RUSTERIX.write().unwrap().scene_handler.settings.read(&code);
299                            project.config = code;
300                            redraw = true;
301                        }
302                    } else if server_ctx.pc.is_game_rules() {
303                        if let Some(code) = value.to_string() {
304                            project.rules = code;
305                            redraw = true;
306                        }
307                    } else if server_ctx.pc.is_game_locales() {
308                        if let Some(code) = value.to_string() {
309                            project.locales = code;
310                            redraw = true;
311                        }
312                    } else if server_ctx.pc.is_game_audio_fx() {
313                        if let Some(code) = value.to_string() {
314                            project.audio_fx = code;
315                            redraw = true;
316                            let mut rusterix = RUSTERIX.write().unwrap();
317                            rusterix.assets.audio_fx_src = project.audio_fx.clone();
318                            rusterix.load_audio_assets();
319                        }
320                    }
321
322                    self.validate_project_documents(project);
323                }
324            }
325            TheEvent::StateChanged(id, state) => {
326                if *state == TheWidgetState::Clicked {
327                    if id.name == "Audio FX Preview Play" {
328                        self.preview_audio_fx(ui, project);
329                    }
330                }
331            }
332            _ => {}
333        }
334        redraw
335    }
336
337    fn draw_minimap(
338        &self,
339        buffer: &mut TheRGBABuffer,
340        project: &Project,
341        ctx: &mut TheContext,
342        server_ctx: &ServerContext,
343    ) -> bool {
344        let ProjectContext::CharacterPreviewRigging(character_id) = server_ctx.pc else {
345            return false;
346        };
347        let Some(character) = project.characters.get(&character_id) else {
348            return false;
349        };
350
351        let mut entity = rusterix::Entity::default();
352        apply_entity_data(&mut entity, &character.data);
353
354        let preview = Self::parse_preview_rigging(&character.preview_rigging);
355        if preview.debug {
356            eprintln!(
357                "[RIGPREVIEW] active char={} anim='{}' perspective={:?} play={} speed={} slots={} overrides={} attrs={}",
358                character_id,
359                preview.animation.as_deref().unwrap_or("<first>"),
360                preview.perspective,
361                preview.play,
362                preview.speed,
363                preview.slots.len(),
364                preview.slot_overrides.len(),
365                preview.attrs.len()
366            );
367        }
368        Self::populate_preview_equipment(&preview, project, &mut entity);
369
370        let Some(avatar) = Self::find_preview_avatar(&entity, project) else {
371            buffer.fill(BLACK);
372            return true;
373        };
374
375        let frame_index = Self::preview_frame_index(avatar, &preview, server_ctx.animation_counter);
376        let mut assets = rusterix::Assets::new();
377        assets.palette = project.palette.clone();
378        assets.tiles = project.tiles.clone();
379
380        let out = AvatarRuntimeBuilder::build_preview_for_entity(
381            &entity,
382            avatar,
383            &assets,
384            preview.animation.as_deref(),
385            preview.perspective,
386            frame_index,
387            rusterix::AvatarShadingOptions::default(),
388        );
389
390        buffer.fill(BLACK);
391        let Some(out) = out else {
392            if preview.debug {
393                eprintln!(
394                    "[RIGPREVIEW] build failed anim='{}' perspective={:?} frame={}",
395                    preview.animation.as_deref().unwrap_or("<first>"),
396                    preview.perspective,
397                    frame_index
398                );
399            }
400            return true;
401        };
402
403        let src_w = out.size as usize;
404        let src_h = out.size as usize;
405        if src_w == 0 || src_h == 0 {
406            return true;
407        }
408
409        let dim = buffer.dim();
410        let dst_w = dim.width as f32;
411        let dst_h = dim.height as f32;
412        let scale = (dst_w / src_w as f32).min(dst_h / src_h as f32);
413        let draw_w = (src_w as f32 * scale).round().max(1.0) as usize;
414        let draw_h = (src_h as f32 * scale).round().max(1.0) as usize;
415        let offset_x = ((dst_w as usize).saturating_sub(draw_w)) / 2;
416        let offset_y = ((dst_h as usize).saturating_sub(draw_h)) / 2;
417        let dst_rect = (offset_x, offset_y, draw_w, draw_h);
418
419        let stride = buffer.stride();
420        ctx.draw.blend_scale_chunk(
421            buffer.pixels_mut(),
422            &dst_rect,
423            stride,
424            &out.rgba,
425            &(src_w, src_h),
426        );
427
428        true
429    }
430
431    fn supports_minimap_animation(&self) -> bool {
432        true
433    }
434
435    fn supports_undo(&self) -> bool {
436        true
437    }
438
439    fn has_changes(&self) -> bool {
440        // Check if any entity has changes (index >= 0, meaning not fully undone)
441        self.entity_undos.values().any(|undo| undo.has_changes())
442    }
443
444    fn mark_saved(&mut self) {
445        for undo in self.entity_undos.values_mut() {
446            undo.index = -1;
447        }
448    }
449
450    fn undo(
451        &mut self,
452        ui: &mut TheUI,
453        ctx: &mut TheContext,
454        project: &mut Project,
455        server_ctx: &mut ServerContext,
456    ) {
457        if let Some(entity_key) = self.current_entity {
458            if let Some(undo) = self.entity_undos.get_mut(&entity_key) {
459                if let Some(edit) = ui.get_text_area_edit("DockDataEditor") {
460                    undo.undo(edit);
461                    self.prev_state = Some(edit.get_state());
462                    self.set_undo_state_to_ui(ctx);
463
464                    // Update the project with the undone text
465                    self.update_project_data(ui, ctx, project, server_ctx);
466                }
467            }
468        }
469    }
470
471    fn redo(
472        &mut self,
473        ui: &mut TheUI,
474        ctx: &mut TheContext,
475        project: &mut Project,
476        server_ctx: &mut ServerContext,
477    ) {
478        if let Some(entity_key) = self.current_entity {
479            if let Some(undo) = self.entity_undos.get_mut(&entity_key) {
480                if let Some(edit) = ui.get_text_area_edit("DockDataEditor") {
481                    undo.redo(edit);
482                    self.prev_state = Some(edit.get_state());
483                    self.set_undo_state_to_ui(ctx);
484
485                    // Update the project with the redone text
486                    self.update_project_data(ui, ctx, project, server_ctx);
487                }
488            }
489        }
490    }
491
492    fn set_undo_state_to_ui(&self, ctx: &mut TheContext) {
493        if let Some(entity_key) = self.current_entity {
494            if let Some(undo) = self.entity_undos.get(&entity_key) {
495                if undo.has_undo() {
496                    ctx.ui.set_enabled("Undo");
497                } else {
498                    ctx.ui.set_disabled("Undo");
499                }
500
501                if undo.has_redo() {
502                    ctx.ui.set_enabled("Redo");
503                } else {
504                    ctx.ui.set_disabled("Redo");
505                }
506                return;
507            }
508        }
509
510        // No entity selected or no undo stack
511        ctx.ui.set_disabled("Undo");
512        ctx.ui.set_disabled("Redo");
513    }
514}
515
516impl DataDock {
517    fn sync_audio_fx_toolbar(&mut self, ctx: &mut TheContext, server_ctx: &ServerContext) {
518        let active = server_ctx.pc.is_game_audio_fx();
519        for id in ["Audio FX Preview Play"] {
520            if active {
521                ctx.ui.set_enabled(id);
522            } else {
523                ctx.ui.set_disabled(id);
524            }
525        }
526    }
527
528    fn preview_audio_fx(&mut self, ui: &mut TheUI, project: &Project) {
529        let Some(effect_name) = self.current_audio_fx_name(ui) else {
530            return;
531        };
532        let Ok(bytes) = rusterix::audio::synthesize_audio_fx_wav(&project.audio_fx, &effect_name)
533        else {
534            return;
535        };
536
537        let mut rusterix = RUSTERIX.write().unwrap();
538        if rusterix.audio.is_none() {
539            rusterix.audio = rusterix::AudioEngine::new().ok();
540        }
541        let Some(engine) = rusterix.audio.as_ref() else {
542            return;
543        };
544        engine.clear_bus("preview");
545        let clip_name = "__audio_fx_preview";
546        let _ = engine.load_clip_from_bytes(clip_name, &bytes);
547        let _ = engine.play_on_bus(clip_name, "preview", 1.0, false);
548    }
549
550    fn current_audio_fx_name(&self, ui: &mut TheUI) -> Option<String> {
551        let edit = ui.get_text_area_edit("DockDataEditor")?;
552        let state = edit.get_state();
553        let row = state.cursor.row.min(state.rows.len().saturating_sub(1));
554
555        for index in (0..=row).rev() {
556            let line = state.rows.get(index)?.trim();
557            if let Some(section) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
558                let section = section.trim();
559                if let Some(name) = section.strip_prefix("sfx.") {
560                    let name = name.trim();
561                    if !name.is_empty() {
562                        return Some(name.to_string());
563                    }
564                }
565            }
566        }
567        None
568    }
569
570    fn is_preview_slot_key(key: &str) -> bool {
571        matches!(
572            key.to_ascii_lowercase().as_str(),
573            "main_hand"
574                | "mainhand"
575                | "weapon"
576                | "weapon_main"
577                | "hand_main"
578                | "off_hand"
579                | "offhand"
580                | "weapon_off"
581                | "hand_off"
582                | "shield"
583        )
584    }
585
586    fn parse_preview_rigging(toml_src: &str) -> CharacterPreviewRigging {
587        let mut out = CharacterPreviewRigging {
588            animation: None,
589            perspective: AvatarDirection::Front,
590            fixed_frame: 0,
591            play: true,
592            speed: 1.0,
593            debug: false,
594            slots: FxHashMap::default(),
595            slot_overrides: FxHashMap::default(),
596            attrs: FxHashMap::default(),
597        };
598
599        let Ok(table) = toml_src.parse::<Table>() else {
600            return out;
601        };
602
603        out.animation = table
604            .get("animation")
605            .and_then(toml::Value::as_str)
606            .map(ToString::to_string);
607        if let Some(dir) = table.get("perspective").and_then(toml::Value::as_str) {
608            out.perspective = match dir.to_ascii_lowercase().as_str() {
609                "back" => AvatarDirection::Back,
610                "left" => AvatarDirection::Left,
611                "right" => AvatarDirection::Right,
612                _ => AvatarDirection::Front,
613            };
614        }
615        out.fixed_frame = table
616            .get("frame")
617            .and_then(toml::Value::as_integer)
618            .unwrap_or(0)
619            .max(0) as usize;
620        out.play = table
621            .get("play")
622            .and_then(toml::Value::as_bool)
623            .unwrap_or(true);
624        out.speed = table
625            .get("speed")
626            .and_then(toml::Value::as_float)
627            .unwrap_or(1.0)
628            .max(0.01) as f32;
629        out.debug = table
630            .get("debug")
631            .and_then(toml::Value::as_bool)
632            .unwrap_or(false);
633
634        // Top-level preview attributes (e.g. torso_index = 2)
635        for (key, value) in &table {
636            if matches!(
637                key.as_str(),
638                "animation"
639                    | "perspective"
640                    | "frame"
641                    | "play"
642                    | "speed"
643                    | "debug"
644                    | "slots"
645                    | "slot_overrides"
646            ) {
647                continue;
648            }
649            if Self::is_preview_slot_key(key)
650                && let Some(item_ref) = value.as_str()
651            {
652                out.slots.insert(key.to_string(), item_ref.to_string());
653                continue;
654            }
655            if let Some(v) = Self::toml_to_attr_value(value) {
656                out.attrs.insert(key.to_string(), v);
657            }
658        }
659
660        if let Some(slots) = table.get("slots").and_then(toml::Value::as_table) {
661            for (slot, value) in slots {
662                if let Some(v) = value.as_str() {
663                    out.slots.insert(slot.to_string(), v.to_string());
664                } else if let Some(v) = Self::toml_to_attr_value(value) {
665                    // Allow preview color/index overrides under [slots] for backward compatibility.
666                    out.attrs.insert(slot.to_string(), v);
667                }
668            }
669        }
670
671        if let Some(overrides) = table.get("slot_overrides").and_then(toml::Value::as_table) {
672            for (slot, value) in overrides {
673                let Some(slot_table) = value.as_table() else {
674                    continue;
675                };
676                let mut slot_override = CharacterPreviewSlotOverride::default();
677                if let Some(scale) = slot_table.get("rig_scale").and_then(toml::Value::as_float) {
678                    slot_override.rig_scale = Some(scale as f32);
679                }
680                if let Some(pivot) = slot_table.get("rig_pivot").and_then(toml::Value::as_array)
681                    && pivot.len() == 2
682                    && let (Some(x), Some(y)) = (pivot[0].as_float(), pivot[1].as_float())
683                {
684                    slot_override.rig_pivot = Some([x as f32, y as f32]);
685                }
686                if let Some(layer) = slot_table.get("rig_layer").and_then(toml::Value::as_str) {
687                    slot_override.rig_layer = Some(layer.to_string());
688                }
689                if slot_override.rig_scale.is_some()
690                    || slot_override.rig_pivot.is_some()
691                    || slot_override.rig_layer.is_some()
692                {
693                    out.slot_overrides.insert(slot.to_string(), slot_override);
694                }
695            }
696        }
697
698        out
699    }
700
701    fn find_preview_avatar<'a>(
702        entity: &rusterix::Entity,
703        project: &'a Project,
704    ) -> Option<&'a Avatar> {
705        if let Some(avatar_id) = entity.attributes.get_id("avatar_id")
706            && let Some(avatar) = project.avatars.get(&avatar_id)
707        {
708            return Some(avatar);
709        }
710        if let Some(name) = entity.attributes.get_str("avatar") {
711            for avatar in project.avatars.values() {
712                if avatar.name.eq_ignore_ascii_case(name) {
713                    return Some(avatar);
714                }
715            }
716        }
717        project.avatars.values().next()
718    }
719
720    fn find_item_template<'a>(project: &'a Project, ident: &str) -> Option<&'a Item> {
721        project.items.values().find(|item| {
722            if item.name.eq_ignore_ascii_case(ident) {
723                return true;
724            }
725
726            let mut parsed = rusterix::Item::default();
727            apply_item_data(&mut parsed, &item.data);
728            if parsed
729                .attributes
730                .get_str("name")
731                .map(|name| name.eq_ignore_ascii_case(ident))
732                .unwrap_or(false)
733            {
734                return true;
735            }
736
737            // Also support top-level item TOML names in preview lookup.
738            if let Ok(table) = item.data.parse::<Table>() {
739                return table
740                    .get("name")
741                    .and_then(toml::Value::as_str)
742                    .map(|name| name.eq_ignore_ascii_case(ident))
743                    .unwrap_or(false);
744            }
745            false
746        })
747    }
748
749    fn apply_preview_item_top_level(item: &mut rusterix::Item, toml_src: &str) {
750        let Ok(table) = toml_src.parse::<Table>() else {
751            return;
752        };
753        for key in [
754            "tile_id",
755            "tile_id_front",
756            "tile_id_back",
757            "tile_id_left",
758            "tile_id_right",
759        ] {
760            if let Some(id) = table.get(key).and_then(toml::Value::as_str)
761                && let Ok(uuid) = Uuid::parse_str(id)
762            {
763                item.attributes
764                    .set(key, Value::Source(PixelSource::TileId(uuid)));
765            }
766        }
767        if let Some(scale) = table.get("rig_scale").and_then(toml::Value::as_float) {
768            item.attributes.set("rig_scale", Value::Float(scale as f32));
769        }
770        if let Some(pivot) = table.get("rig_pivot").and_then(toml::Value::as_array)
771            && pivot.len() == 2
772            && let (Some(x), Some(y)) = (pivot[0].as_float(), pivot[1].as_float())
773        {
774            item.attributes
775                .set("rig_pivot", Value::Vec2([x as f32, y as f32]));
776        }
777        if let Some(slot) = table.get("slot").and_then(toml::Value::as_str) {
778            item.attributes.set("slot", Value::Str(slot.to_string()));
779        }
780        if let Some(layer) = table.get("rig_layer").and_then(toml::Value::as_str) {
781            item.attributes
782                .set("rig_layer", Value::Str(layer.to_string()));
783        }
784    }
785
786    fn populate_preview_equipment(
787        preview: &CharacterPreviewRigging,
788        project: &Project,
789        entity: &mut rusterix::Entity,
790    ) {
791        entity.equipped.clear();
792        entity
793            .attributes
794            .set("avatar_preview_debug", Value::Bool(preview.debug));
795        for (key, value) in &preview.attrs {
796            entity.attributes.set(key, value.clone());
797        }
798        for (slot, item_ref) in &preview.slots {
799            let Some(template) = Self::find_item_template(project, item_ref) else {
800                if preview.debug {
801                    eprintln!(
802                        "[RIGPREVIEW] slot='{}' item='{}' -> NOT FOUND",
803                        slot, item_ref
804                    );
805                }
806                continue;
807            };
808            let mut runtime_item = rusterix::Item::default();
809            apply_item_data(&mut runtime_item, &template.data);
810            Self::apply_preview_item_top_level(&mut runtime_item, &template.data);
811            runtime_item
812                .attributes
813                .set("slot", Value::Str(slot.to_string()));
814            if let Some(override_cfg) = preview.slot_overrides.get(slot) {
815                if let Some(scale) = override_cfg.rig_scale {
816                    runtime_item
817                        .attributes
818                        .set("rig_scale", Value::Float(scale.max(0.01)));
819                }
820                if let Some(pivot) = override_cfg.rig_pivot {
821                    runtime_item.attributes.set("rig_pivot", Value::Vec2(pivot));
822                }
823                if let Some(layer) = &override_cfg.rig_layer {
824                    runtime_item
825                        .attributes
826                        .set("rig_layer", Value::Str(layer.clone()));
827                }
828            }
829            if preview.debug {
830                let has_tile = runtime_item
831                    .attributes
832                    .get_source("source")
833                    .or_else(|| runtime_item.attributes.get_source("tile_id"))
834                    .or_else(|| runtime_item.attributes.get_source("tile_id_front"))
835                    .or_else(|| runtime_item.attributes.get_source("tile_id_back"))
836                    .or_else(|| runtime_item.attributes.get_source("tile_id_left"))
837                    .or_else(|| runtime_item.attributes.get_source("tile_id_right"))
838                    .is_some();
839                eprintln!(
840                    "[RIGPREVIEW] slot='{}' item='{}' -> FOUND name='{}' tile={} override_scale={:?} override_pivot={:?} override_layer={:?}",
841                    slot,
842                    item_ref,
843                    template.name,
844                    has_tile,
845                    preview.slot_overrides.get(slot).and_then(|o| o.rig_scale),
846                    preview.slot_overrides.get(slot).and_then(|o| o.rig_pivot),
847                    preview
848                        .slot_overrides
849                        .get(slot)
850                        .and_then(|o| o.rig_layer.clone())
851                );
852            }
853            entity.equipped.insert(slot.to_string(), runtime_item);
854        }
855    }
856
857    fn toml_to_attr_value(value: &toml::Value) -> Option<Value> {
858        if let Some(v) = value.as_integer() {
859            return Some(Value::Int(v as i32));
860        }
861        if let Some(v) = value.as_float() {
862            return Some(Value::Float(v as f32));
863        }
864        if let Some(v) = value.as_bool() {
865            return Some(Value::Bool(v));
866        }
867        if let Some(v) = value.as_str() {
868            return Some(Value::Str(v.to_string()));
869        }
870        None
871    }
872
873    fn preview_frame_index(
874        avatar: &Avatar,
875        preview: &CharacterPreviewRigging,
876        animation_counter: usize,
877    ) -> usize {
878        let Some(anim) = preview
879            .animation
880            .as_deref()
881            .and_then(|name| {
882                avatar
883                    .animations
884                    .iter()
885                    .find(|a| a.name.eq_ignore_ascii_case(name))
886            })
887            .or_else(|| avatar.animations.first())
888        else {
889            return preview.fixed_frame;
890        };
891        let frame_count = anim
892            .perspectives
893            .iter()
894            .find(|p| p.direction == preview.perspective)
895            .or_else(|| {
896                anim.perspectives
897                    .iter()
898                    .find(|p| p.direction == AvatarDirection::Front)
899            })
900            .or_else(|| anim.perspectives.first())
901            .map(|p| p.frames.len().max(1))
902            .unwrap_or(1);
903
904        if preview.play {
905            ((animation_counter as f32 / preview.speed).floor() as usize) % frame_count
906        } else {
907            preview.fixed_frame % frame_count
908        }
909    }
910
911    /// Switch to a different entity and update undo button states
912    fn switch_to_entity(&mut self, entity_key: EntityKey, ctx: &mut TheContext) {
913        self.current_entity = Some(entity_key);
914        self.set_undo_state_to_ui(ctx);
915    }
916
917    fn validate_project_documents(&mut self, project: &Project) {
918        let Some(entity_key) = self.current_entity else {
919            return;
920        };
921
922        if !matches!(
923            entity_key,
924            EntityKey::GameRules | EntityKey::GameLocales | EntityKey::GameAudioFx
925        ) {
926            return;
927        }
928
929        let issues = Self::collect_project_validation_issues(project);
930        let signature = issues.join("\n");
931        let previous = self
932            .validation_signatures
933            .insert(entity_key, signature.clone())
934            .unwrap_or_default();
935
936        if signature == previous || issues.is_empty() {
937            return;
938        }
939
940        let label = match entity_key {
941            EntityKey::GameRules => "Game / Rules",
942            EntityKey::GameLocales => "Game / Locales",
943            EntityKey::GameAudioFx => "Game / Audio FX",
944            _ => return,
945        };
946
947        let mut chunk = format!("[Warning] {} validation\n", label);
948        for issue in issues {
949            chunk.push_str("- ");
950            chunk.push_str(&issue);
951            chunk.push('\n');
952        }
953
954        let mut rusterix = RUSTERIX.write().unwrap();
955        rusterix.server.log.push_str(&chunk);
956        rusterix.server.log_changed = true;
957    }
958
959    fn collect_project_validation_issues(project: &Project) -> Vec<String> {
960        let mut issues = Vec::new();
961
962        let locale_tables = match Self::parse_locale_tables(&project.locales) {
963            Ok(locales) => locales,
964            Err(err) => {
965                issues.push(format!("Locales TOML parse error: {}", err));
966                FxHashMap::default()
967            }
968        };
969
970        let (audio_fx_names, audio_fx_issues) =
971            Self::parse_audio_fx_names_and_issues(&project.audio_fx);
972        issues.extend(audio_fx_issues);
973
974        let asset_audio_names = project
975            .assets
976            .values()
977            .filter(|asset| matches!(asset.buffer, AssetBuffer::Audio(_)))
978            .map(|asset| asset.name.clone())
979            .collect::<FxHashSet<_>>();
980
981        match project.rules.parse::<Table>() {
982            Ok(rules) => {
983                let referenced_locale_keys = Self::rules_locale_keys(&rules);
984                let referenced_audio_fx = Self::rules_audio_fx_refs(&rules);
985
986                if locale_tables.is_empty() {
987                    for key in &referenced_locale_keys {
988                        issues.push(format!(
989                            "Rules reference locale key '{}' but Game / Locales has no locale tables.",
990                            key
991                        ));
992                    }
993                } else {
994                    for locale in locale_tables.keys() {
995                        let keys = locale_tables.get(locale).unwrap();
996                        for key in &referenced_locale_keys {
997                            if !keys.contains(key) {
998                                issues
999                                    .push(format!("Locale '{}' is missing key '{}'.", locale, key));
1000                            }
1001                        }
1002                    }
1003                }
1004
1005                for (path, name) in referenced_audio_fx {
1006                    if !audio_fx_names.contains(&name) && !asset_audio_names.contains(&name) {
1007                        issues.push(format!(
1008                            "Rules reference unknown audio '{}' at '{}'.",
1009                            name, path
1010                        ));
1011                    }
1012                }
1013            }
1014            Err(err) => issues.push(format!("Rules TOML parse error: {}", err)),
1015        }
1016
1017        issues
1018    }
1019
1020    fn parse_locale_tables(src: &str) -> Result<FxHashMap<String, FxHashSet<String>>, String> {
1021        let table = src.parse::<Table>().map_err(|err| err.to_string())?;
1022        let mut locales = FxHashMap::default();
1023        for (locale, value) in table {
1024            let Some(locale_table) = value.as_table() else {
1025                continue;
1026            };
1027            let mut keys = FxHashSet::default();
1028            Self::flatten_locale_keys("", locale_table, &mut keys);
1029            locales.insert(locale, keys);
1030        }
1031        Ok(locales)
1032    }
1033
1034    fn flatten_locale_keys(prefix: &str, table: &Table, out: &mut FxHashSet<String>) {
1035        for (key, value) in table {
1036            let full = if prefix.is_empty() {
1037                key.clone()
1038            } else {
1039                format!("{}.{}", prefix, key)
1040            };
1041            if let Some(nested) = value.as_table() {
1042                Self::flatten_locale_keys(&full, nested, out);
1043            } else {
1044                out.insert(full);
1045            }
1046        }
1047    }
1048
1049    fn parse_audio_fx_names_and_issues(src: &str) -> (FxHashSet<String>, Vec<String>) {
1050        const ALLOWED_PARAMS: &[&str] = &[
1051            "wave",
1052            "duration",
1053            "attack",
1054            "decay",
1055            "sustain_level",
1056            "release",
1057            "gain",
1058            "freq",
1059            "freq_end",
1060            "noise",
1061            "lowpass",
1062            "repeat",
1063            "repeat_gap",
1064            "tremolo_depth",
1065            "tremolo_freq",
1066        ];
1067        const ALLOWED_WAVES: &[&str] = &["sine", "square", "saw", "triangle", "noise"];
1068
1069        let table = match src.parse::<Table>() {
1070            Ok(table) => table,
1071            Err(err) => {
1072                return (
1073                    FxHashSet::default(),
1074                    vec![format!("Audio FX TOML parse error: {}", err)],
1075                );
1076            }
1077        };
1078
1079        let mut names = FxHashSet::default();
1080        let mut issues = Vec::new();
1081
1082        let Some(sfx) = table.get("sfx").and_then(toml::Value::as_table) else {
1083            return (names, issues);
1084        };
1085
1086        for (name, value) in sfx {
1087            let Some(effect) = value.as_table() else {
1088                issues.push(format!("Audio FX section 'sfx.{}' must be a table.", name));
1089                continue;
1090            };
1091            names.insert(name.clone());
1092
1093            for key in effect.keys() {
1094                if !ALLOWED_PARAMS.contains(&key.as_str()) {
1095                    issues.push(format!(
1096                        "Audio FX 'sfx.{}' uses unknown parameter '{}'.",
1097                        name, key
1098                    ));
1099                }
1100            }
1101
1102            if let Some(wave) = effect.get("wave").and_then(toml::Value::as_str)
1103                && !ALLOWED_WAVES.contains(&wave)
1104            {
1105                issues.push(format!(
1106                    "Audio FX 'sfx.{}' uses unsupported wave '{}'.",
1107                    name, wave
1108                ));
1109            }
1110        }
1111
1112        (names, issues)
1113    }
1114
1115    fn rules_locale_keys(rules: &Table) -> Vec<String> {
1116        let mut keys = Vec::new();
1117        if let Some(messages) = rules
1118            .get("combat")
1119            .and_then(toml::Value::as_table)
1120            .and_then(|combat| combat.get("messages"))
1121            .and_then(toml::Value::as_table)
1122        {
1123            for key in ["incoming_key", "outgoing_key"] {
1124                if let Some(value) = messages
1125                    .get(key)
1126                    .and_then(toml::Value::as_str)
1127                    .filter(|value| !value.trim().is_empty())
1128                {
1129                    keys.push(value.to_string());
1130                }
1131            }
1132        }
1133        keys
1134    }
1135
1136    fn rules_audio_fx_refs(rules: &Table) -> Vec<(String, String)> {
1137        let mut refs = Vec::new();
1138
1139        if let Some(audio) = rules
1140            .get("combat")
1141            .and_then(toml::Value::as_table)
1142            .and_then(|combat| combat.get("audio"))
1143            .and_then(toml::Value::as_table)
1144        {
1145            for key in ["incoming_fx", "outgoing_fx"] {
1146                if let Some(name) = audio
1147                    .get(key)
1148                    .and_then(toml::Value::as_str)
1149                    .filter(|value| !value.trim().is_empty())
1150                {
1151                    refs.push((format!("combat.audio.{}", key), name.to_string()));
1152                }
1153            }
1154        }
1155
1156        if let Some(kinds) = rules
1157            .get("combat")
1158            .and_then(toml::Value::as_table)
1159            .and_then(|combat| combat.get("kinds"))
1160            .and_then(toml::Value::as_table)
1161        {
1162            for (kind, value) in kinds {
1163                let Some(kind_audio) = value
1164                    .as_table()
1165                    .and_then(|kind_table| kind_table.get("audio"))
1166                    .and_then(toml::Value::as_table)
1167                else {
1168                    continue;
1169                };
1170                for key in ["incoming_fx", "outgoing_fx"] {
1171                    if let Some(name) = kind_audio
1172                        .get(key)
1173                        .and_then(toml::Value::as_str)
1174                        .filter(|value| !value.trim().is_empty())
1175                    {
1176                        refs.push((
1177                            format!("combat.kinds.{}.audio.{}", kind, key),
1178                            name.to_string(),
1179                        ));
1180                    }
1181                }
1182            }
1183        }
1184
1185        refs
1186    }
1187
1188    /// Add an undo atom to the current entity's undo stack
1189    fn add_undo(&mut self, atom: DataUndoAtom, ctx: &mut TheContext) {
1190        if let Some(entity_key) = self.current_entity {
1191            let undo = self
1192                .entity_undos
1193                .entry(entity_key)
1194                .or_insert_with(DataUndo::new);
1195            undo.add(atom);
1196            undo.truncate_to_limit(self.max_undo);
1197            self.set_undo_state_to_ui(ctx);
1198        }
1199    }
1200
1201    /// Update the project with the current text state
1202    fn update_project_data(
1203        &mut self,
1204        ui: &mut TheUI,
1205        ctx: &mut TheContext,
1206        project: &mut Project,
1207        server_ctx: &mut ServerContext,
1208    ) {
1209        if let Some(id) = server_ctx.pc.id() {
1210            if let Some(edit) = ui.get_text_area_edit("DockDataEditor") {
1211                let state = edit.get_state();
1212                let text = state.rows.join("\n");
1213
1214                if server_ctx.pc.is_region() {
1215                    if let Some(region) = project.get_region_mut(&id) {
1216                        region.config = text;
1217                        if let Ok(changed) =
1218                            crate::utils::update_region_settings(project, server_ctx)
1219                        {
1220                            if changed {
1221                                ctx.ui.send(TheEvent::Custom(
1222                                    TheId::named("Update Minimap"),
1223                                    TheValue::Empty,
1224                                ));
1225
1226                                RUSTERIX.write().unwrap().set_dirty();
1227
1228                                ctx.ui.send(TheEvent::Custom(
1229                                    TheId::named("Render SceneManager Map"),
1230                                    TheValue::Empty,
1231                                ));
1232                            }
1233                        }
1234                    }
1235                } else if server_ctx.pc.is_character() {
1236                    if let Some(character) = project.characters.get_mut(&id) {
1237                        match server_ctx.pc {
1238                            ProjectContext::CharacterPreviewRigging(_) => {
1239                                character.preview_rigging = text;
1240                                ctx.ui.send(TheEvent::Custom(
1241                                    TheId::named("Update Minimap"),
1242                                    TheValue::Empty,
1243                                ));
1244                            }
1245                            _ => character.data = text,
1246                        }
1247                    }
1248                } else if server_ctx.pc.is_item() {
1249                    if let Some(item) = project.items.get_mut(&id) {
1250                        item.data = text;
1251                    }
1252                } else if let ProjectContext::ScreenWidget(screen_id, widget_id) = server_ctx.pc {
1253                    if let Some(screen) = project.screens.get_mut(&screen_id) {
1254                        for sector in &mut screen.map.sectors {
1255                            if sector.creator_id == widget_id {
1256                                sector.properties.set("data".into(), Value::Str(text));
1257                                break;
1258                            }
1259                        }
1260                    }
1261                }
1262            }
1263        } else if server_ctx.pc.is_project_settings() {
1264            if let Some(edit) = ui.get_text_area_edit("DockDataEditor") {
1265                let state = edit.get_state();
1266                let text = state.rows.join("\n");
1267                _ = RUSTERIX.write().unwrap().scene_handler.settings.read(&text);
1268                project.config = text;
1269            }
1270        } else if server_ctx.pc.is_game_rules() {
1271            if let Some(edit) = ui.get_text_area_edit("DockDataEditor") {
1272                let state = edit.get_state();
1273                let text = state.rows.join("\n");
1274                project.rules = text;
1275            }
1276        } else if server_ctx.pc.is_game_locales() {
1277            if let Some(edit) = ui.get_text_area_edit("DockDataEditor") {
1278                let state = edit.get_state();
1279                let text = state.rows.join("\n");
1280                project.locales = text;
1281            }
1282        } else if server_ctx.pc.is_game_audio_fx() {
1283            if let Some(edit) = ui.get_text_area_edit("DockDataEditor") {
1284                let state = edit.get_state();
1285                let text = state.rows.join("\n");
1286                project.audio_fx = text;
1287            }
1288        }
1289
1290        self.validate_project_documents(project);
1291    }
1292}