Repository: Uni-Gal/Ayaka (language: rust)

Issue:
Ayaka Script 与国际化支持
Ayaka Script 是完全没有国际化支持的。与之相关的资源文本虽然有国际化支持，但是在执行脚本时只是将当前语言的资源字典以及 fallback 传给了解释器。这相当于没有国际化支持。

同时，Ayaka Script 的嵌入方式非常自由，因此给国际化也带来了很大的压力。

我现在计划分离 `Text` 的 parsing 步骤，将 `Action` 分成两部分，一部分 `ActionParams` 是不变的属性与计算好的结果，不随语言变化；一部分 `Action` 在需要显示的时候即时获取，以保证所有的显示内容与当前语言匹配。但是由于 Ayaka Script 没有国际化支持，这个重构面临着某些强耦合。

代码示例1：
``` yaml
- \exec{ format.fmt(#r1, 123,456) }
```

提问：如果 `r1` 在不同的语言下有不同的值， `format.fmt` 在何时执行？

代码示例2：
``` yaml
- \exec{ format.fmt("{}", random.rnd(10)) }
```

提问：如何保证 `random.rnd` 只执行一次？

代码示例3：
``` yaml
- a = \exec{ $a }
- b = \exec{ $b += 1; $b }
```

提问：其它语言的文本怎么写？

我们也许需要一种新的语法来解决脚本语言的问题。


Discussion / hints:

Fix patch:
diff --git a/bins/ayaka-check/src/main.rs b/bins/ayaka-check/src/main.rs
index 32274cc0..5a475312 100644
--- a/bins/ayaka-check/src/main.rs
+++ b/bins/ayaka-check/src/main.rs
@@ -1,8 +1,4 @@
-use ayaka_runtime::{
-    anyhow::{bail, Result},
-    log::LevelFilter,
-    *,
-};
+use ayaka_runtime::{anyhow::Result, log::LevelFilter, *};
 use clap::Parser;
 use std::{
     ffi::OsString,
@@ -14,8 +10,6 @@ use std::{
 pub struct Options {
     input: OsString,
     #[clap(long)]
-    check: bool,
-    #[clap(long)]
     auto: bool,
     #[clap(short, long)]
     locale: Option<Locale>,
@@ -58,43 +52,42 @@ async fn main() -> Result<()> {
         }
     }
     let mut ctx = context.await?;
-    if opts.check && !ctx.check() {
-        bail!("Check failed.");
-    }
     ctx.init_new();
-    ctx.set_settings(Settings {
-        lang: opts.locale.unwrap_or_else(Locale::current),
-    });
-    while let Some(action) = ctx.next_run() {
-        if let Some(name) = &action.character {
-            print!("_{}_", name);
-        }
-        for s in action.line {
-            print!("{}", s.as_str());
-        }
-        if !action.switches.is_empty() {
-            for (i, s) in action.switches.iter().enumerate() {
-                if s.enabled {
-                    print!("\n-{}- {}", i + 1, s.text);
-                } else {
-                    print!("\n-x- {}", s.text);
+    let loc = opts.locale.unwrap_or_else(Locale::current);
+    while let Some(raw_ctx) = ctx.next_run() {
+        let action = ctx.get_action(&loc, &raw_ctx)?;
+        match action {
+            Action::Empty | Action::Custom(_) => {}
+            Action::Text(action) => {
+                if let Some(name) = &action.character {
+                    print!("_{}_", name);
+                }
+                for s in &action.text {
+                    print!("{}", s.as_str());
                 }
+                pause(opts.auto)?;
             }
-            println!();
-            loop {
-                let s = read_line()?;
-                if let Ok(i) = s.trim().parse::<usize>() {
-                    let valid =
-                        i > 0 && i <= action.switches.len() && action.switches[i - 1].enabled;
-                    if valid {
-                        ctx.call(&action.switches[i - 1].action);
-                        break;
+            Action::Switches(switches) => {
+                for (i, s) in switches.iter().enumerate() {
+                    if s.enabled {
+                        print!("\n-{}- {}", i + 1, s.text);
+                    } else {
+                        print!("\n-x- {}", s.text);
+                    }
+                }
+                println!();
+                loop {
+                    let s = read_line()?;
+                    if let Ok(i) = s.trim().parse::<usize>() {
+                        let valid = i > 0 && i <= switches.len() && switches[i - 1].enabled;
+                        if valid {
+                            ctx.switch(i - 1);
+                            break;
+                        }
                     }
+                    println!("Invalid switch, enter again!");
                 }
-                println!("Invalid switch, enter again!");
             }
-        } else {
-            pause(opts.auto)?;
         }
     }
     Ok(())
diff --git a/bins/ayaka-gui/package.json b/bins/ayaka-gui/package.json
index d16d8a38..87808d53 100644
--- a/bins/ayaka-gui/package.json
+++ b/bins/ayaka-gui/package.json
@@ -18,7 +18,7 @@
     "bootstrap-dark-5": "^1.1.3",
     "lodash": "^4.17.21",
     "pixi-live2d-display": "^0.4.0",
-    "pixi.js": "^6.5.3",
+    "pixi.js": "^6.5.4",
     "timers-promises": "^1.0.1",
     "vue": "^3.2.39",
     "vue-i18n": "^9.2.2",
@@ -33,7 +33,7 @@
     "@vue/tsconfig": "^0.1.3",
     "process": "^0.11.10",
     "typescript": "~4.8.3",
-    "vite": "^3.1.2",
+    "vite": "^3.1.3",
     "vue-tsc": "^0.40.13"
   }
 }
\ No newline at end of file
diff --git a/bins/ayaka-gui/src-tauri/src/main.rs b/bins/ayaka-gui/src-tauri/src/main.rs
index 3d20049e..d1aaab84 100644
--- a/bins/ayaka-gui/src-tauri/src/main.rs
+++ b/bins/ayaka-gui/src-tauri/src/main.rs
@@ -5,7 +5,7 @@
 
 use ayaka_runtime::{
     anyhow::{self, anyhow, Result},
-    log::{debug, info, warn},
+    log::{debug, error, info, warn},
     *,
 };
 use flexi_logger::{FileSpec, LogSpecification, Logger};
@@ -42,12 +42,6 @@ fn ayaka_version() -> &'static str {
     ayaka_runtime::version()
 }
 
-#[derive(Debug, Serialize)]
-struct FullSettings {
-    settings: Settings,
-    contexts: Vec<RawContext>,
-}
-
 #[derive(Debug, Clone, Serialize)]
 #[serde(tag = "t", content = "data")]
 enum OpenGameStatus {
@@ -63,11 +57,48 @@ enum OpenGameStatus {
     Loaded,
 }
 
-fn emit_open_status(
-    handle: &AppHandle,
-    status: OpenGameStatus,
-) -> std::result::Result<(), tauri::Error> {
-    handle.emit_all("ayaka://open_status", status)
+impl OpenGameStatus {
+    pub fn emit(self, handle: &AppHandle) -> std::result::Result<(), tauri::Error> {
+        handle.emit_all("ayaka://open_status", self)
+    }
+}
+
+#[derive(Default)]
+struct Storage {
+    ident: String,
+    config: String,
+    records: Mutex<Vec<ActionRecord>>,
+    context: Mutex<Option<Context>>,
+    current: Mutex<Option<RawContext>>,
+    settings: Mutex<Option<Settings>>,
+    global_record: Mutex<Option<GlobalRecord>>,
+}
+
+impl Storage {
+    pub fn new(ident: impl Into<String>, config: impl Into<String>) -> Self {
+        Self {
+            ident: ident.into(),
+            config: config.into(),
+            ..Default::default()
+        }
+    }
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+struct GameInfo {
+    pub title: String,
+    pub author: String,
+    pub props: HashMap<String, String>,
+}
+
+impl GameInfo {
+    pub fn new(game: &Game) -> Self {
+        Self {
+            title: game.config.title.clone(),
+            author: game.config.author.clone(),
+            props: game.config.props.clone(),
+        }
+    }
 }
 
 #[command]
@@ -78,39 +109,39 @@ async fn open_game(handle: AppHandle, storage: State<'_, Storage>) -> CommandRes
     while let Some(status) = context.next().await {
         match status {
             OpenStatus::LoadProfile => {
-                emit_open_status(&handle, OpenGameStatus::LoadProfile(config.clone()))?
+                OpenGameStatus::LoadProfile(config.clone()).emit(&handle)?;
             }
-            OpenStatus::CreateRuntime => emit_open_status(&handle, OpenGameStatus::CreateRuntime)?,
+            OpenStatus::CreateRuntime => OpenGameStatus::CreateRuntime.emit(&handle)?,
             OpenStatus::LoadPlugin(name, i, len) => {
-                emit_open_status(&handle, OpenGameStatus::LoadPlugin(name, i, len))?
+                OpenGameStatus::LoadPlugin(name, i, len).emit(&handle)?
             }
-            OpenStatus::GamePlugin => emit_open_status(&handle, OpenGameStatus::GamePlugin)?,
-            OpenStatus::LoadResource => emit_open_status(&handle, OpenGameStatus::LoadResource)?,
-            OpenStatus::LoadParagraph => emit_open_status(&handle, OpenGameStatus::LoadParagraph)?,
+            OpenStatus::GamePlugin => OpenGameStatus::GamePlugin.emit(&handle)?,
+            OpenStatus::LoadResource => OpenGameStatus::LoadResource.emit(&handle)?,
+            OpenStatus::LoadParagraph => OpenGameStatus::LoadParagraph.emit(&handle)?,
         }
     }
-    let mut ctx = context.await?;
+    let ctx = context.await?;
 
     let window = handle.get_window("main").unwrap();
     window.set_title(&ctx.game.config.title)?;
     let settings = {
-        emit_open_status(&handle, OpenGameStatus::LoadSettings)?;
+        OpenGameStatus::LoadSettings.emit(&handle)?;
         load_settings(&storage.ident).unwrap_or_else(|e| {
             warn!("Load settings failed: {}", e);
             Settings::new()
         })
     };
-    ctx.set_settings(settings);
+    *storage.settings.lock().await = Some(settings);
 
-    emit_open_status(&handle, OpenGameStatus::LoadGlobalRecords)?;
-    ctx.set_global_record(
+    OpenGameStatus::LoadGlobalRecords.emit(&handle)?;
+    let global_record =
         load_global_record(&storage.ident, &ctx.game.config.title).unwrap_or_else(|e| {
             warn!("Load global records failed: {}", e);
             Default::default()
-        }),
-    );
+        });
+    *storage.global_record.lock().await = Some(global_record);
 
-    emit_open_status(&handle, OpenGameStatus::LoadRecords)?;
+    OpenGameStatus::LoadRecords.emit(&handle)?;
     *storage.records.lock().await = load_records(&storage.ident, &ctx.game.config.title)
         .unwrap_or_else(|e| {
             warn!("Load records failed: {}", e);
@@ -118,61 +149,73 @@ async fn open_game(handle: AppHandle, storage: State<'_, Storage>) -> CommandRes
         });
     *storage.context.lock().await = Some(ctx);
 
-    emit_open_status(&handle, OpenGameStatus::Loaded)?;
+    OpenGameStatus::Loaded.emit(&handle)?;
     Ok(())
 }
 
 #[command]
 async fn get_settings(storage: State<'_, Storage>) -> CommandResult<Option<Settings>> {
-    Ok(storage
-        .context
-        .lock()
-        .await
-        .as_ref()
-        .map(|ctx| ctx.settings())
-        .cloned())
+    Ok(storage.settings.lock().await.clone())
 }
 
 #[command]
 async fn set_settings(settings: Settings, storage: State<'_, Storage>) -> CommandResult<()> {
-    if let Some(context) = storage.context.lock().await.as_mut() {
-        context.set_settings(settings);
-    }
+    *storage.settings.lock().await = Some(settings);
     Ok(())
 }
 
 #[command]
-async fn get_records(storage: State<'_, Storage>) -> CommandResult<Vec<ActionRecord>> {
-    Ok(storage.records.lock().await.clone())
+async fn get_records(storage: State<'_, Storage>) -> CommandResult<Vec<ActionText>> {
+    let context = storage.context.lock().await;
+    let context = context.as_ref().unwrap();
+    let settings = storage.settings.lock().await;
+    let settings = settings.as_ref().unwrap();
+    let mut res = vec![];
+    for record in storage.records.lock().await.iter() {
+        let raw_ctx = record.history.last().unwrap();
+        let action = context.get_action(&settings.lang, raw_ctx)?;
+        if let Action::Text(action) = action {
+            res.push(action);
+        } else {
+            unreachable!()
+        }
+    }
+    Ok(res)
 }
 
 #[command]
 async fn save_record_to(index: usize, storage: State<'_, Storage>) -> CommandResult<()> {
     let mut records = storage.records.lock().await;
-    if let Some(record) = storage
+    let record = storage
         .context
         .lock()
         .await
         .as_ref()
-        .map(|ctx| ctx.record.clone())
-    {
-        if index >= records.len() {
-            records.push(record);
-        } else {
-            records[index] = record;
-        }
+        .unwrap()
+        .record
+        .clone();
+    if index >= records.len() {
+        records.push(record);
+    } else {
+        records[index] = record;
     }
     Ok(())
 }
 
 #[command]
 async fn save_all(storage: State<'_, Storage>) -> CommandResult<()> {
-    if let Some(context) = storage.context.lock().await.as_ref() {
-        let game = &context.game.config.title;
-        save_settings(&storage.ident, context.settings())?;
-        save_global_record(&storage.ident, game, context.global_record())?;
-        save_records(&storage.ident, game, &storage.records.lock().await)?;
-    }
+    let context = storage.context.lock().await;
+    let game = &context.as_ref().unwrap().game.config.title;
+    save_settings(
+        &storage.ident,
+        storage.settings.lock().await.as_ref().unwrap(),
+    )?;
+    save_global_record(
+        &storage.ident,
+        game,
+        storage.global_record.lock().await.as_ref().unwrap(),
+    )?;
+    save_records(&storage.ident, game, &storage.records.lock().await)?;
     Ok(())
 }
 
@@ -181,12 +224,18 @@ async fn avaliable_locale(
     storage: State<'_, Storage>,
     locales: HashSet<Locale>,
 ) -> CommandResult<HashSet<Locale>> {
-    if let Some(context) = storage.context.lock().await.as_ref() {
-        let avaliable = context.game.paras.keys().cloned().collect();
-        Ok(locales.intersection(&avaliable).cloned().collect())
-    } else {
-        Ok(locales)
-    }
+    let avaliable = storage
+        .context
+        .lock()
+        .await
+        .as_ref()
+        .unwrap()
+        .game
+        .paras
+        .keys()
+        .cloned()
+        .collect();
+    Ok(locales.intersection(&avaliable).cloned().collect())
 }
 
 #[command]
@@ -200,61 +249,16 @@ async fn choose_locale(
     Ok(current.choose_from(&locales).cloned())
 }
 
-#[derive(Default)]
-struct Storage {
-    ident: String,
-    config: String,
-    records: Mutex<Vec<ActionRecord>>,
-    context: Mutex<Option<Context>>,
-    action: Mutex<Option<Action>>,
-}
-
-impl Storage {
-    pub fn new(ident: impl Into<String>, config: impl Into<String>) -> Self {
-        Self {
-            ident: ident.into(),
-            config: config.into(),
-            ..Default::default()
-        }
-    }
-}
-
-#[derive(Debug, Serialize, Deserialize)]
-struct GameInfo {
-    pub title: String,
-    pub author: String,
-    pub props: HashMap<String, String>,
-}
-
-impl GameInfo {
-    pub fn new(game: &Game) -> Self {
-        Self {
-            title: game.config.title.clone(),
-            author: game.config.author.clone(),
-            props: game.config.props.clone(),
-        }
-    }
-}
-
 #[command]
 async fn info(storage: State<'_, Storage>) -> CommandResult<Option<GameInfo>> {
     let ctx = storage.context.lock().await;
-    if let Some(ctx) = ctx.as_ref() {
-        Ok(Some(GameInfo::new(&ctx.game)))
-    } else {
-        warn!("Game hasn't been loaded.");
-        Ok(None)
-    }
+    Ok(Some(GameInfo::new(&ctx.as_ref().unwrap().game)))
 }
 
 #[command]
 async fn start_new(locale: Locale, storage: State<'_, Storage>) -> CommandResult<()> {
-    if let Some(ctx) = storage.context.lock().await.as_mut() {
-        ctx.init_new();
-        info!("Init new context with locale {}.", locale);
-    } else {
-        warn!("Game hasn't been loaded.")
-    }
+    storage.context.lock().await.as_mut().unwrap().init_new();
+    info!("Init new context with locale {}.", locale);
     Ok(())
 }
 
@@ -264,40 +268,62 @@ async fn start_record(
     index: usize,
     storage: State<'_, Storage>,
 ) -> CommandResult<()> {
-    if let Some(ctx) = storage.context.lock().await.as_mut() {
-        let raw_ctx = storage.records.lock().await[index].clone();
-        let last_line = raw_ctx.history.last().unwrap();
-        *storage.action.lock().await = Some(last_line.clone());
-        ctx.init_context(raw_ctx);
-        info!("Init new context with locale {}.", locale);
-    } else {
-        warn!("Game hasn't been loaded.")
-    }
+    let record = storage.records.lock().await[index].clone();
+    storage
+        .context
+        .lock()
+        .await
+        .as_mut()
+        .unwrap()
+        .init_context(record);
+    info!("Init new context with locale {}.", locale);
     Ok(())
 }
 
 #[command]
 async fn next_run(storage: State<'_, Storage>) -> CommandResult<bool> {
-    let mut context = storage.context.lock().await;
-    let action = context.as_mut().and_then(|context| context.next_run());
-    if let Some(action) = action {
-        debug!("Next action: {:?}", action);
-        *storage.action.lock().await = Some(action);
-        Ok(true)
-    } else {
-        debug!("No action left.");
-        *storage.action.lock().await = None;
-        Ok(false)
+    loop {
+        let mut context = storage.context.lock().await;
+        let context = context.as_mut().unwrap();
+        if let Some(raw_ctx) = context.next_run() {
+            debug!("Next action: {:?}", raw_ctx);
+            let is_empty = {
+                let action = context
+                    .get_action(&context.game.config.base_lang, &raw_ctx)
+                    .unwrap_or_default();
+                if let Action::Empty = action {
+                    true
+                } else if let Action::Custom(vars) = action {
+                    vars.is_empty()
+                } else {
+                    false
+                }
+            };
+            storage
+                .global_record
+                .lock()
+                .await
+                .as_mut()
+                .unwrap()
+                .update(&raw_ctx);
+            *storage.current.lock().await = Some(raw_ctx);
+            if !is_empty {
+                return Ok(true);
+            }
+        } else {
+            *storage.current.lock().await = None;
+            return Ok(false);
+        }
     }
 }
 
 #[command]
 async fn next_back_run(storage: State<'_, Storage>) -> CommandResult<bool> {
     let mut context = storage.context.lock().await;
-    let action = context.as_mut().and_then(|context| context.next_back_run());
-    if let Some(action) = action {
-        debug!("Last action: {:?}", action);
-        *storage.action.lock().await = Some(action);
+    let context = context.as_mut().unwrap();
+    if let Some(raw_ctx) = context.next_back_run() {
+        debug!("Last action: {:?}", raw_ctx);
+        *storage.current.lock().await = Some(raw_ctx.clone());
         Ok(true)
     } else {
         debug!("No action in the history.");
@@ -307,12 +333,12 @@ async fn next_back_run(storage: State<'_, Storage>) -> CommandResult<bool> {
 
 #[command]
 async fn current_visited(storage: State<'_, Storage>) -> CommandResult<bool> {
-    let action = storage.action.lock().await;
-    let visited = if let Some(action) = action.as_ref() {
-        let context = storage.context.lock().await;
-        context
+    let raw_ctx = storage.current.lock().await;
+    let visited = if let Some(raw_ctx) = raw_ctx.as_ref() {
+        let record = storage.global_record.lock().await;
+        record
             .as_ref()
-            .map(|context| context.visited(action))
+            .map(|record| record.visited(raw_ctx))
             .unwrap_or_default()
     } else {
         false
@@ -321,44 +347,81 @@ async fn current_visited(storage: State<'_, Storage>) -> CommandResult<bool> {
 }
 
 #[command]
-async fn current_run(storage: State<'_, Storage>) -> CommandResult<Option<Action>> {
-    Ok(storage.action.lock().await.as_ref().cloned())
+async fn current_run(storage: State<'_, Storage>) -> CommandResult<Option<RawContext>> {
+    let raw_ctx = storage.current.lock().await;
+    Ok(raw_ctx.as_ref().cloned())
+}
+
+fn get_actions(
+    context: &Context,
+    settings: &Settings,
+    raw_ctx: &RawContext,
+) -> (Action, Option<Action>) {
+    let action = context
+        .get_action(&settings.lang, raw_ctx)
+        .unwrap_or_else(|e| {
+            error!("Cannot get action: {}", e);
+            Action::default()
+        });
+    let base_action = settings.sub_lang.as_ref().and_then(|sub_lang| {
+        match context.get_action(sub_lang, raw_ctx) {
+            Ok(action) => Some(action),
+            Err(e) => {
+                error!("Cannot get sub action: {}", e);
+                None
+            }
+        }
+    });
+    (action, base_action)
+}
+
+#[command]
+async fn current_action(
+    storage: State<'_, Storage>,
+) -> CommandResult<Option<(Action, Option<Action>)>> {
+    let context = storage.context.lock().await;
+    let context = context.as_ref().unwrap();
+    let raw_ctx = storage.current.lock().await;
+    let settings = storage.settings.lock().await;
+    let settings = settings.as_ref().unwrap();
+    Ok(raw_ctx
+        .as_ref()
+        .map(|raw_ctx| get_actions(context, settings, raw_ctx)))
 }
 
 #[command]
 async fn current_title(storage: State<'_, Storage>) -> CommandResult<Option<String>> {
+    let settings = storage.settings.lock().await;
+    let settings = settings.as_ref().unwrap();
     Ok(storage
         .context
         .lock()
         .await
         .as_ref()
-        .and_then(|context| context.current_paragraph_title().cloned()))
+        .unwrap()
+        .current_paragraph_title(&settings.lang)
+        .cloned())
 }
 
 #[command]
-async fn switch(i: usize, storage: State<'_, Storage>) -> CommandResult<RawValue> {
+async fn switch(i: usize, storage: State<'_, Storage>) -> CommandResult<()> {
     debug!("Switch {}", i);
-    let mut context = storage.context.lock().await;
-    let context = context
-        .as_mut()
-        .ok_or_else(|| anyhow!("Context not initialized."))?;
-    let action = storage.action.lock().await;
-    let switch = action
-        .as_ref()
-        .and_then(|action| action.switches.get(i))
-        .ok_or_else(|| anyhow!("Index error: {}", i))?;
-    Ok(context.call(&switch.action))
+    storage.context.lock().await.as_mut().unwrap().switch(i);
+    Ok(())
 }
 
 #[command]
-async fn history(storage: State<'_, Storage>) -> CommandResult<Vec<Action>> {
-    let mut hs = storage
-        .context
-        .lock()
-        .await
-        .as_ref()
-        .map(|context| context.record.history.clone())
-        .unwrap_or_default();
+async fn history(storage: State<'_, Storage>) -> CommandResult<Vec<(Action, Option<Action>)>> {
+    let context = storage.context.lock().await;
+    let context = context.as_ref().unwrap();
+    let settings = storage.settings.lock().await;
+    let settings = settings.as_ref().unwrap();
+    let mut hs = context
+        .record
+        .history
+        .iter()
+        .map(|raw_ctx| get_actions(context, settings, raw_ctx))
+        .collect::<Vec<_>>();
     hs.reverse();
     debug!("Get history {:?}", hs);
     Ok(hs)
@@ -373,11 +436,13 @@ fn main() -> Result<()> {
         .setup(|app| {
             let ident = app.config().tauri.bundle.identifier.clone();
             let log_handle = if cfg!(debug_assertions) {
-                Logger::with(LogSpecification::parse("warn,ayaka=debug,ayalog=debug")?)
-                    .log_to_stdout()
-                    .set_palette("b1;3;2;4;6".to_string())
-                    .use_utc()
-                    .start()?
+                Logger::with(LogSpecification::parse(
+                    "debug,wasmer=warn,regalloc=info,cranelift=info",
+                )?)
+                .log_to_stdout()
+                .set_palette("b1;3;2;4;6".to_string())
+                .use_utc()
+                .start()?
             } else {
                 Logger::with(LogSpecification::parse("info,wasmer=warn")?)
                     .log_to_file(
@@ -427,6 +492,7 @@ fn main() -> Result<()> {
             next_run,
             next_back_run,
             current_run,
+            current_action,
             current_title,
             current_visited,
             switch,
diff --git a/bins/ayaka-gui/src/components/ActionCard.vue b/bins/ayaka-gui/src/components/ActionCard.vue
index 5ac4591f..b3000aec 100644
--- a/bins/ayaka-gui/src/components/ActionCard.vue
+++ b/bins/ayaka-gui/src/components/ActionCard.vue
@@ -1,6 +1,6 @@
 <script lang="ts">
 export default {
-    props: { ch: String, line: String }
+    props: { ch: String, line: String, sub_line: String }
 }
 </script>
 
@@ -13,6 +13,9 @@ export default {
             <p class="h4 card-text">
                 <span v-html="line"></span>
             </p>
+            <p class="h4 card-text">
+                <span v-html="sub_line"></span>
+            </p>
         </div>
     </div>
 </template>
diff --git a/bins/ayaka-gui/src/interop/index.ts b/bins/ayaka-gui/src/interop/index.ts
index fa4ddfca..b5ae1916 100644
--- a/bins/ayaka-gui/src/interop/index.ts
+++ b/bins/ayaka-gui/src/interop/index.ts
@@ -28,14 +28,17 @@ export enum OpenGameStatusType {
 
 export interface Settings {
     lang: Locale,
+    sub_lang?: Locale,
 }
 
 export interface RawContext {
     cur_para: string,
     cur_act: number,
-    history: Action[],
-    bg?: string,
-    bgm?: string,
+    locals: {
+        bg?: string,
+        bgm?: string,
+        ch_models?: string,
+    }
 }
 
 export interface GameInfo {
@@ -47,18 +50,28 @@ export interface GameInfo {
 }
 
 export interface Action {
-    line: ActionLine[],
+    type: keyof typeof ActionType,
+    data: undefined | ActionText | Switch[] | CustomVars
+}
+
+export enum ActionType {
+    Empty,
+    Text,
+    Switches,
+    Custom,
+}
+
+export interface ActionText {
+    text: ActionLine[],
     ch_key?: string,
     character?: string,
-    switches: Switch[],
-    props: {
-        bg?: string,
-        bgm?: string,
-        efm?: string,
-        voice?: string,
-        video?: string,
-        ch_models?: string,
-    },
+    vars: {
+        voice?: string
+    }
+}
+
+export interface CustomVars {
+    video?: string,
 }
 
 export interface ActionLine {
@@ -92,7 +105,7 @@ export function set_settings(settings: Settings): Promise<void> {
     return invoke("set_settings", { settings: settings })
 }
 
-export function get_records(): Promise<RawContext[]> {
+export function get_records(): Promise<ActionText[]> {
     return invoke("get_records")
 }
 
@@ -106,6 +119,12 @@ export async function set_locale(loc: Locale): Promise<void> {
     await set_settings(settings)
 }
 
+export async function set_sub_locale(loc?: Locale): Promise<void> {
+    let settings = await get_settings() ?? { lang: "" };
+    settings.sub_lang = loc
+    await set_settings(settings)
+}
+
 export function save_all(): Promise<void> {
     return invoke("save_all")
 }
@@ -139,10 +158,14 @@ export function next_back_run(): Promise<boolean> {
     return invoke("next_back_run")
 }
 
-export function current_run(): Promise<Action | undefined> {
+export function current_run(): Promise<RawContext | undefined> {
     return invoke("current_run")
 }
 
+export function current_action(): Promise<[Action, Action | undefined] | undefined> {
+    return invoke("current_action")
+}
+
 export function current_title(): Promise<string | undefined> {
     return invoke("current_title")
 }
@@ -155,7 +178,7 @@ export function switch_(i: number): Promise<void> {
     return invoke("switch", { i: i })
 }
 
-export function history(): Promise<Action[]> {
+export function history(): Promise<[Action, Action | undefined][]> {
     return invoke("history")
 }
 
diff --git a/bins/ayaka-gui/src/locale/en.json b/bins/ayaka-gui/src/locale/en.json
index 1f39a866..066c9498 100644
--- a/bins/ayaka-gui/src/locale/en.json
+++ b/bins/ayaka-gui/src/locale/en.json
@@ -6,6 +6,9 @@
     "newGame": "New game",
     "loadRecords": "Load records",
     "settings": "Settings",
+    "language": "Language",
+    "subLanguage": "Secondary language",
+    "none": "None",
     "about": "About",
     "goHome": "Go home",
     "goHomeConfirm": "Back to the home page?",
diff --git a/bins/ayaka-gui/src/locale/ja.json b/bins/ayaka-gui/src/locale/ja.json
index 1bb41964..f3c76dcf 100644
--- a/bins/ayaka-gui/src/locale/ja.json
+++ b/bins/ayaka-gui/src/locale/ja.json
@@ -6,6 +6,9 @@
     "newGame": "始めから",
     "loadRecords": "続きから",
     "settings": "設定",
+    "language": "言語",
+    "subLanguage": "第二言語",
+    "none": "無し",
     "about": "説明",
     "goHome": "ホーム",
     "goHomeConfirm": "ホームに戻りますか？",
diff --git a/bins/ayaka-gui/src/locale/schema.json b/bins/ayaka-gui/src/locale/schema.json
index 4cd2f610..d40f1060 100644
--- a/bins/ayaka-gui/src/locale/schema.json
+++ b/bins/ayaka-gui/src/locale/schema.json
@@ -28,6 +28,18 @@
             "description": "The settings button at HomeView, and title at SettingsView",
             "type": "string"
         },
+        "language": {
+            "description": "The language caption in the SettingsView",
+            "type": "string"
+        },
+        "subLanguage": {
+            "description": "The secondary language caption in the SettingsView",
+            "type": "string"
+        },
+        "none": {
+            "description": "The choice none",
+            "type": "string"
+        },
         "about": {
             "description": "The about button at HomeView",
             "type": "string"
diff --git a/bins/ayaka-gui/src/locale/zh_Hans.json b/bins/ayaka-gui/src/locale/zh_Hans.json
index a18171b8..8cb689a8 100644
--- a/bins/ayaka-gui/src/locale/zh_Hans.json
+++ b/bins/ayaka-gui/src/locale/zh_Hans.json
@@ -6,6 +6,9 @@
     "newGame": "新游戏",
     "loadRecords": "加载进度",
     "settings": "设置",
+    "language": "语言",
+    "subLanguage": "第二语言",
+    "none": "无",
     "about": "关于",
     "goHome": "主页",
     "goHomeConfirm": "返回主页？",
diff --git a/bins/ayaka-gui/src/views/GameView.vue b/bins/ayaka-gui/src/views/GameView.vue
index 5e041665..1aa5921a 100644
--- a/bins/ayaka-gui/src/views/GameView.vue
+++ b/bins/ayaka-gui/src/views/GameView.vue
@@ -3,21 +3,13 @@ import { setTimeout } from 'timers-promises'
 import { Mutex, tryAcquire } from 'async-mutex'
 import ActionCard from '../components/ActionCard.vue'
 import IconButton from '../components/IconButton.vue'
-import { conv_src, current_run, current_title, next_run, next_back_run, switch_, merge_lines, Action, ActionLineType, ActionLine, current_visited } from '../interop'
+import { conv_src, current_run, current_action, current_title, next_run, next_back_run, switch_, merge_lines, RawContext, ActionType, ActionText, CustomVars, Switch, ActionLineType, ActionLine, current_visited } from '../interop'
 import { cloneDeep } from 'lodash'
 import Live2D from '../components/Live2D.vue'
 import { Modal } from 'bootstrap'
 </script>
 
 <script lang="ts">
-enum ActionState {
-    Typing,
-    Typed,
-    Switching,
-    Video,
-    End,
-}
-
 enum PlayState {
     Manual,
     Auto,
@@ -30,23 +22,28 @@ function wait_play(e: HTMLAudioElement): Promise<void> {
     })
 }
 
-function live2d_names(props: { ch_models?: string }): string[] {
-    return (props.ch_models ?? "").split(",").filter(s => s.length != 0)
+function live2d_names(locals: { ch_models?: string }): string[] {
+    return (locals.ch_models ?? "").split(",").filter(s => s.length != 0)
 }
 
 export default {
     emits: ["quit"],
     data() {
         return {
+            raw_ctx: {
+                cur_para: "", cur_act: 0, history: [], locals: {}
+            } as RawContext,
             action: {
-                line: [],
-                switches: [],
-                props: {},
-            } as Action,
+                text: [], vars: {}
+            } as ActionText,
+            sub_action_text: [] as ActionLine[],
+            switches: [] as Switch[],
+            vars: {} as CustomVars,
             title: "",
             type_text: "",
             type_text_buffer: [] as ActionLine[],
-            state: ActionState.End,
+            type_sub_text: "",
+            type_sub_text_buffer: [] as ActionLine[],
             play_state: PlayState.Manual,
             mutex: new Mutex(),
         }
@@ -69,80 +66,69 @@ export default {
         },
         // Should be called in mutex
         async fetch_current_run() {
-            const res = await current_run()
+            const ctx = await current_run()
+            const actions = await current_action()
             this.title = await current_title() ?? ""
-            console.info(res)
-            if (res) {
-                const load_new_bgm = (res.props.bgm != this.action.props.bgm);
-                this.action = res
-                if (load_new_bgm) {
-                    (this.$refs.bgm as HTMLAudioElement).load()
-                }
-                if (res.props.efm) {
-                    (this.$refs.efm as HTMLAudioElement).load()
-                }
-                if (res.props.voice) {
-                    (this.$refs.voice as HTMLAudioElement).load()
+            console.info(actions)
+            if (ctx && actions) {
+                this.raw_ctx = ctx
+                let [action, sub_action] = actions
+                switch (ActionType[action.type]) {
+                    case ActionType.Empty:
+                        this.action = { text: [], vars: {} } as ActionText
+                        this.sub_action_text = []
+                        this.switches = []
+                        this.vars = {}
+                        break
+                    case ActionType.Text:
+                        this.action = action.data as ActionText
+                        this.sub_action_text = (sub_action?.data as ActionText | undefined)?.text ?? []
+                        this.switches = []
+                        this.vars = {}
+                        this.start_type_anime(true)
+                        break
+                    case ActionType.Switches:
+                        this.play_state = PlayState.Manual
+                        this.switches = action.data as Switch[]
+                        this.vars = {}
+                        break
+                    case ActionType.Custom:
+                        this.action = { text: [], vars: {} } as ActionText
+                        this.sub_action_text = []
+                        this.switches = []
+                        let data = action.data as CustomVars
+                        this.vars = data
+                        if (data.video) {
+                            this.play_state = PlayState.Manual
+                        }
+                        break
                 }
-            } else {
-                await this.go_home_direct()
             }
         },
         // Should be called in mutex
-        async fetch_next_run(): Promise<boolean> {
+        async fetch_next_run() {
             const has_next = await next_run()
+            if (!has_next) {
+                this.play_state = PlayState.Manual
+                await this.go_home_direct()
+            }
             await this.fetch_current_run()
-            return has_next
         },
-        async fetch_next_back_run(): Promise<boolean> {
-            const has_back = await next_back_run()
+        async fetch_next_back_run() {
+            await next_back_run()
             await this.fetch_current_run()
-            return has_back
         },
-        end_typing(): boolean {
-            this.type_text = merge_lines(this.action.line)
+        end_typing() {
+            this.type_text = merge_lines(this.action.text)
             this.type_text_buffer = []
-            if (this.action.switches.length != 0) {
-                this.state = ActionState.Switching
-                return false
-            } else {
-                return this.end_switching()
-            }
-        },
-        end_switching(): boolean {
-            if (this.action.props.video) {
-                this.state = ActionState.Video;
-                let element = this.$refs.video as HTMLVideoElement
-                element.load()
-                element.play()
-                return false
-            } else {
-                return true
-            }
         },
         async switch_run(i: number) {
             await switch_(i)
-            if (this.end_switching()) {
-                await this.mutex.runExclusive(this.fetch_next_run)
-                this.start_type_anime()
-            }
+            await this.mutex.runExclusive(this.fetch_next_run)
         },
-        // Shouldn't be called in mutex
-        async start_type_anime(timeout: boolean = false) {
-            this.state = ActionState.Typing
-            let values = timeout ? [setTimeout(3000)] : []
-            if (this.action.props.efm) {
-                let efm = this.$refs.efm as HTMLAudioElement
-                values.push(wait_play(efm))
-                efm.play()
-            }
-            if (this.action.props.voice) {
-                let voice = this.$refs.voice as HTMLAudioElement
-                values.push(wait_play(voice))
-                voice.play()
-            }
+        async type_anime_impl() {
             this.type_text = ""
-            this.type_text_buffer = cloneDeep(this.action.line)
+            this.type_text_buffer = cloneDeep(this.action.text)
             while (this.type_text_buffer.length != 0) {
                 if (this.type_text_buffer[0].data.length == 0) {
                     this.type_text_buffer.shift()
@@ -160,48 +146,51 @@ export default {
                         break
                 }
             }
-            await Promise.all(values)
-            this.state = ActionState.Typed
-            if (this.type_text.length == 0) {
-                await this.next()
-            }
         },
-        async next() {
-            if (this.state != ActionState.Switching) {
-                const new_text = await tryAcquire(this.mutex).runExclusive(async () => {
-                    switch (this.state) {
-                        case ActionState.Typing:
-                        case ActionState.Typed:
-                            return this.end_typing()
-                        case ActionState.Video:
-                            let element = this.$refs.video as HTMLVideoElement
-                            element.pause()
-                            this.state = ActionState.End
-                        case ActionState.End:
-                            return true
-                    }
-                    return false
-                }).catch(_ => { })
-                if (new_text) {
-                    await this.mutex.runExclusive(this.fetch_next_run)
-                    this.start_type_anime()
+        async sub_type_anime_impl() {
+            this.type_sub_text = ""
+            this.type_sub_text_buffer = cloneDeep(this.sub_action_text)
+            while (this.type_sub_text_buffer.length != 0) {
+                if (this.type_sub_text_buffer[0].data.length == 0) {
+                    this.type_sub_text_buffer.shift()
+                    continue
+                }
+                switch (ActionLineType[this.type_sub_text_buffer[0].type]) {
+                    case ActionLineType.Chars:
+                        this.type_sub_text += this.type_sub_text_buffer[0].data[0]
+                        this.type_sub_text_buffer[0].data = this.type_sub_text_buffer[0].data.substring(1)
+                        await setTimeout(10)
+                        break
+                    case ActionLineType.Block:
+                        this.type_sub_text += this.type_sub_text_buffer[0].data
+                        this.type_sub_text_buffer[0].data = ""
+                        break
                 }
             }
         },
+        // Shouldn't be called in mutex
+        async start_type_anime(timeout: boolean = false) {
+            let values = timeout ? [setTimeout(3000)] : []
+            if (this.action.vars.voice) {
+                let voice = this.$refs.voice as HTMLAudioElement
+                values.push(wait_play(voice))
+            }
+            values.push(this.type_anime_impl(), this.sub_type_anime_impl())
+            await Promise.all(values)
+        },
+        async next() {
+            await tryAcquire(this.mutex).runExclusive(this.fetch_next_run).catch(_ => { });
+        },
         async on_auto_play_click() {
             if (this.play_state != PlayState.Auto) {
                 this.play_state = PlayState.Auto
                 this.end_typing()
-                while (this.play_state == PlayState.Auto && (this.state != ActionState.Switching && this.state != ActionState.Video)) {
-                    const has_next = await tryAcquire(this.mutex).runExclusive(async () => {
-                        const has_next = await this.fetch_next_run()
+                while (this.play_state == PlayState.Auto) {
+                    await tryAcquire(this.mutex).runExclusive(async () => {
+                        await this.fetch_next_run()
                         await this.start_type_anime(true)
                         this.end_typing()
-                        return has_next
                     }).catch(_ => { })
-                    if (!has_next) {
-                        break
-                    }
                 }
             }
             this.play_state = PlayState.Manual
@@ -210,37 +199,37 @@ export default {
             if (this.play_state != PlayState.FastForward) {
                 this.play_state = PlayState.FastForward
                 this.end_typing()
-                while (this.play_state == PlayState.FastForward && (this.state != ActionState.Switching && this.state != ActionState.Video)) {
+                while (this.play_state == PlayState.FastForward) {
                     await setTimeout(20)
-                    const has_next = await tryAcquire(this.mutex).runExclusive(async () => {
-                        const has_next = await this.fetch_next_run()
+                    await tryAcquire(this.mutex).runExclusive(async () => {
+                        await this.fetch_next_run()
                         this.end_typing()
-                        return has_next
                     }).catch(_ => { })
                     if (!await current_visited()) {
                         break
                     }
-                    if (!has_next) {
-                        break
-                    }
                 }
             }
             this.play_state = PlayState.Manual
         },
         async onkeydown(e: KeyboardEvent) {
-            if (e.key == "Enter" || e.key == " " || e.key == "ArrowDown") {
-                await this.next()
+            switch (e.key) {
+                case "Enter":
+                case " ":
+                case "ArrowDown":
+                    await this.next()
+                    break
+                case "ArrowUp":
+                    await this.next_back()
+                    break
             }
         },
         async onvideoended() {
-            this.state = ActionState.End
             await this.next()
         },
         async next_back() {
-            if (this.state != ActionState.Switching) {
-                await this.mutex.runExclusive(this.fetch_next_back_run)
-                this.start_type_anime()
-            }
+            await this.mutex.runExclusive(this.fetch_next_back_run)
+            this.start_type_anime()
         },
         async on_history_click() {
             await this.$router.push("/history")
@@ -256,13 +245,12 @@ export default {
 </script>
 
 <template>
-    <audio ref="bgm" :src="conv_src(action.props.bgm)" type="audio/mpeg" autoplay hidden loop></audio>
-    <audio ref="efm" :src="conv_src(action.props.efm)" type="audio/mpeg" hidden></audio>
-    <audio ref="voice" :src="conv_src(action.props.voice)" type="audio/mpeg" hidden></audio>
-    <img class="background" :src="conv_src(action.props.bg)">
-    <Live2D :names="live2d_names(action.props)"></Live2D>
+    <audio ref="bgm" :src="conv_src(raw_ctx.locals.bgm)" type="audio/mpeg" autoplay hidden loop></audio>
+    <audio ref="voice" :src="conv_src(action.vars.voice)" type="audio/mpeg" autoplay hidden></audio>
+    <img class="background" :src="conv_src(raw_ctx.locals.bg)">
+    <Live2D :names="live2d_names(raw_ctx.locals)"></Live2D>
     <div class="card-lines">
-        <ActionCard :ch="action.character" :line="type_text"></ActionCard>
+        <ActionCard :ch="action.character" :line="type_text" :sub_line="type_sub_text"></ActionCard>
     </div>
     <div>
         <h4><span class="badge bg-primary">{{ title }}</span></h4>
@@ -270,13 +258,13 @@ export default {
     <div class="logo d-flex align-items-center">
         <span>Powered by Ayaka.</span>
     </div>
-    <div class="content-full bg-body" :hidden="state != ActionState.Video">
-        <video ref="video" class="background" @ended="onvideoended" :src="conv_src(action.props.video)"
-            type="video/mp4"></video>
+    <div class="content-full bg-body" :hidden="!vars.video">
+        <video ref="video" class="background" @ended="onvideoended" :src="conv_src(vars.video)" type="video/mp4"
+            autoplay></video>
     </div>
     <div class="backboard" @click="next"></div>
     <div class="commands">
-        <div class="btn-group" role="group" :hidden="state == ActionState.Video">
+        <div class="btn-group" role="group">
             <IconButton icon="file-arrow-down" @click='on_records_click("save")'></IconButton>
             <IconButton icon="file-arrow-up" @click='on_records_click("load")'></IconButton>
             <IconButton icon="list" @click="on_history_click"></IconButton>
@@ -290,11 +278,11 @@ export default {
             <IconButton icon="house" @click="go_home"></IconButton>
         </div>
     </div>
-    <div class="content-full container-switches" :hidden="state != ActionState.Switching">
+    <div class="content-full container-switches" :hidden="switches.length == 0">
         <div class="switches">
             <div class="switches-center">
                 <div class="d-grid gap-5 col-8 mx-auto">
-                    <button class="btn btn-primary switch" v-for="(s, i) in action.switches" @click="switch_run(i)"
+                    <button class="btn btn-primary switch" v-for="(s, i) in switches" @click="switch_run(i)"
                         :disabled="!s.enabled">
                         {{ s.text }}
                     </button>
diff --git a/bins/ayaka-gui/src/views/HistoryView.vue b/bins/ayaka-gui/src/views/HistoryView.vue
index b13a8f71..6b017a59 100644
--- a/bins/ayaka-gui/src/views/HistoryView.vue
+++ b/bins/ayaka-gui/src/views/HistoryView.vue
@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { merge_lines, history, Action } from '../interop'
+import { merge_lines, history, ActionText } from '../interop'
 import ActionCard from '../components/ActionCard.vue'
 </script>
 
@@ -8,11 +8,12 @@ export default {
     emits: ["quit"],
     data() {
         return {
-            records: [] as Action[]
+            records: [] as ActionText[]
         }
     },
     async mounted() {
-        this.records = await history()
+        let records = await history()
+        this.records = records.filter(action => action[0].type == "Text").map(action => action[0].data as ActionText)
     }
 }
 </script>
@@ -21,7 +22,7 @@ export default {
     <div class="content-full container-history" @click="$router.back">
         <ul class="list-group">
             <li class="list-group-item" v-for="h in records">
-                <ActionCard :ch="h.character" :line="merge_lines(h.line)"></ActionCard>
+                <ActionCard :ch="h.character" :line="merge_lines(h.text)"></ActionCard>
             </li>
         </ul>
     </div>
diff --git a/bins/ayaka-gui/src/views/RecordsView.vue b/bins/ayaka-gui/src/views/RecordsView.vue
index 438bf30f..f98570f1 100644
--- a/bins/ayaka-gui/src/views/RecordsView.vue
+++ b/bins/ayaka-gui/src/views/RecordsView.vue
@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { RawContext, get_records, start_record, save_record_to, merge_lines } from '../interop'
+import { get_records, start_record, save_record_to, merge_lines, ActionText } from '../interop'
 import IconButton from '../components/IconButton.vue';
 </script>
 
@@ -9,7 +9,7 @@ export default {
     data() {
         return {
             op: this.$route.params.op,
-            records: [] as RawContext[],
+            records: [] as ActionText[],
         }
     },
     async created() {
@@ -36,7 +36,7 @@ export default {
         <ul class="list-group list-group-flush">
             <li class="list-group-item list-group-item-action record-item" v-for="(rec, i) in records"
                 @click="on_record_click(i)">
-                <span v-html="merge_lines(rec.history[rec.history.length - 1].line)"></span>
+                <span v-html="merge_lines((rec.text))"></span>
             </li>
             <li class="list-group-item list-group-item-action record-item" @click="on_record_click(records.length)"
                 :hidden='op != "save"'>
diff --git a/bins/ayaka-gui/src/views/SettingsView.vue b/bins/ayaka-gui/src/views/SettingsView.vue
index 3500abc9..0c3bea2e 100644
--- a/bins/ayaka-gui/src/views/SettingsView.vue
+++ b/bins/ayaka-gui/src/views/SettingsView.vue
@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import { Locale } from 'vue-i18n'
-import { set_locale, avaliable_locale } from '../interop'
+import { set_locale, set_sub_locale, avaliable_locale, get_settings } from '../interop'
 import IconButton from '../components/IconButton.vue';
 </script>
 
@@ -14,14 +14,30 @@ export default {
     data() {
         return {
             locales: [] as Locale[],
+            sub_locale: "none"
         }
     },
     async created() {
         this.locales = await avaliable_locale(this.$i18n.availableLocales)
+        let sub_locale = (await get_settings())?.sub_lang
+        if (sub_locale && this.$i18n.locale != sub_locale) {
+            this.sub_locale = sub_locale
+        }
     },
     methods: {
         async on_locale_select(e: Event) {
+            let loc = (e.target as HTMLInputElement).value
+            if (loc == this.sub_locale) {
+                this.sub_locale = "none"
+            }
             await set_locale((e.target as HTMLInputElement).value)
+        },
+        async on_sub_locale_select(e: Event) {
+            let loc = (e.target as HTMLInputElement).value
+            if (loc == this.$i18n.locale) {
+                this.sub_locale = loc = "none"
+            }
+            await set_sub_locale(loc == "none" ? undefined : loc)
         }
     }
 }
@@ -31,11 +47,19 @@ export default {
     <div class="content">
         <div class="d-grid gap-4 col-4 mx-auto">
             <h1>{{ $t("settings") }}</h1>
+            <h2>{{ $t("language") }}</h2>
             <select class="form-select" v-model="$i18n.locale" @change="on_locale_select">
                 <option v-for="locale in locales" :value="locale">
                     {{ locale_native_name(locale) }}
                 </option>
             </select>
+            <h2>{{ $t("subLanguage") }}</h2>
+            <select class="form-select" v-model="sub_locale" @change="on_sub_locale_select">
+                <option value="none">{{ $t("none") }}</option>
+                <option v-for="locale in locales" :value="locale">
+                    {{ locale_native_name(locale) }}
+                </option>
+            </select>
         </div>
     </div>
     <div>
diff --git a/bins/ayaka-latex/src/main.rs b/bins/ayaka-latex/src/main.rs
index c1fcb06b..1e463812 100644
--- a/bins/ayaka-latex/src/main.rs
+++ b/bins/ayaka-latex/src/main.rs
@@ -39,15 +39,14 @@ async fn main() -> Result<()> {
             output.command0("tableofcontents").await?;
 
             ctx.init_new();
-            ctx.set_settings(Settings {
-                lang: opts.locale.unwrap_or_else(Locale::current),
-            });
+            let loc = opts.locale.unwrap_or_else(Locale::current);
 
             let mut current_para = None;
             let mut current_bg = None;
 
-            while let Some(action) = ctx.next_run() {
-                let para_title = ctx.current_paragraph_title();
+            while let Some(raw_ctx) = ctx.next_run() {
+                let action = ctx.get_action(&loc, &raw_ctx)?;
+                let para_title = ctx.current_paragraph_title(&loc);
                 if para_title != current_para.as_ref() {
                     output
                         .command(
@@ -57,9 +56,12 @@ async fn main() -> Result<()> {
                         .await?;
                     current_para = para_title.cloned();
                 }
-                let bg = action.props.get("bg");
-                if current_bg.as_ref() != bg {
-                    current_bg = bg.cloned();
+                let bg = raw_ctx
+                    .locals
+                    .get("bg")
+                    .map(|value| value.get_str().into_owned());
+                if current_bg != bg {
+                    current_bg = bg;
                     if let Some(bg) = &current_bg {
                         output
                             .environment_attr("figure", "!htbp", |output| async move {
@@ -76,28 +78,33 @@ async fn main() -> Result<()> {
                             .await?;
                     }
                 }
-                if let Some(name) = &action.character {
-                    output.command("paragraph", [name]).await?;
-                }
-                for s in action.line {
-                    output.write(s.as_str()).await?;
-                }
-                output.write("\n\n").await?;
-                if !action.switches.is_empty() {
-                    output
-                        .environment("itemize", |output| async move {
-                            for s in action.switches.iter() {
-                                output.command0("item").await?;
-                                if s.enabled {
-                                    output.write(&s.text).await?;
-                                } else {
-                                    output.command("strikeThrough", [&s.text]).await?;
+                match action {
+                    Action::Empty | Action::Custom(_) => {}
+                    Action::Text(action) => {
+                        if let Some(name) = &action.character {
+                            output.command("paragraph", [name]).await?;
+                        }
+                        for s in action.text {
+                            output.write(s.as_str()).await?;
+                        }
+                        output.write("\n\n").await?;
+                    }
+                    Action::Switches(switches) => {
+                        output
+                            .environment("itemize", |output| async move {
+                                for s in switches.iter() {
+                                    output.command0("item").await?;
+                                    if s.enabled {
+                                        output.write(&s.text).await?;
+                                    } else {
+                                        output.command("strikeThrough", [&s.text]).await?;
+                                    }
+                                    output.write("\n").await?;
                                 }
-                                output.write("\n").await?;
-                            }
-                            Ok(output)
-                        })
-                        .await?;
+                                Ok(output)
+                            })
+                            .await?;
+                    }
                 }
             }
             Ok(output)
diff --git a/bins/ayaka-latex/src/writer.rs b/bins/ayaka-latex/src/writer.rs
index 8ae67040..b318e67c 100644
--- a/bins/ayaka-latex/src/writer.rs
+++ b/bins/ayaka-latex/src/writer.rs
@@ -1,4 +1,5 @@
-use std::{future::Future, io::Result};
+use ayaka_runtime::anyhow::Result;
+use std::future::Future;
 use tokio::{fs::File, io::AsyncWriteExt};
 
 pub struct LaTeXWriter {
diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md
index ce5be563..386cacb5 100644
--- a/book/src/SUMMARY.md
+++ b/book/src/SUMMARY.md
@@ -14,6 +14,7 @@
 - [Plugin](./plugin/summary.md)
     - [Script plugin](./plugin/script_plugin.md)
     - [Text plugin](./plugin/text_plugin.md)
+    - [Line plugin](./plugin/line_plugin.md)
     - [Action plugin](./plugin/action_plugin.md)
     - [Game plugin](./plugin/game_plugin.md)
 - [GUI](./gui/summary.md)
diff --git a/book/src/config/character.md b/book/src/config/character.md
index d6614109..1ced93d7 100644
--- a/book/src/config/character.md
+++ b/book/src/config/character.md
@@ -8,10 +8,8 @@ Not only because the character name is too long to type again and again, but als
 
 The key of the character name should be prefixed with `ch_`:
 ``` yaml
-res:
-  en:
-    ch_foo: A. Foo
-    ch_bar: B. Bar
+ch_foo: A. Foo
+ch_bar: B. Bar
 ```
 You can then specify the character with the command:
 ``` yaml
diff --git a/book/src/config/i18n.md b/book/src/config/i18n.md
index db8501ec..25c473bc 100644
--- a/book/src/config/i18n.md
+++ b/book/src/config/i18n.md
@@ -7,26 +7,26 @@ We use CLDR to choose the best fit locale for current system.
 The translation of the texts is always a difficult job. You don't need to copy all commands as is.
 For example, the original text (`ja`)
 ``` ignore
-\ch{rd}団長！車の用意できました！
-\switch{おう！}{$end = false}
-\switch{止まるんじゃねぇぞ！}{$end = true}
-\switch{止まれ！}{}{false}
+- bg: 0
+- \ch{rd}団長！車の用意できました！
+- switches:
+  - おう！||$end = false
+  - 止まるんじゃねぇぞ！||$end = true
+  - 止まれ！|false
 ```
 could be translated as (`zh-Hans`)
 ``` ignore
-团长！车已经准备好了！
-\switch{哦！}
-\switch{不要停下来啊！}
-\switch{停下！}
+-
+- 团长！车已经准备好了！
+- switches:
+  - 哦！
+  - 不要停下来啊！
+  - 停下！
 ```
 
 ## Fallback
 The resources, commands, and even paragraphs could be fell back, if a translated one is not apparent.
 However, some other ones couldn't be fell back.
 
-### The `\res` and `\exec` commands
-The `\res` and `\exec` commands couldn't be fell back.
-If there's no corresponding commands in the translated lines, they won't be referenced or executed. However, the `\ch` and `\switch` commands could be fell back.
-
 ### Fallback with empty text
-However, if a certain translated line is empty, it will fall back to the base language one.
+If a certain translated line is empty, it will fall back to the base language one.
diff --git a/book/src/config/script.md b/book/src/config/script.md
index 3327f940..3e713720 100644
--- a/book/src/config/script.md
+++ b/book/src/config/script.md
@@ -10,15 +10,16 @@ pub enum RawValue {
 }
 ```
 ## Execute scripts
-Execute a piece of script(we call it *program*) with `\exec{}` command:
+Execute a piece of script(we call it *program*) with `exec` command:
 ``` yaml
-- 1 + 1 = \exec{1 + 1}
+- exec: $res = 1 + 1
+- 1 + 1 = \var{res}
 ```
 The output is
 ``` ignore
 1 + 1 = 2
 ```
-The script `1 + 1` is evaluated, and the result is `2`.
+The script `$res = 1 + 1` is evaluated, and the result is `2`.
 It is then converted to string and appended to the text.
 
 ## Example: Fibonacci
@@ -26,12 +27,15 @@ With the config file, we can even calculate some math problems. For example, Fib
 ``` yaml
 - tag: init
   texts:
-    - 1
-    - \exec{$n = 50; $a = 1; $b = 1; $i = 1; $b}
+    - '1'
+    - exec: $n = 50; $a = 1; $b = 1; $i = 1;
+    - \var{b}
   next: loop
 - tag: loop
   texts:
-    - \exec{c = $b; $b += $a; $a = c; $i += 1; $b}
-  next: \exec{if($i < $n, "loop")}
+    - exec: c = $b; $b += $a; $a = c; $i += 1;
+    - \var{b}
+    - exec: $next = if($i < $n, "loop")
+  next: \var{next}
 ```
 
diff --git a/book/src/config/switches.md b/book/src/config/switches.md
index 935f3f58..1cf6ceeb 100644
--- a/book/src/config/switches.md
+++ b/book/src/config/switches.md
@@ -1,16 +1,16 @@
 # Switches
-The switches could be specified with `\switch{}{}{}` command.
-The `\switch` command in one line of text are in the same series.
+The switches could be specified with `switches` command.
+The parameters in one line of text are separated by `|`.
 
 The first parameter is the display text;
-the second parameter is the action script;
 the third parameter is a boolean expression of whether the switch is enabled.
+the second parameter is the action script;
 For the second and the third parameter, see [Script](./script.md).
 
 ``` yaml
-- |
-  Choose a switch:
-  \switch{Switch 1}{$s = 1}
-  \switch{Switch 2}{$s = 2}
-  \switch{Not enabled}{}{false}
+- 'Choose a switch:'
+- switches:
+  - Switch 1||$s = 1
+  - Switch 2||$s = 2
+  - Not enabled|false
 ```
diff --git a/book/src/plugin/action_plugin.md b/book/src/plugin/action_plugin.md
index bca455b7..e52a22c3 100644
--- a/book/src/plugin/action_plugin.md
+++ b/book/src/plugin/action_plugin.md
@@ -17,7 +17,7 @@ fn plugin_type() -> PluginType {
 }
 
 #[export]
-fn process_action(mut ctx: ActionProcessContext) -> Action {
+fn process_action(mut ctx: ActionProcessContext) -> ActionProcessResult {
     let line = ctx
         .action
         .line
@@ -28,9 +28,9 @@ fn process_action(mut ctx: ActionProcessContext) -> Action {
     let parser = Parser::new(&line);
     let mut html_output = String::new();
     html::push_html(&mut html_output, parser);
-    ctx.action.line.clear();
-    ctx.action.line.push_back_chars(html_output);
-    ctx.action
+    ctx.action.clear();
+    ctx.action.push_back_chars(html_output);
+    ActionProcessResult { action: ctx.action }
 }
 ```
 
@@ -39,8 +39,8 @@ Add the plugin to the `modules` list, and all markdown text will be translated t
 You may notice that the HTML tags are treated as `ActionLine::Chars`, which means they will be displayed one by one on GUI frontends. Our existing `markdown` plugin resolves this problem by providing a custom writer.
 
 ## Existing plugins
-| Plugin     | Description                                      |
-| ---------- | ------------------------------------------------ |
-| `live2d`   | Inherit models layout and deal with hiding.      |
-| `markdown` | Process Markdown texts.                          |
-| `media`    | Inherit background image and music from history. |
+| Plugin     | Description                                 |
+| ---------- | ------------------------------------------- |
+| `live2d`   | Inherit models layout and deal with hiding. |
+| `markdown` | Process Markdown texts.                     |
+| `media`    | Voice of each action.                       |
diff --git a/book/src/plugin/line_plugin.md b/book/src/plugin/line_plugin.md
new file mode 100644
index 00000000..e26e8fbc
--- /dev/null
+++ b/book/src/plugin/line_plugin.md
@@ -0,0 +1,39 @@
+# Line plugin
+Line plugins provides custom line types.
+
+## Register commands
+Here we will register a command `\hello` and call it in the config file.
+You also need `plugin_type` to specify that it is a line plugin.
+``` rust,ignore
+use ayaka_bindings::*;
+
+#[export]
+fn plugin_type() -> PluginType {
+    PluginType::builder().line(["hello"]).build()
+}
+
+#[export]
+fn hello(_ctx: LineProcessContext) -> LineProcessResult {
+    let mut res = LineProcessResult::default();
+    res.locals.insert("hello".to_string(), RawValue::Str("Hello".to_string()));
+    res
+}
+```
+
+Call the command in the config file:
+``` yaml
+- hello:
+- \var{hello} world!
+```
+And it outputs:
+``` ignore
+Hello world!
+```
+
+## The process results
+The `LineProcessResult` object contains the global variables and temp variables. The temp variables will only apply to this specific line.
+
+## Existing plugins
+| Plugin  | Description          |
+| ------- | -------------------- |
+| `media` | Multimedia commands. |
diff --git a/book/src/plugin/script_plugin.md b/book/src/plugin/script_plugin.md
index 8866310a..b9009057 100644
--- a/book/src/plugin/script_plugin.md
+++ b/book/src/plugin/script_plugin.md
@@ -6,7 +6,7 @@ All plugins are script plugins.
 A plugin method is referenced with `<module>.<fn>(...)` grammar.
 If you would like to call the `rnd` function in `random` module,
 ``` yaml
-- \exec{random.rnd()}
+- exec: $i = random.rnd()
 ```
 Pass the parameters in the brace `()`.
 
@@ -30,7 +30,8 @@ fn hello(_args: Vec<RawValue>) -> RawValue {
 ```
 And call the function:
 ``` yaml
-- \exec{meet.hello()} from plugin!
+- exec: $hello = meet.hello()
+- \var{hello} from plugin!
 ```
 If it builds successfully, and you set the right path to the plugins, it will output:
 ``` ignore
@@ -42,6 +43,5 @@ There are some existing script (only) plugins:
 
 | Plugin   | Description              |
 | -------- | ------------------------ |
-| `format` | Format strings.          |
 | `ayalog` | Log to runtime.          |
 | `random` | Generate random numbers. |
diff --git a/book/src/plugin/text_plugin.md b/book/src/plugin/text_plugin.md
index 74843719..56618f99 100644
--- a/book/src/plugin/text_plugin.md
+++ b/book/src/plugin/text_plugin.md
@@ -1,6 +1,5 @@
 # Text plugin
 Text plugins deal with custom TeX-like commands.
-They should register the commands in `text_commands` method.
 
 ## Register commands
 Here we will register a command `\hello` and call it in the config file.
@@ -30,16 +29,11 @@ And it outputs:
 Hello world!
 ```
 
-## The process result
+## The process results
 The `TextProcessResult` object is some lines and properties to be added to the current action. `line` will be appended to the current position of the command, and `props` will be set and update.
 
-## Notes about `rt-format`
-`rt-format` is used to deal with script calculations and runtime formatting.
-Therefore, if you want to add braces to the text, you need to double them in the text plugins.
-
 ## Existing plugins
-| Plugin     | Description          |
-| ---------- | -------------------- |
-| `basictex` | Basic TeX commands.  |
-| `live2d`   | Live2D commands.     |
-| `media`    | Multimedia commands. |
+| Plugin     | Description         |
+| ---------- | ------------------- |
+| `basictex` | Basic TeX commands. |
+| `live2d`   | Live2D commands.    |
diff --git a/examples/Fibonacci/paras/en/init.yaml b/examples/Fibonacci/paras/en/init.yaml
index b7a99cd6..0cba6e7d 100644
--- a/examples/Fibonacci/paras/en/init.yaml
+++ b/examples/Fibonacci/paras/en/init.yaml
@@ -1,9 +1,12 @@
 - tag: init
   texts:
-    - 1
-    - \exec{$n = 50; $a = 1; $b = 1; $i = 1; $b}
+    - '1'
+    - exec: $n = 50; $a = 1; $b = 1; $i = 1;
+    - \var{b}
   next: loop
 - tag: loop
   texts:
-    - \exec{c = $b; $b += $a; $a = c; $i += 1; $b}
-  next: \exec{if($i < $n, "loop")}
+    - exec: c = $b; $b += $a; $a = c; $i += 1;
+    - \var{b}
+    - exec: $next = if($i < $n, "loop")
+  next: \var{next}
diff --git a/examples/Fibonacci2/config.yaml b/examples/Fibonacci2/config.yaml
index 816bcf21..dcf9b9fa 100644
--- a/examples/Fibonacci2/config.yaml
+++ b/examples/Fibonacci2/config.yaml
@@ -4,7 +4,6 @@ plugins:
   dir: ../plugins/
   modules:
     - random
-    - format
 base_lang: en
 paras: paras
 start: init
diff --git a/examples/Fibonacci2/paras/en/init.yaml b/examples/Fibonacci2/paras/en/init.yaml
index 03546d76..d7b58c1f 100644
--- a/examples/Fibonacci2/paras/en/init.yaml
+++ b/examples/Fibonacci2/paras/en/init.yaml
@@ -1,4 +1,5 @@
 - tag: init
   texts:
-    - \exec{$n = random.rnd(50); $a = 1; $b = 1; $i = 1; format.fmt("Starting to calculate Fibonacci {}", $n)}
+    - exec: $n = random.rnd(50); $a = 1; $b = 1; $i = 1;
+    - Starting to calculate Fibonacci \var{n}
   next: loop
diff --git a/examples/Fibonacci2/paras/en/loop.yaml b/examples/Fibonacci2/paras/en/loop.yaml
index 5d6d81a5..01956161 100644
--- a/examples/Fibonacci2/paras/en/loop.yaml
+++ b/examples/Fibonacci2/paras/en/loop.yaml
@@ -1,4 +1,6 @@
 - tag: loop
   texts:
-    - \exec{a = $a; b = $b; c = $b; $b += $a; $a = c; $i += 1; format.fmt("{:<11} + {:<11} = {:<11}", a, b, $b)}
-  next: \exec{if($i < $n, "loop")}
+    - exec: $olda = $a; $oldb = $b; c = $b; $b += $a; $a = c; $i += 1;
+    - \var{olda} + \var{oldb} = \var{b}
+    - exec: $next = if($i < $n, "loop");
+  next: \var{next}
diff --git a/examples/Gacha/config.yaml b/examples/Gacha/config.yaml
index 2d3cf8bc..0c3efac2 100644
--- a/examples/Gacha/config.yaml
+++ b/examples/Gacha/config.yaml
@@ -3,7 +3,6 @@ author: Berrysoft
 plugins:
   dir: ../plugins/
   modules:
-    - format
     - ayalog
     - media
     - random
diff --git a/examples/Gacha/paras/zh-Hans/init.yaml b/examples/Gacha/paras/zh-Hans/init.yaml
index 7b286c3a..c15fa39e 100644
--- a/examples/Gacha/paras/zh-Hans/init.yaml
+++ b/examples/Gacha/paras/zh-Hans/init.yaml
@@ -1,27 +1,27 @@
 - tag: init
   texts:
-    - \exec{$total = 0; $t3 = 0; $t4 = 0; $t5 = 0; $c4 = 0; $c5 = 0;}
+    - exec: $total = 0; $t3 = 0; $t4 = 0; $t5 = 0; $c4 = 0; $c5 = 0;
   next: start
+
 - tag: start
   texts:
-    - |
-      \exec{format.fmt("你共抽了{}次，三星{}个，四星{}个，五星{}个", $total, $t3, $t4, $t5)}
-      \switch{祈愿一次}{$n = 1}
-      \switch{祈愿十次}{$n = 10}
-      \switch{退出}{$n = -1}
-  next: |
-    \exec{
-      $total5 = 0;
-      $total4 = 0;
-      $total3 = 0;
-      $temp5 = $c5;
-      $temp4 = $c4;
-      if($n > 0, "calc")
-    }
+    - 你共抽了\var{total}次，三星\var{t3}个，四星\var{t4}个，五星\var{t5}个
+    - switches:
+      - 祈愿一次||$n = 1
+      - 祈愿十次||$n = 10
+      - 退出||$n = -1
+    - exec: |
+        $total5 = 0;
+        $total4 = 0;
+        $total3 = 0;
+        $temp5 = $c5;
+        $temp4 = $c4;
+        $next = if($n > 0, "calc")
+  next: \var{next}
+
 - tag: calc
   texts:
-    - |
-      \exec{
+    - exec: |
         p = random.rnd(1000);
         current5 = if($temp5 < 73,
           p < 6,
@@ -56,48 +56,54 @@
           $temp5 += 1,
         );
         ayalog.info(if(current5, 5, if(current4, 4, 3)), " star");
-      }
-  next: |
-    \exec{
-      $n -= 1;
-      if($n,
-        "calc",
-        if($total3 + $total4 + $total5 == 1,
-          if($total5,
-            "pull2",
-            if($total4,
-              "pull1",
-              "pull0"
+    - exec: |
+        $n -= 1;
+        $next = if($n,
+          "calc",
+          if($total3 + $total4 + $total5 == 1,
+            if($total5,
+              "pull2",
+              if($total4,
+                "pull1",
+                "pull0"
+              )
+            ),
+            if($total5,
+              "pull4",
+              "pull3"
             )
-          ),
-          if($total5,
-            "pull4",
-            "pull3"
           )
         )
-      )
-    }
+  next: \var{next}
+
 - tag: pull0
   texts:
-    - \video{0}\exec{$total += 1;}
+    - video: 0
+    - exec: $total += 1;
   next: end
 - tag: pull1
   texts:
-    - \video{1}\exec{$total += 1;}
+    - video: 1
+    - exec: $total += 1;
   next: end
 - tag: pull2
   texts:
-    - \video{2}\exec{$total += 1;}
+    - video: 2
+    - exec: $total += 1;
   next: end
+
 - tag: pull3
   texts:
-    - \video{3}\exec{$total += 10;}
+    - video: 3
+    - exec: $total += 10;
   next: end
 - tag: pull4
   texts:
-    - \video{4}\exec{$total += 10;}
+    - video: 4
+    - exec: $total += 10;
   next: end
+
 - tag: end
   texts:
-    - \exec{$t5 += $total5; $t4 += $total4; $t3 += $total3; $c4 = $temp4; $c5 = $temp5;}
+    - exec: $t5 += $total5; $t4 += $total4; $t3 += $total3; $c4 = $temp4; $c5 = $temp5;
   next: start
diff --git a/examples/Live2D/paras/ja/show.yaml b/examples/Live2D/paras/ja/show.yaml
index acea9404..96b00593 100644
--- a/examples/Live2D/paras/ja/show.yaml
+++ b/examples/Live2D/paras/ja/show.yaml
@@ -1,8 +1,13 @@
 - tag: show
   texts:
-    - /Haru//おはよう\show
+    - show: Haru
+    - /Haru//おはよう
     - /Haru//元気ですか
-    - /Hiyori//~~~ヾ(＾∇＾)おはよー♪\show{Haru}{Hiyori}
-    - /Hiyori//今日もよろしく\hide{Haru}
-    - /Mao/？？？/お早う\hide
-    - /Mao//元気？\show
+    - show: Hiyori
+    - /Hiyori//~~~ヾ(＾∇＾)おはよー♪
+    - hide: Haru
+    - /Hiyori//今日もよろしく
+    - hide:
+    - /Mao/？？？/お早う
+    - show: Mao
+    - /Mao//元気？
diff --git a/examples/Orga/paras/ja/main.yaml b/examples/Orga/paras/ja/main.yaml
index 8304556b..b134c8fc 100644
--- a/examples/Orga/paras/ja/main.yaml
+++ b/examples/Orga/paras/ja/main.yaml
@@ -1,21 +1,24 @@
 - tag: main
   title: 車の用意し
   texts:
-    - |
-      \ch{rd}団長！車の用意できました！\bg{0}
-      \switch{おう！}{$end = false}
-      \switch{止まるんじゃねぇぞ！}{$end = true}
-      \switch{止まれ！}{}{false}
-  next: \exec{if($end, "end", "walk")}
+    - bg: 0
+    - \ch{rd}団長！車の用意できました！
+    - switches:
+      - おう！||$end = false
+      - 止まるんじゃねぇぞ！||$end = true
+      - 止まれ！|false
+    - exec: $next = if($end, "end", "walk")
+  next: \var{next}
 - tag: walk
   title: 静かだ
   texts:
     - \ch{org}おう！じゃあな。
     - /kdr//はい、お気をつけて。
-    - |
-      /rd//なんか静かですね。街の中にはギャラルホルンもいないし本部とはえらい違いだ。\bg{1}
-      \switch{安全}{}
-      \switch{気を付け}{}{false}
+    - bg: 1
+    - /rd//なんか静かですね。街の中にはギャラルホルンもいないし本部とはえらい違いだ。
+    - switches:
+      - 安全
+      - 気を付け|false
     - /org//ああ。火星の戦力は軒並み向こうに回してんのかもな。
     - /rd//まっそんなのもう関係ないですけどね！
     - /org//上機嫌だな。
@@ -26,14 +29,17 @@
 - tag: gun
   title: 何やってんだよ
   texts:
-    - /td//ぐわっ！\bg{2}
+    - bg: 2
+    - /td//ぐわっ！
     - /rd//団長？何やってんだよ？団長！
     - /org//ぐっ！うおぉ～～！
     - /ass//うおっ！あっ！
-    - /org//はぁはぁはぁ……なんだよ、結構当たんじゃねぇか。ふっ……\bg{3}
+    - bg: 3
+    - /org//はぁはぁはぁ……なんだよ、結構当たんじゃねぇか。ふっ……
     - /rd//だ……団長……あっ……あぁ……
     - /org//なんて声出してやがる……ライド。
-    - /rd//だって……だって……\bgm{0}
+    - bgm: 0
+    - /rd//だって……だって……
     - /org//俺は鉄華団団長オルガ・イツカだぞ。こんくれぇなんてこたぁねぇ。
     - /rd//そんな……俺なんかのために……
     - /org//団員を守んのは俺の仕事だ。
@@ -47,5 +53,7 @@
 - tag: end
   title: 止まるんじゃねぇぞ
   texts:
-    - /org//俺は止まんねぇからよ、お前らが止まんねぇかぎり、その先に俺はいるぞ！\bgm{0}\bg{4}
+    - bg: 4
+    - bgm: 0
+    - /org//俺は止まんねぇからよ、お前らが止まんねぇかぎり、その先に俺はいるぞ！
     - /org//だからよ、止まるんじゃねぇぞ……
diff --git a/examples/Orga/paras/zh-Hans/main.yaml b/examples/Orga/paras/zh-Hans/main.yaml
index 7fd62bf9..49f4d429 100644
--- a/examples/Orga/paras/zh-Hans/main.yaml
+++ b/examples/Orga/paras/zh-Hans/main.yaml
@@ -1,36 +1,42 @@
 - tag: main
   title: 备车
   texts:
-    - |
-      团长！车已经准备好了！
-      \switch{哦！}
-      \switch{不要停下来啊！}
-      \switch{停下！}
+    -
+    - 团长！车已经准备好了！
+    - switches:
+      - 哦！
+      - 不要停下来啊！
+      - 停下！
+    -
 - tag: walk
   title: 安静
   texts:
     - 哦！那么我走了。
     - 好，路上小心。
-    - |
-      感觉真安静啊，街上也没有加拉尔霍恩的人，和总部差别真大啊。
-      \switch{安全}
-      \switch{小心}
+    -
+    - 感觉真安静啊，街上也没有加拉尔霍恩的人，和总部差别真大啊。
+    - switches:
+      - 安全
+      - 小心
     - 啊，可能是他们把火星的战力都调到那里去了。
     - 不过这也和我们没关系了。
     - 心情很愉快啊。
-    - /rd/加把劲骑士/那是当然的了！大家也有救了，{}又那么努力，我也\ruby{要加油了}{是加把劲骑士}！
+    - /rd/加把劲骑士/那是当然的了！大家也有救了，\res{ch_tkk}又那么努力，我也\ruby{要加油了}{是加把劲骑士}！
     - 啊。
     - 没错，我们至今为止所做的一切，并不是全部徒劳的。今后也是，只要我们不停下脚步，道路就会不断延伸。
 - tag: gun
   title: 你在干什么啊
   texts:
+    -
     - 咕哇！
     - 团长，你在干什么啊，团长！
     - 呃呃啊啊啊啊啊啊啊啊啊啊啊啊啊啊啊！！！
     - 呜哦！啊！
+    -
     - 哈哈哈……什么嘛，我的枪法还挺准的嘛。呵……
     - 团……团长……啊……啊……
     - 你的声音为什么要颤抖，莱德。
+    -
     - 因为……因为……
     - 我可是铁华团团长奥尔加·伊兹卡啊，这点小伤无关紧要。
     - 为什么……要为了保护我……
@@ -44,5 +50,7 @@
 - tag: end
   title: 不要停下来啊
   texts:
+    -
+    -
     - 我是不会停下来的，只要你们不停下来，那前面就一定有我！
     - 所以，不要停下来啊……
diff --git a/plugins/Cargo.toml b/plugins/Cargo.toml
index 8e88f8a4..ab4d1236 100644
--- a/plugins/Cargo.toml
+++ b/plugins/Cargo.toml
@@ -1,7 +1,6 @@
 [workspace]
 members = [
     "basictex",
-    "format",
     "ayalog",
     "live2d",
     "markdown",
diff --git a/plugins/basictex/src/lib.rs b/plugins/basictex/src/lib.rs
index 2d69a954..b0862338 100644
--- a/plugins/basictex/src/lib.rs
+++ b/plugins/basictex/src/lib.rs
@@ -12,9 +12,9 @@ fn par(args: Vec<String>, ctx: TextProcessContext) -> TextProcessResult {
     assert!(args.is_empty());
     let mut res = TextProcessResult::default();
     match ctx.frontend {
-        FrontendType::Text => res.line.push_back_chars("\n"),
-        FrontendType::Html => res.line.push_back_block("<br />"),
-        FrontendType::Latex => res.line.push_back_block("\\par "),
+        FrontendType::Text => res.text.push_back_chars("\n"),
+        FrontendType::Html => res.text.push_back_block("<br />"),
+        FrontendType::Latex => res.text.push_back_block("\\par "),
     }
     res
 }
@@ -28,17 +28,17 @@ fn text_font(
     assert_eq!(args.len(), 1);
     let mut res = TextProcessResult::default();
     match ctx.frontend {
-        FrontendType::Text => res.line.push_back_chars(&args[0]),
+        FrontendType::Text => res.text.push_back_chars(&args[0]),
         FrontendType::Html => {
-            res.line
+            res.text
                 .push_back_block(format!("<font face=\"{}\">", fonts));
-            res.line.push_back_chars(&args[0]);
-            res.line.push_back_block("</font>");
+            res.text.push_back_chars(&args[0]);
+            res.text.push_back_block("</font>");
         }
         FrontendType::Latex => {
-            res.line.push_back_block(format!("\\{}{{{{", cmd));
-            res.line.push_back_chars(&args[0]);
-            res.line.push_back_block("}}");
+            res.text.push_back_block(format!("\\{}{{", cmd));
+            res.text.push_back_chars(&args[0]);
+            res.text.push_back_block("}");
         }
     }
     res
@@ -65,19 +65,19 @@ fn ruby(args: Vec<String>, ctx: TextProcessContext) -> TextProcessResult {
     let mut res = TextProcessResult::default();
     match ctx.frontend {
         FrontendType::Text => res
-            .line
+            .text
             .push_back_chars(format!("{}（{}）", args[0], args[1])),
         FrontendType::Html => {
-            res.line.push_back_block("<ruby>");
-            res.line.push_back_chars(&args[0]);
-            res.line.push_back_block("<rp>（</rp><rt>");
-            res.line.push_back_chars(&args[1]);
-            res.line.push_back_block("</rt><rp>）</rp>");
-            res.line.push_back_block("</ruby>");
+            res.text.push_back_block("<ruby>");
+            res.text.push_back_chars(&args[0]);
+            res.text.push_back_block("<rp>（</rp><rt>");
+            res.text.push_back_chars(&args[1]);
+            res.text.push_back_block("</rt><rp>）</rp>");
+            res.text.push_back_block("</ruby>");
         }
         FrontendType::Latex => res
-            .line
-            .push_back_block(format!("\\ruby{{{{{}}}}}{{{{{}}}}}", args[0], args[1])),
+            .text
+            .push_back_block(format!("\\ruby{{{}}}{{{}}}", args[0], args[1])),
     }
     res
 }
diff --git a/plugins/format/Cargo.toml b/plugins/format/Cargo.toml
deleted file mode 100644
index 914c8c04..00000000
--- a/plugins/format/Cargo.toml
+++ /dev/null
@@ -1,13 +0,0 @@
-cargo-features = ["per-package-target"]
-
-[package]
-name = "format"
-version = "0.1.0"
-edition = "2021"
-forced-target = "wasm32-unknown-unknown"
-
-[lib]
-crate-type = ["cdylib"]
-
-[dependencies]
-ayaka-bindings = { path = "../../utils/ayaka-bindings" }
diff --git a/plugins/format/src/lib.rs b/plugins/format/src/lib.rs
deleted file mode 100644
index 0cb88580..00000000
--- a/plugins/format/src/lib.rs
+++ /dev/null
@@ -1,16 +0,0 @@
-use ayaka_bindings::*;
-
-#[export]
-fn plugin_type() -> PluginType {
-    PluginType::default()
-}
-
-#[import("format")]
-extern "C" {
-    fn __format(args: Vec<RawValue>) -> RawValue;
-}
-
-#[export]
-fn fmt(args: Vec<RawValue>) -> RawValue {
-    __format(args)
-}
diff --git a/plugins/live2d/src/lib.rs b/plugins/live2d/src/lib.rs
index b896efee..5dfb6451 100644
--- a/plugins/live2d/src/lib.rs
+++ b/plugins/live2d/src/lib.rs
@@ -6,16 +6,9 @@ use std::{
 
 #[export]
 fn plugin_type() -> PluginType {
-    PluginType::builder()
-        .action()
-        .text(["show", "hide"])
-        .game()
-        .build()
+    PluginType::builder().line(["show", "hide"]).game().build()
 }
 
-const CH_DEFAULT: &str = "__ch_default__";
-const CH_ALL: &str = "__ch_all__";
-
 fn find_model(
     ch: &str,
     root_path: impl AsRef<Path>,
@@ -31,71 +24,52 @@ fn find_model(
 }
 
 #[export]
-fn show(args: Vec<String>, ctx: TextProcessContext) -> TextProcessResult {
-    let models = args
-        .into_iter()
-        .filter(|name| ctx.game_props.contains_key(&format!("ch_{}_model", name)))
+fn show(ctx: LineProcessContext) -> LineProcessResult {
+    let models = ctx.props["show"].get_str();
+    let exist_models = ctx
+        .ctx
+        .locals
+        .get("ch_models")
+        .map(|value| value.get_str())
+        .unwrap_or_default();
+    let models = exist_models
+        .split(',')
+        .chain(
+            models
+                .split(',')
+                .filter(|name| ctx.game_props.contains_key(&format!("ch_{}_model", name))),
+        )
         .collect::<Vec<_>>();
-    let mut res = TextProcessResult::default();
-    res.props.insert(
-        "ch_models".to_string(),
-        if models.is_empty() {
-            CH_DEFAULT.to_string()
-        } else {
-            models.join(",")
-        },
-    );
+    let mut res = LineProcessResult::default();
+    res.locals
+        .insert("ch_models".to_string(), RawValue::Str(models.join(",")));
     res
 }
 
 #[export]
-fn hide(args: Vec<String>, _ctx: TextProcessContext) -> TextProcessResult {
-    let mut res = TextProcessResult::default();
-    res.props.insert(
-        "ch_hide".to_string(),
-        if args.is_empty() {
-            CH_ALL.to_string()
-        } else {
-            args.join(",")
-        },
-    );
-    res
-}
+fn hide(ctx: LineProcessContext) -> LineProcessResult {
+    let hide = ctx.props["hide"].get_str();
 
-#[export]
-fn process_action(mut ctx: ActionProcessContext) -> Action {
-    let hide = ctx.action.props.remove("ch_hide");
-    if hide.as_deref() == Some(CH_ALL) {
-        ctx.action.props.remove("ch_models");
+    let models = ctx
+        .ctx
+        .locals
+        .get("ch_models")
+        .map(|value| value.get_str())
+        .unwrap_or_default();
+    let models = if hide.is_empty() {
+        vec![]
     } else {
-        let hide = hide
-            .as_ref()
-            .map(|hide| hide.split(',').collect::<HashSet<_>>())
-            .unwrap_or_default();
-
-        let models = ctx
-            .action
-            .props
-            .entry("ch_models".to_string())
-            .or_insert_with_key(|key| {
-                ctx.last_action
-                    .and_then(|act| act.props.get(key).cloned())
-                    .unwrap_or_default()
-            });
-
-        if models == CH_DEFAULT {
-            if let Some(ch) = &ctx.action.ch_key {
-                *models = ch.clone()
-            }
-        }
-
-        *models = models
+        let hide = hide.split(',').collect::<HashSet<_>>();
+        models
             .split(',')
-            .filter(|name| !hide.contains(name))
+            .filter(|ch| !hide.contains(ch))
             .collect::<Vec<_>>()
-            .join(",");
-    }
-    ctx.action
+    };
+
+    let mut res = LineProcessResult::default();
+    res.locals
+        .insert("ch_models".to_string(), RawValue::Str(models.join(",")));
+    res
 }
 
 #[export]
diff --git a/plugins/markdown/src/lib.rs b/plugins/markdown/src/lib.rs
index 0451f40f..a319198b 100644
--- a/plugins/markdown/src/lib.rs
+++ b/plugins/markdown/src/lib.rs
@@ -1,6 +1,9 @@
 use ayaka_bindings::*;
 use pulldown_cmark::{Event::*, *};
-use std::{borrow::Cow, collections::HashMap};
+use std::{
+    borrow::Cow,
+    collections::{HashMap, VecDeque},
+};
 
 #[export]
 fn plugin_type() -> PluginType {
@@ -8,22 +11,22 @@ fn plugin_type() -> PluginType {
 }
 
 #[export]
-fn process_action(mut ctx: ActionProcessContext) -> Action {
+fn process_action(mut ctx: ActionProcessContext) -> ActionProcessResult {
     let line = ctx
         .action
-        .line
+        .text
         .into_iter()
         .map(|s| s.into_string())
         .collect::<Vec<_>>()
         .concat();
     let parser = Parser::new(&line);
     let writer = Writer::new(parser);
-    ctx.action.line = match ctx.frontend {
+    ctx.action.text = match ctx.frontend {
         FrontendType::Html => writer.run_html().into_lines(),
         FrontendType::Text => writer.run_text().into_lines(),
         FrontendType::Latex => writer.run_latex().into_lines(),
     };
-    ctx.action
+    ActionProcessResult { action: ctx.action }
 }
 
 // The below code are modified from pulldown_cmark
@@ -41,7 +44,7 @@ enum TableState {
 
 struct Writer<'a, I> {
     iter: I,
-    writer: ActionLines,
+    writer: ActionText,
     table_state: TableState,
     table_alignments: Vec<Alignment>,
     table_cell_index: usize,
@@ -55,7 +58,7 @@ where
     fn new(iter: I) -> Self {
         Self {
             iter,
-            writer: ActionLines::default(),
+            writer: ActionText::default(),
             table_state: TableState::Head,
             table_alignments: vec![],
             table_cell_index: 0,
@@ -365,7 +368,7 @@ where
         self
     }
 
-    pub fn into_lines(self) -> ActionLines {
-        self.writer
+    pub fn into_lines(self) -> VecDeque<ActionSubText> {
+        self.writer.text
     }
 }
diff --git a/plugins/media/src/lib.rs b/plugins/media/src/lib.rs
index fc94653b..cc70ec47 100644
--- a/plugins/media/src/lib.rs
+++ b/plugins/media/src/lib.rs
@@ -6,7 +6,7 @@ use ayaka_bindings::*;
 fn plugin_type() -> PluginType {
     PluginType::builder()
         .action()
-        .text(["bg", "bgm", "efm", "video"])
+        .line(["bg", "bgm", "video"])
         .game()
         .build()
 }
@@ -19,87 +19,79 @@ fn find_exists(name: &str, base_dir: Option<&Path>, exs: &[&str]) -> Option<Path
     })
 }
 
-fn file(args: Vec<String>, base_dir: Option<&Path>, prop: &str, exs: &[&str]) -> TextProcessResult {
-    assert_eq!(args.len(), 1);
+fn file(
+    arg: &str,
+    base_dir: Option<&Path>,
+    prop: &str,
+    exs: &[&str],
+    temp: bool,
+) -> LineProcessResult {
     log::debug!(
-        "File {:?}, {:?}, {}, {:?}",
-        args,
+        "File {}, {:?}, {}, {:?}",
+        arg,
         base_dir.map(|p| p.display()),
         prop,
         exs
     );
-    let mut res = TextProcessResult::default();
-    if let Some(path) = find_exists(&args[0], base_dir, exs) {
-        res.props
-            .insert(prop.to_string(), path.to_string_lossy().into_owned());
+    let mut res = LineProcessResult::default();
+    if let Some(path) = find_exists(arg, base_dir, exs) {
+        if temp { &mut res.vars } else { &mut res.locals }.insert(
+            prop.to_string(),
+            RawValue::Str(path.to_string_lossy().into_owned()),
+        );
     }
     res
 }
 
 fn file_ctx(
-    args: Vec<String>,
-    ctx: TextProcessContext,
+    ctx: LineProcessContext,
     game_prop: &str,
     prop: &str,
     exs: &[&str],
-) -> TextProcessResult {
+    temp: bool,
+) -> LineProcessResult {
     file(
-        args,
+        &ctx.props[prop].get_str(),
         ctx.game_props
             .get(game_prop)
             .map(|game_prop| ctx.root_path.join(game_prop))
             .as_deref(),
         prop,
         exs,
+        temp,
     )
 }
 
 #[export]
-fn bg(args: Vec<String>, ctx: TextProcessContext) -> TextProcessResult {
-    file_ctx(args, ctx, "bgs", "bg", &["png", "jpg", "gif"])
-}
-
-#[export]
-fn bgm(args: Vec<String>, ctx: TextProcessContext) -> TextProcessResult {
-    file_ctx(args, ctx, "bgms", "bgm", &["mp3"])
+fn bg(ctx: LineProcessContext) -> LineProcessResult {
+    file_ctx(ctx, "bgs", "bg", &["png", "jpg", "gif"], false)
 }
 
 #[export]
-fn efm(args: Vec<String>, ctx: TextProcessContext) -> TextProcessResult {
-    file_ctx(args, ctx, "efms", "efm", &["mp3"])
+fn bgm(ctx: LineProcessContext) -> LineProcessResult {
+    file_ctx(ctx, "bgms", "bgm", &["mp3"], false)
 }
 
 #[export]
-fn video(args: Vec<String>, ctx: TextProcessContext) -> TextProcessResult {
-    file_ctx(args, ctx, "videos", "video", &["mp4"])
+fn video(ctx: LineProcessContext) -> LineProcessResult {
+    file_ctx(ctx, "videos", "video", &["mp4"], true)
 }
 
 #[export]
-fn process_action(mut ctx: ActionProcessContext) -> Action {
-    if let Some(last_action) = &ctx.last_action {
-        for prop in ["bg", "bgm"] {
-            if let Some(value) = last_action.props.get(prop) {
-                ctx.action
-                    .props
-                    .entry(prop.to_string())
-                    .or_insert_with(|| value.clone());
-            }
-        }
-    }
-    let voice_id = ctx.action.ctx.cur_act.to_string();
+fn process_action(mut ctx: ActionProcessContext) -> ActionProcessResult {
+    let voice_id = ctx.ctx.cur_act.to_string();
     let res = file(
-        vec![voice_id],
+        &voice_id,
         ctx.game_props
             .get("voices")
-            .map(|p| ctx.root_path.join(p).join(&ctx.action.ctx.cur_para))
+            .map(|p| ctx.root_path.join(p).join(&ctx.ctx.cur_para))
             .as_deref(),
         "voice",
         &["mp3"],
+        true,
     );
-    for (key, value) in res.props.into_iter() {
-        ctx.action.props.insert(key, value);
-    }
-    ctx.action
+    ctx.action.vars.extend(res.vars);
+    ActionProcessResult { action: ctx.action }
 }
 
 #[export]
diff --git a/utils/Cargo.toml b/utils/Cargo.toml
index a2643360..d0b9cb10 100644
--- a/utils/Cargo.toml
+++ b/utils/Cargo.toml
@@ -1,7 +1,6 @@
 [workspace]
 members = [
     "ayaka-bindings-types",
-    "ayaka-script-types",
     "ayaka-script",
     "ayaka-runtime",
 ]
diff --git a/utils/ayaka-bindings-types/Cargo.toml b/utils/ayaka-bindings-types/Cargo.toml
index 141581ae..c0e837b9 100644
--- a/utils/ayaka-bindings-types/Cargo.toml
+++ b/utils/ayaka-bindings-types/Cargo.toml
@@ -5,7 +5,7 @@ authors = ["Berrysoft <Strawberry_Str@hotmail.com>"]
 edition = "2021"
 
 [dependencies]
-ayaka-script-types = { path = "../ayaka-script-types" }
+ayaka-script = { path = "../ayaka-script" }
 fallback = "0.1"
 serde = { version = "1.0", features = ["derive"] }
 log = { version = "0.4", features = ["serde"] }
diff --git a/utils/ayaka-bindings-types/src/lib.rs b/utils/ayaka-bindings-types/src/lib.rs
index 9ed119c7..2026eed6 100644
--- a/utils/ayaka-bindings-types/src/lib.rs
+++ b/utils/ayaka-bindings-types/src/lib.rs
@@ -3,13 +3,12 @@
 #![warn(missing_docs)]
 #![deny(unsafe_code)]
 
-use ayaka_script_types::{Program, RawValue};
-use fallback::{FallbackSpec, IsEmpty2};
+use ayaka_script::RawValue;
+use fallback::FallbackSpec;
 use serde::{Deserialize, Serialize};
 use std::{
     borrow::Cow,
     collections::{HashMap, VecDeque},
-    ops::{Deref, DerefMut},
     path::{Path, PathBuf},
 };
 
@@ -58,6 +57,9 @@ pub struct PluginType {
     /// The text plugin.
     /// The custom text commands are dealt with this type of plugin.
     pub text: Vec<String>,
+    /// The line plugin.
+    /// The custom line types are dealt with this type of plugin.
+    pub line: Vec<String>,
     /// The game plugin.
     /// This plugin processes the game properties after it is loaded.
     pub game: bool,
@@ -90,6 +92,12 @@ impl PluginTypeBuilder {
         self
     }
 
+    /// A line plugins, which provides custom line types.
+    pub fn line(mut self, cmds: impl IntoIterator<Item = impl Into<String>>) -> Self {
+        self.data.line = cmds.into_iter().map(|s| s.into()).collect();
+        self
+    }
+
     /// A game plugin.
     pub fn game(mut self) -> Self {
         self.data.game = true;
@@ -120,7 +128,7 @@ pub enum FrontendType {
 /// while the characters in [`ActionLine::Block`] should be printed together.
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(tag = "type", content = "data")]
-pub enum ActionLine {
+pub enum ActionSubText {
     /// Characters printed one by one.
     /// Usually they are meaningful texts.
     Chars(String),
@@ -129,7 +137,7 @@ pub enum ActionLine {
     Block(String),
 }
 
-impl ActionLine {
+impl ActionSubText {
     /// Creates [`ActionLine::Chars`].
     pub fn chars(s: impl Into<String>) -> Self {
         Self::Chars(s.into())
@@ -155,10 +163,24 @@ impl ActionLine {
     }
 }
 
-/// A collection of [`ActionLine`].
-///
-/// Internally it is a [`VecDeque<ActionLine>`].
-/// The [`ActionLine`] could be pushed and poped at front or back.
+/// A map from variable name to [`RawValue`].
+pub type VarMap = HashMap<String, RawValue>;
+
+/// The serializable context.
+#[derive(Debug, Default, Clone, Deserialize, Serialize)]
+pub struct RawContext {
+    /// Current base paragraph tag.
+    pub cur_base_para: String,
+    /// Current paragraph tag.
+    pub cur_para: String,
+    /// Current text index.
+    pub cur_act: usize,
+    /// Current local variables.
+    pub locals: VarMap,
+}
+
+/// The `text` is a [`VecDeque<ActionSubText>`].
+/// The [`ActionSubText`] could be pushed and poped at front or back.
 ///
 /// Generally, you should avoid using `push_back` directly.
 /// To reduce allocations in serialization, you should use
@@ -166,49 +188,33 @@ impl ActionLine {
 ///
 /// ```
 /// # use ayaka_bindings_types::*;
-/// let mut lines = ActionLines::default();
-/// lines.push_back_chars("Hello ");
-/// assert_eq!(lines[0], ActionLine::chars("Hello "));
-/// lines.push_back_chars("world!");
-/// assert_eq!(lines[0], ActionLine::chars("Hello world!"));
+/// let mut text = ActionText::default();
+/// text.push_back_chars("Hello ");
+/// assert_eq!(text.text[0], ActionSubText::chars("Hello "));
+/// text.push_back_chars("world!");
+/// assert_eq!(text.text[0], ActionSubText::chars("Hello world!"));
 /// ```
-#[derive(Debug, Default, Clone, Serialize, Deserialize)]
-#[serde(transparent)]
-pub struct ActionLines(VecDeque<ActionLine>);
-
-impl Deref for ActionLines {
-    type Target = VecDeque<ActionLine>;
-
-    fn deref(&self) -> &Self::Target {
-        &self.0
-    }
-}
-
-impl DerefMut for ActionLines {
-    fn deref_mut(&mut self) -> &mut Self::Target {
-        &mut self.0
-    }
-}
-
-impl IntoIterator for ActionLines {
-    type Item = ActionLine;
-
-    type IntoIter = <VecDeque<ActionLine> as IntoIterator>::IntoIter;
-
-    fn into_iter(self) -> Self::IntoIter {
-        self.0.into_iter()
-    }
+#[derive(Debug, Default, Clone, Serialize, Deserialize, FallbackSpec)]
+pub struct ActionText {
+    /// The full texts.
+    pub text: VecDeque<ActionSubText>,
+    /// The key of current character.
+    pub ch_key: Option<String>,
+    /// The current character.
+    pub character: Option<String>,
+    /// The temp variables.
+    pub vars: VarMap,
 }
 
-impl ActionLines {
+impl ActionText {
     /// Push the string as [`ActionLine::Chars`] to the back.
     /// If the back element is also [`ActionLine::Chars`], the string is appended.
     pub fn push_back_chars<'a>(&mut self, s: impl Into<Cow<'a, str>>) {
         let s = s.into();
-        if let Some(ActionLine::Chars(text)) = self.back_mut() {
+        if let Some(ActionSubText::Chars(text)) = self.text.back_mut() {
             text.push_str(&s);
         } else {
-            self.push_back(ActionLine::chars(s));
+            self.text.push_back(ActionSubText::chars(s));
         }
     }
 
@@ -216,54 +222,28 @@ impl ActionLines {
     /// If the back element is also [`ActionLine::Block`], the string is appended.
     pub fn push_back_block<'a>(&mut self, s: impl Into<Cow<'a, str>>) {
         let s = s.into();
-        if let Some(ActionLine::Block(text)) = self.back_mut() {
+        if let Some(ActionSubText::Block(text)) = self.text.back_mut() {
             text.push_str(&s);
         } else {
-            self.push_back(ActionLine::block(s));
+            self.text.push_back(ActionSubText::block(s));
         }
     }
 }
 
-impl IsEmpty2 for ActionLines {
-    fn is_empty2(&self) -> bool {
-        self.0.is_empty()
-    }
-}
-
-/// A map from variable name to [`RawValue`].
-pub type VarMap = HashMap<String, RawValue>;
-
-/// The serializable context.
-#[derive(Debug, Default, Clone, Deserialize, Serialize)]
-pub struct RawContext {
-    /// Current base paragraph tag.
-    pub cur_base_para: String,
-    /// Current paragraph tag.
-    pub cur_para: String,
-    /// Current text index.
-    pub cur_act: usize,
-    /// Current local variables.
-    pub locals: VarMap,
-}
-
 /// The full action information in one line of config.
-/// It provides the full texts and other properties exacted from [`ayaka_script_types::Text`].
-#[derive(Debug, Default, Clone, Serialize, Deserialize, FallbackSpec)]
-pub struct Action {
-    /// The context snapshot.
-    pub ctx: RawContext,
-    /// The full texts.
-    pub line: ActionLines,
-    /// The format params of texts.
-    pub line_params: Vec<RawValue>,
-    /// The key of current character.
-    pub ch_key: Option<String>,
-    /// The current character.
-    pub character: Option<String>,
-    /// The switches.
-    pub switches: Vec<Switch>,
-    /// The other custom properties.
-    pub props: HashMap<String, String>,
+/// It provides the full texts and other properties exacted from [`ayaka_script::Text`].
+#[derive(Debug, Default, Clone, Serialize, Deserialize)]
+#[serde(tag = "type", content = "data")]
+pub enum Action {
+    /// An empty action usually means an `exec` or custom action.
+    #[default]
+    Empty,
+    /// A text action, display some texts.
+    Text(ActionText),
+    /// A switch action, display switches and let player to choose.
+    Switches(Vec<Switch>),
+    /// A custom action.
+    Custom(VarMap),
 }
 
 /// One switch in the switches of an [`Action`].
@@ -271,8 +251,6 @@ pub struct Action {
 pub struct Switch {
     /// The switch text.
     pub text: String,
-    /// The action of this switch after chosen.
-    pub action: Program,
     /// Whether the switch is enabled.
     pub enabled: bool,
 }
@@ -284,9 +262,9 @@ pub struct Switch {
 /// use ayaka_bindings::*;
 ///
 /// #[export]
-/// fn process_action(mut ctx: ActionProcessContext) -> Action {
+/// fn process_action(mut ctx: ActionProcessContext) -> ActionProcessResult {
 ///     // Process the action...
-///     ctx.action
+///     ActionProcessResult { action: ctx.action }
 /// }
 /// ```
 #[derive(Debug, Serialize, Deserialize)]
@@ -297,11 +275,10 @@ pub struct ActionProcessContext {
     pub game_props: HashMap<String, String>,
     /// The frontend type.
     pub frontend: FrontendType,
-    /// The previous action in the history.
-    /// It is used if some properties need to inherit.
-    pub last_action: Option<Action>,
+    /// The current context.
+    pub ctx: RawContext,
     /// The current action.
-    pub action: Action,
+    pub action: ActionText,
 }
 
 #[derive(Debug, Serialize)]
@@ -310,19 +287,26 @@ pub struct ActionProcessContextRef<'a> {
     pub root_path: &'a Path,
     pub game_props: &'a HashMap<String, String>,
     pub frontend: FrontendType,
-    pub last_action: Option<&'a Action>,
-    pub action: &'a Action,
+    pub ctx: &'a RawContext,
+    pub action: &'a ActionText,
+}
+
+/// The result of action plugins.
+/// See examples at [`ActionProcessContext`].
+#[derive(Debug, Default, Serialize, Deserialize)]
+pub struct ActionProcessResult {
+    /// The processed action text.
+    pub action: ActionText,
 }
 
 /// The argument to text plugin.
 ///
-/// Every text plugin should implement `text_commands` and the specified function:
 /// ```ignore
 /// use ayaka_bindings::*;
 ///
 /// #[export]
-/// fn text_commands() -> &'static [&'static str] {
-///     &["hello"]
+/// fn plugin_type() -> PluginType {
+///     PluginType::builder().text(&["hello"]).build()
 /// }
 ///
 /// #[export]
@@ -355,9 +339,7 @@ pub struct TextProcessContextRef<'a> {
 #[derive(Debug, Default, Serialize, Deserialize)]
 pub struct TextProcessResult {
     /// The lines to append.
-    pub line: ActionLines,
-    /// The custom properties to update.
-    pub props: HashMap<String, String>,
+    pub text: ActionText,
 }
 
 /// The argument to game plugin.
@@ -400,3 +382,54 @@ pub struct GameProcessResult {
     /// The updated properties.
     pub props: HashMap<String, String>,
 }
+
+/// The argument to line plugin.
+///
+/// ```ignore
+/// use ayaka_bindings::*;
+///
+/// #[export]
+/// fn plugin_type() -> PluginType {
+///     PluginType::builder().line(&["hello"]).build()
+/// }
+///
+/// #[export]
+/// fn hello(_ctx: LineProcessContext) -> LineProcessResult {
+///     let mut res = LineProcessResult::default();
+///     res.locals.insert("hello".to_string(), RawValue::Str("world".to_string()));
+///     res
+/// }
+/// ```
+#[derive(Debug, Serialize, Deserialize)]
+pub struct LineProcessContext {
+    /// The root path of the game profile.
+    pub root_path: PathBuf,
+    /// The global properties of the game profile.
+    pub game_props: HashMap<String, String>,
+    /// The frontend type.
+    pub frontend: FrontendType,
+    /// The current context.
+    pub ctx: RawContext,
+    /// The full properties of the custom command.
+    pub props: HashMap<String, RawValue>,
+}
+
+#[derive(Debug, Serialize)]
+#[doc(hidden)]
+pub struct LineProcessContextRef<'a> {
+    pub root_path: &'a Path,
+    pub game_props: &'a HashMap<String, String>,
+    pub frontend: FrontendType,
+    pub ctx: &'a RawContext,
+    pub props: &'a HashMap<String, RawValue>,
+}
+
+/// The result of commands in line plugins.
+/// See examples at [`LineProcessContext`].
+#[derive(Debug, Default, Serialize, Deserialize)]
+pub struct LineProcessResult {
+    /// The updated variables.
+    pub locals: VarMap,
+    /// The temp variables.
+    pub vars: VarMap,
+}
diff --git a/utils/ayaka-bindings/Cargo.toml b/utils/ayaka-bindings/Cargo.toml
index d788954d..76904435 100644
--- a/utils/ayaka-bindings/Cargo.toml
+++ b/utils/ayaka-bindings/Cargo.toml
@@ -5,7 +5,7 @@ authors = ["Berrysoft <Strawberry_Str@hotmail.com>"]
 edition = "2021"
 
 [dependencies]
-ayaka-script-types = { path = "../ayaka-script-types" }
+ayaka-script = { path = "../ayaka-script" }
 ayaka-bindings-types = { path = "../ayaka-bindings-types" }
 ayaka-bindings-impl = { path = "../ayaka-bindings-impl" }
 serde = "1.0"
diff --git a/utils/ayaka-bindings/src/lib.rs b/utils/ayaka-bindings/src/lib.rs
index ccb45a1a..3f167bac 100644
--- a/utils/ayaka-bindings/src/lib.rs
+++ b/utils/ayaka-bindings/src/lib.rs
@@ -4,7 +4,7 @@
 #[doc(no_inline)]
 pub use ayaka_bindings_types::*;
 #[doc(no_inline)]
-pub use ayaka_script_types::{RawValue, ValueType};
+pub use ayaka_script::{RawValue, ValueType};
 #[doc(no_inline)]
 pub use log;
 
diff --git a/utils/ayaka-runtime/Cargo.toml b/utils/ayaka-runtime/Cargo.toml
index be9710d5..6cfb24c0 100644
--- a/utils/ayaka-runtime/Cargo.toml
+++ b/utils/ayaka-runtime/Cargo.toml
@@ -5,7 +5,6 @@ edition = "2021"
 
 [dependencies]
 ayaka-bindings-types = { path = "../ayaka-bindings-types" }
-ayaka-script-types = { path = "../ayaka-script-types", features = ["rt-format"] }
 ayaka-script = { path = "../ayaka-script" }
 fallback = "0.1"
 language-matcher = "0.1"
@@ -18,8 +17,6 @@ rmp-serde = "1.1"
 anyhow = "1.0"
 wasmer = "2.3"
 wasmer-wasi = "2.3"
-unicode-width = "0.1"
-rt-format = "0.3"
 tokio = { version = "1", features = ["macros", "sync"] }
 stream-future = "0.3"
 futures-util = "0.3"
diff --git a/utils/ayaka-runtime/src/config.rs b/utils/ayaka-runtime/src/config.rs
index 629fc6a4..2f2fff0f 100644
--- a/utils/ayaka-runtime/src/config.rs
+++ b/utils/ayaka-runtime/src/config.rs
@@ -1,9 +1,6 @@
-#[doc(no_inline)]
-pub use ayaka_bindings_types::{Action, Switch};
-#[doc(no_inline)]
-pub use fallback::Fallback;
-
 use crate::*;
+use ayaka_bindings_types::VarMap;
+use fallback::Fallback;
 use serde::Deserialize;
 use std::{collections::HashMap, path::PathBuf};
 
@@ -17,11 +14,11 @@ pub struct Paragraph {
     /// It can be [`None`], but better with a human-readable one.
     pub title: Option<String>,
     /// The texts.
-    /// They will be parsed into [`ayaka_script_types::Text`] later.
-    pub texts: Vec<String>,
+    /// They will be parsed into [`ayaka_script::Text`] later.
+    pub texts: Vec<Line>,
     /// The next paragraph.
     /// If [`None`], the game meets the end.
-    pub next: Option<String>,
+    pub next: Option<Text>,
 }
 
 /// The Ayaka config.
@@ -79,7 +76,8 @@ impl Game {
             .unwrap_or(&self.config.base_lang)
     }
 
-    fn find_para(&self, loc: &Locale, base_tag: &str, tag: &str) -> Option<&Paragraph> {
+    /// Find a paragraph by tag, with specified locale.
+    pub fn find_para(&self, loc: &Locale, base_tag: &str, tag: &str) -> Option<&Paragraph> {
         if let Some(paras) = self.paras.get(loc) {
             if let Some(paras) = paras.get(base_tag) {
                 for p in paras.iter() {
diff --git a/utils/ayaka-runtime/src/context.rs b/utils/ayaka-runtime/src/context.rs
index 2128bf64..bb292028 100644
--- a/utils/ayaka-runtime/src/context.rs
+++ b/utils/ayaka-runtime/src/context.rs
@@ -1,16 +1,11 @@
-#[doc(no_inline)]
-pub use ayaka_bindings_types::{FrontendType, RawContext};
-
 use crate::{
     plugin::{LoadStatus, Runtime},
     *,
 };
 use anyhow::{anyhow, bail, Result};
-use ayaka_bindings_types::{
-    ActionLine, ActionLines, ActionProcessContextRef, GameProcessContextRef, TextProcessContextRef,
-};
-use ayaka_script::{Loc, ParseError, TextParser};
-use ayaka_script_types::{Command, Line, Program, Text};
+use ayaka_bindings_types::*;
+use ayaka_script::{Command, Line, Text};
+use fallback::Fallback;
 use log::error;
 use script::*;
 use std::{
@@ -18,7 +13,6 @@ use std::{
     path::{Path, PathBuf},
 };
 use stream_future::stream;
-use unicode_width::UnicodeWidthStr;
 
 /// The game running context.
 pub struct Context {
@@ -27,12 +21,12 @@ pub struct Context {
     frontend: FrontendType,
     root_path: PathBuf,
     runtime: Runtime,
-    settings: Settings,
-    global_record: GlobalRecord,
     /// The inner raw context.
     pub ctx: RawContext,
     /// The inner record.
     pub record: ActionRecord,
+    switches: Vec<(bool, Program)>,
+    vars: VarMap,
 }
 
 /// The open status when creating [`Context`].
@@ -143,10 +137,10 @@ impl Context {
             frontend,
             root_path,
             runtime,
-            settings: Settings::new(),
-            global_record: GlobalRecord::default(),
             ctx: RawContext::default(),
             record: ActionRecord::default(),
+            switches: vec![],
+            vars: VarMap::default(),
         })
     }
 
@@ -168,64 +162,28 @@ impl Context {
     }
 
     fn table(&mut self) -> VarTable {
-        VarTable::new(
-            &self.runtime,
-            self.game.find_res_fallback(&self.settings.lang),
-            &mut self.ctx.locals,
-        )
-    }
-
-    fn current_paragraph(&self) -> Fallback<&Paragraph> {
-        self.game.find_para_fallback(
-            &self.settings.lang,
-            &self.ctx.cur_base_para,
-            &self.ctx.cur_para,
-        )
+        VarTable::new(&self.runtime, &mut self.ctx.locals)
     }
 
-    fn current_text(&self) -> Fallback<&String> {
-        self.current_paragraph()
-            .map(|p| {
-                p.texts.get(self.ctx.cur_act).and_then(|s| {
-                    if s.is_empty() || s == "~" {
-                        None
-                    } else {
-                        Some(s)
-                    }
-                })
-            })
-            .flatten()
+    fn current_paragraph(&self, loc: &Locale) -> Option<&Paragraph> {
+        self.game
+            .find_para(loc, &self.ctx.cur_base_para, &self.ctx.cur_para)
     }
 
-    /// Set all settings.
-    pub fn set_settings(&mut self, s: Settings) {
-        self.settings = s;
+    fn current_paragraph_fallback(&self, loc: &Locale) -> Fallback<&Paragraph> {
+        self.game
+            .find_para_fallback(loc, &self.ctx.cur_base_para, &self.ctx.cur_para)
     }
 
-    /// Get all settings.
-    pub fn settings(&self) -> &Settings {
-        &self.settings
+    fn current_text(&self, loc: &Locale) -> Option<&Line> {
+        self.current_paragraph(loc)
+            .and_then(|p| p.texts.get(self.ctx.cur_act))
     }
 
-    /// Set global record.
-    pub fn set_global_record(&mut self, r: GlobalRecord) {
-        self.global_record = r;
-    }
-
-    /// Get global record.
-    pub fn global_record(&self) -> &GlobalRecord {
-        &self.global_record
-    }
-
-    /// Determine if an [`Action`] has been visited,
-    /// by the paragraph tag and action index.
-    pub fn visited(&self, action: &Action) -> bool {
-        if let Some(max_act) = self.global_record.record.get(&action.ctx.cur_para) {
-            log::debug!("Test act: {}, max act: {}", action.ctx.cur_act, max_act);
-            *max_act >= action.ctx.cur_act
-        } else {
-            false
-        }
+    fn find_res(&self, loc: &Locale, key: &str) -> Option<&RawValue> {
+        self.game
+            .find_res_fallback(loc)
+            .and_then(|map| map.get(key))
     }
 
     /// Call the part of script with this context.
@@ -233,248 +191,226 @@ impl Context {
         self.table().call(expr)
     }
 
-    fn rich_error(&self, text: &str, e: &ParseError) -> String {
-        use std::iter::repeat;
-        const FREE_LEN: usize = 20;
-
-        let loc = e.loc();
-        let loc = Loc(
-            text.floor_char_boundary(loc.0),
-            text.ceil_char_boundary(loc.1),
-        );
-        let pre = text.floor_char_boundary(loc.0 - loc.0.min(FREE_LEN));
-        let post = text.ceil_char_boundary(loc.1 + (text.len() - loc.1).min(FREE_LEN));
-
-        let para_name = self
-            .current_paragraph()
-            .and_then(|p| p.title.as_deref())
-            .unwrap_or_default()
-            .escape_default();
-        let act_num = self.ctx.cur_act + 1;
-        let show_code = &text[pre..post];
-        let pre_code = &text[pre..loc.0];
-        let error_code = &text[loc.0..loc.1];
-        format!(
-            "Parse error on paragraph \"{para_name}\", act {act_num}:\n    {show_code}\n    {}\n{e}\n",
-            repeat(' ')
-                .take(UnicodeWidthStr::width_cjk(pre_code))
-                .chain(repeat('^').take(UnicodeWidthStr::width_cjk(error_code)))
-                .collect::<String>(),
-        )
+    /// Choose a switch item by index, start by 0.
+    pub fn switch(&mut self, i: usize) -> RawValue {
+        assert!((0..self.switches.len()).contains(&i));
+        assert!(self.switches[i].0);
+        self.call(&self.switches[i].1.clone())
     }
 
-    fn exact_text(&mut self, t: Text) -> Result<Action> {
-        let mut action_line = ActionLines::default();
-        let mut action_line_params = vec![];
-        let mut chkey = None;
-        let mut chname = None;
-        let mut switches = vec![];
-        let mut props = HashMap::new();
-        for line in t.0.into_iter() {
-            match line {
-                Line::Str(s) => action_line.push_back_chars(s),
-                Line::Cmd(cmd) => match cmd {
-                    Command::Character(key, alter) => {
-                        // TODO: reduce allocation
-                        chkey = Some(key.clone());
-                        chname = if alter.is_empty() {
-                            let res_key = format!("ch_{}", key);
-                            self.game
-                                .find_res_fallback(&self.settings.lang)
-                                .and_then(|map| map.get(&res_key))
-                                .map(|v| v.get_str().into_owned())
+    fn parse_text(&self, loc: &Locale, text: &Text) -> Result<ActionText> {
+        let mut action = ActionText::default();
+        for subtext in &text.0 {
+            match subtext {
+                SubText::Str(s) => action.push_back_chars(s),
+                SubText::Cmd(cmd) => match cmd {
+                    Command::Character(key, alias) => {
+                        action.ch_key = Some(key.clone());
+                        action.character = if alias.is_empty() {
+                            self.find_res(loc, &format!("ch_{}", key))
+                                .map(|value| value.get_str().into_owned())
                         } else {
-                            Some(alter)
+                            Some(alias.clone())
                         }
                     }
-                    Command::Exec(p) => {
-                        let param = self.call(&p);
-                        action_line.push_back_chars(format!("{{{}}}", action_line_params.len()));
-                        action_line_params.push(param);
+                    Command::Res(n) => {
+                        if let Some(value) = self.find_res(loc, n) {
+                            action.push_back_block(value.get_str())
+                        }
                     }
-                    Command::Switch {
-                        text,
-                        action,
-                        enabled,
-                    } => {
-                        // unwrap: when enabled is None, it means true.
-                        let enabled = enabled.map(|p| self.call(&p).get_bool()).unwrap_or(true);
-                        switches.push(Switch {
-                            text,
-                            action,
-                            enabled,
-                        });
+                    Command::Ctx(n) => {
+                        if let Some(value) = self.ctx.locals.get(n) {
+                            action.push_back_block(value.get_str())
+                        }
                     }
-                    Command::Other(name, args) => {
-                        if let Some(m) = self.runtime.text_modules.get(&name) {
-                            let game_context = TextProcessContextRef {
+                    Command::Other(cmd, args) => {
+                        if let Some(module) = self.runtime.text_modules.get(cmd) {
+                            let ctx = TextProcessContextRef {
                                 root_path: &self.root_path,
                                 game_props: &self.game.config.props,
                                 frontend: self.frontend,
                             };
-                            let mut res = self.runtime.modules.get(m).unwrap().dispatch_command(
-                                &name,
-                                &args,
-                                game_context,
-                            )?;
-                            action_line.append(&mut res.line);
-                            for (key, value) in res.props.into_iter() {
-                                props.insert(key, value);
-                            }
-                        } else {
-                            bail!("Invalid command {}", name);
+                            let res = self.runtime.modules[module].dispatch_text(cmd, args, ctx)?;
+                            action.text.extend(res.text.text);
+                            action.vars.extend(res.text.vars);
                         }
                     }
                 },
             }
         }
-        Ok(Action {
-            ctx: self.ctx.clone(),
-            line: action_line,
-            line_params: action_line_params,
-            ch_key: chkey,
-            character: chname,
-            switches,
-            props,
-        })
+        Ok(action)
     }
 
-    fn merge_action(&self, actions: Fallback<Action>) -> Option<Action> {
-        if actions.is_some() {
-            let actions = actions.spec();
+    fn parse_switches(&self, s: &[SwitchItem]) -> Vec<Switch> {
+        s.iter()
+            .zip(&self.switches)
+            .map(|(item, (enabled, _))| Switch {
+                text: item.text.clone(),
+                enabled: *enabled,
+            })
+            .collect()
+    }
 
-            let ctx = actions.ctx.fallback().unwrap_or_default();
-            let line = actions.line.and_any().unwrap_or_default();
-            let line_params = actions.line_params.and_any().unwrap_or_default();
-            let ch_key = actions.ch_key.flatten().and_any();
-            let character = actions.character.flatten().and_any();
-            let switches = actions
-                .switches
-                .into_iter()
-                .map(|s| {
-                    let s = s.spec();
-                    let text = s.text.and_any().unwrap_or_default();
-                    let action = s
-                        .action
-                        .map(|p| p.0)
-                        .and_any()
-                        .map(Program)
-                        .unwrap_or_default();
-                    let (enabled, base_enabled) = s.enabled.unzip();
-                    let enabled = base_enabled.or(enabled).unwrap_or(true);
-                    Switch {
-                        text,
-                        action,
-                        enabled,
+    fn process_line(&mut self, t: Line) -> Result<()> {
+        match t {
+            Line::Empty | Line::Text(_) => {}
+            Line::Exec { exec } => {
+                self.call(&exec);
+            }
+            Line::Switch { switches } => {
+                self.switches.clear();
+                for item in switches {
+                    let enabled = item
+                        .enabled
+                        .as_ref()
+                        .map(|p| self.call(p).get_bool())
+                        .unwrap_or(true);
+                    self.switches.push((enabled, item.action));
+                }
+            }
+            Line::Custom(props) => {
+                self.vars.clear();
+                let cmd = props.iter().next().map(|(key, _)| key);
+                if let Some(cmd) = cmd {
+                    let module = self.runtime.line_modules.get(cmd);
+                    if let Some(module) = module {
+                        let module = &self.runtime.modules[module];
+                        let ctx = LineProcessContextRef {
+                            root_path: &self.root_path,
+                            game_props: &self.game.config.props,
+                            frontend: self.frontend,
+                            ctx: &self.ctx,
+                            props: &props,
+                        };
+                        let res = module.dispatch_line(cmd, ctx)?;
+                        self.ctx.locals.extend(res.locals);
+                        self.vars.extend(res.vars);
+                    } else {
+                        bail!("Cannot find command {}", cmd)
                     }
-                })
-                .collect();
-            let (props, base_props) = actions.props.unzip();
-            let (mut props, base_props) =
-                (props.unwrap_or_default(), base_props.unwrap_or_default());
-            for (key, value) in base_props.into_iter() {
-                props.entry(key).or_insert(value);
+                }
             }
-            Some(Action {
-                ctx,
-                line,
-                line_params,
-                ch_key,
-                character,
-                switches,
-                props,
-            })
-        } else {
-            None
         }
+        Ok(())
     }
 
-    fn process_action(&self, mut action: Action) -> Result<Action> {
-        {
-            let params = std::mem::take(&mut action.line_params);
-            let named = HashMap::<String, RawValue>::new();
-            for line in action.line.iter_mut() {
-                match line {
-                    ActionLine::Chars(s) | ActionLine::Block(s) => {
-                        *s = rt_format::ParsedFormat::parse(s, &params, &named)
-                            .map_err(|id| anyhow!("Format error at {}", id))?
-                            .to_string();
+    fn merge_action(&self, action: Fallback<Action>) -> Result<Action> {
+        match action.unzip() {
+            (None, None) => Ok(Action::default()),
+            (Some(action), None) | (None, Some(action)) => Ok(action),
+            (Some(action), Some(action_base)) => match (action, action_base) {
+                (Action::Text(action), Action::Text(action_base)) => {
+                    let action = Fallback::new(Some(action), Some(action_base));
+                    let action = action.spec();
+                    Ok(Action::Text(ActionText {
+                        text: action
+                            .text
+                            .and_then(|s| if s.is_empty() { None } else { Some(s) })
+                            .unwrap_or_default(),
+                        ch_key: action.ch_key.flatten().fallback(),
+                        character: action.character.flatten().fallback(),
+                        vars: action.vars.and_any().unwrap_or_default(),
+                    }))
+                }
+                (Action::Switches(mut switches), Action::Switches(switches_base)) => {
+                    for (item, item_base) in switches.iter_mut().zip(switches_base) {
+                        item.enabled = item_base.enabled;
                     }
+                    Ok(Action::Switches(switches))
                 }
-            }
+                (Action::Custom(mut vars), Action::Custom(vars_base)) => {
+                    vars.extend(vars_base);
+                    Ok(Action::Custom(vars))
+                }
+                _ => bail!("Mismatching action type"),
+            },
         }
-        let last_action = self.record.history.last();
+    }
+
+    fn process_action_text(&self, ctx: &RawContext, action: &mut ActionText) -> Result<()> {
         for action_module in &self.runtime.action_modules {
             let module = &self.runtime.modules[action_module];
             let ctx = ActionProcessContextRef {
                 root_path: &self.root_path,
                 game_props: &self.game.config.props,
                 frontend: self.frontend,
-                last_action,
-                action: &action,
+                ctx,
+                action,
             };
-            action = module.process_action(ctx)?;
+            *action = module.process_action(ctx)?.action;
         }
-        while let Some(act) = action.line.back() {
+        while let Some(act) = action.text.back() {
             if act.as_str().trim().is_empty() {
-                action.line.pop_back();
+                action.text.pop_back();
             } else {
                 break;
             }
         }
-        while let Some(act) = action.line.front() {
+        while let Some(act) = action.text.front() {
             if act.as_str().trim().is_empty() {
-                action.line.pop_front();
+                action.text.pop_front();
             } else {
                 break;
             }
         }
-        Ok(action)
+        Ok(())
     }
 
-    fn push_history(&mut self, action: &Action) {
-        if !action.line.is_empty() || action.character.is_some() {
-            self.record.history.push(action.clone());
-        }
-    }
+    /// Get the [`Action`] from [`Locale`] and [`ActionParams`].
+    pub fn get_action(&self, loc: &Locale, ctx: &RawContext) -> Result<Action> {
+        let cur_text = self
+            .game
+            .find_para_fallback(loc, &ctx.cur_base_para, &ctx.cur_para)
+            .map(|p| p.texts.get(ctx.cur_act))
+            .flatten();
+
+        let action = cur_text
+            .map(|t| match t {
+                Line::Text(t) => self.parse_text(loc, t).map(Action::Text).ok(),
+                Line::Switch { switches } => Some(Action::Switches(self.parse_switches(switches))),
+                // The real vars will be filled in `merge_action`.
+                Line::Custom(_) => Some(Action::Custom(self.vars.clone())),
+                _ => None,
+            })
+            .flatten();
 
-    fn parse_text_rich_error(&self, text: &str) -> Text {
-        match TextParser::new(text).parse() {
-            Ok(t) => t,
-            Err(e) => {
-                error!("{}", self.rich_error(text, &e));
-                Text::default()
-            }
+        let mut act = self.merge_action(action)?;
+        if let Action::Text(act) = &mut act {
+            self.process_action_text(ctx, act)?;
         }
+        Ok(act)
     }
 
-    fn check_text_rich_error(&self, text: &str) -> bool {
-        if let Err(e) = TextParser::new(text).parse() {
-            eprintln!("{}", self.rich_error(text, &e));
-            false
-        } else {
-            true
+    fn push_history(&mut self) {
+        let ctx = &self.ctx;
+        let cur_text = self
+            .game
+            .find_para(
+                &self.game.config.base_lang,
+                &ctx.cur_base_para,
+                &ctx.cur_para,
+            )
+            .and_then(|p| p.texts.get(ctx.cur_act));
+        let is_text = cur_text
+            .map(|line| matches!(line, Line::Text(_)))
+            .unwrap_or_default();
+        if is_text {
+            self.record.history.push(ctx.clone());
         }
     }
 
     /// Step to next line.
-    pub fn next_run(&mut self) -> Option<Action> {
-        if let Some(action) = self.record.history.last() {
-            self.global_record
-                .record
-                .entry(action.ctx.cur_para.clone())
-                .and_modify(|act| *act = (*act).max(action.ctx.cur_act))
-                .or_insert(action.ctx.cur_act);
-        }
-        let cur_text = loop {
-            let cur_para = self.current_paragraph();
-            let cur_text = self.current_text();
+    pub fn next_run(&mut self) -> Option<RawContext> {
+        let cur_text_base = loop {
+            let cur_para = self.current_paragraph(&self.game.config.base_lang);
+            let cur_text = self.current_text(&self.game.config.base_lang);
             match (cur_para.is_some(), cur_text.is_some()) {
                 (true, true) => break cur_text,
                 (true, false) => {
                     self.ctx.cur_para = cur_para
                         .and_then(|p| p.next.as_ref())
-                        .map(|next| self.parse_text_rich_error(next))
+                        // TODO: reduce clone
+                        .cloned()
                         .map(|text| self.call(&text).into_str())
                         .unwrap_or_default();
                     self.ctx.cur_act = 0;
@@ -494,67 +430,41 @@ impl Context {
                 }
             }
         };
-        let actions = cur_text
-            .map(|act| self.parse_text_rich_error(act))
-            .map(|t| {
-                self.exact_text(t).unwrap_or_else(|e| {
-                    error!("Exact text error: {}", e);
-                    Action::default()
-                })
+
+        // TODO: reduce clone
+        let ctx = if let Some(t) = cur_text_base.cloned() {
+            self.process_line(t).unwrap_or_else(|e| {
+                error!("Parse line error: {}", e);
             });
-        let res = self.merge_action(actions).map(|act| {
-            self.process_action(act).unwrap_or_else(|e| {
-                error!("Error when processing action: {}", e);
-                Action::default()
-            })
-        });
-        if let Some(res) = &res {
-            self.push_history(res);
-        }
+            self.push_history();
+            Some(self.ctx.clone())
+        } else {
+            None
+        };
         self.ctx.cur_act += 1;
-        res
+        ctx
     }
 
     /// Step back to the last run.
-    pub fn next_back_run(&mut self) -> Option<Action> {
+    pub fn next_back_run(&mut self) -> Option<&RawContext> {
         if self.record.history.len() <= 1 {
             None
         } else {
-            if let Some(last_action) = self.record.history.pop() {
-                self.ctx = last_action.ctx;
+            if let Some(ctx) = self.record.history.pop() {
+                self.ctx = ctx;
                 log::debug!(
                     "Back to para {}, act {}",
                     self.ctx.cur_para,
                     self.ctx.cur_act
                 );
             }
-            self.record.history.last().cloned()
+            self.record.history.last()
         }
     }
 
     /// Get current paragraph title.
-    pub fn current_paragraph_title(&self) -> Option<&String> {
-        self.current_paragraph().and_then(|p| p.title.as_ref())
-    }
-
-    /// Check all paragraphs to find grammer errors.
-    pub fn check(&mut self) -> bool {
-        let mut succeed = true;
-        for paras in self.game.paras.values() {
-            for (base_tag, paras) in paras {
-                self.ctx.cur_base_para = base_tag.clone();
-                for para in paras.iter() {
-                    self.ctx.cur_para = para.tag.clone();
-                    for (index, act) in para.texts.iter().enumerate() {
-                        self.ctx.cur_act = index;
-                        succeed &= self.check_text_rich_error(act);
-                    }
-                    if let Some(next) = &para.next {
-                        succeed &= self.check_text_rich_error(next);
-                    }
-                }
-            }
-        }
-        succeed
+    pub fn current_paragraph_title(&self, loc: &Locale) -> Option<&String> {
+        self.current_paragraph_fallback(loc)
+            .and_then(|p| p.title.as_ref())
     }
 }
diff --git a/utils/ayaka-runtime/src/lib.rs b/utils/ayaka-runtime/src/lib.rs
index 68cbf126..5b4a87e6 100644
--- a/utils/ayaka-runtime/src/lib.rs
+++ b/utils/ayaka-runtime/src/lib.rs
@@ -10,7 +10,6 @@
 #![feature(fn_traits)]
 #![feature(generators)]
 #![feature(once_cell)]
-#![feature(round_char_boundary)]
 #![feature(unboxed_closures)]
 
 mod config;
@@ -23,9 +22,11 @@ mod settings;
 #[doc(no_inline)]
 pub use anyhow;
 #[doc(no_inline)]
+pub use ayaka_bindings_types::*;
+#[doc(no_inline)]
 pub use ayaka_script::log;
 #[doc(no_inline)]
-pub use ayaka_script_types::RawValue;
+pub use ayaka_script::*;
 pub use config::*;
 pub use context::*;
 #[doc(no_inline)]
diff --git a/utils/ayaka-runtime/src/plugin.rs b/utils/ayaka-runtime/src/plugin.rs
index 5691e056..f6c5a864 100644
--- a/utils/ayaka-runtime/src/plugin.rs
+++ b/utils/ayaka-runtime/src/plugin.rs
@@ -7,7 +7,6 @@ use crate::*;
 use anyhow::Result;
 use ayaka_bindings_types::*;
 use log::warn;
-use rt_format::ParsedFormat;
 use scopeguard::defer;
 use serde::{de::DeserializeOwned, Serialize};
 use std::{collections::HashMap, path::Path};
@@ -93,17 +92,12 @@ impl Host {
     }
 
     /// Processes [`Action`] in action plugin.
-    pub fn process_action(&self, ctx: ActionProcessContextRef) -> Result<Action> {
+    pub fn process_action(&self, ctx: ActionProcessContextRef) -> Result<ActionProcessResult> {
         self.call("process_action", (ctx,))
     }
 
-    /// Gets registered TeX commands of a text plugin.
-    pub fn text_commands(&self) -> Result<Vec<String>> {
-        self.call("text_commands", ())
-    }
-
     /// Calls a custom command in the text plugin.
-    pub fn dispatch_command(
+    pub fn dispatch_text(
         &self,
         name: &str,
         args: &[String],
@@ -112,6 +106,15 @@ impl Host {
         self.call(name, (args, ctx))
     }
 
+    /// Calls a custom command in the line plugin.
+    pub fn dispatch_line(
+        &self,
+        name: &str,
+        ctx: LineProcessContextRef,
+    ) -> Result<LineProcessResult> {
+        self.call(name, (ctx,))
+    }
+
     /// Processes [`Game`] when opening the config file.
     pub fn process_game(&self, ctx: GameProcessContextRef) -> Result<GameProcessResult> {
         self.call("process_game", (ctx,))
@@ -126,6 +129,8 @@ pub struct Runtime {
     pub action_modules: Vec<String>,
     /// The text plugins by command name.
     pub text_modules: HashMap<String, String>,
+    /// The line plugins by command name.
+    pub line_modules: HashMap<String, String>,
     /// The game plugins.
     pub game_modules: Vec<String>,
 }
@@ -188,37 +193,10 @@ impl Runtime {
         );
         let log_flush_func = Function::new_native(store, || log::logger().flush());
 
-        let format_func = Function::new_native_with_env(
-            store,
-            RuntimeInstanceData::default(),
-            |env_data: &RuntimeInstanceData, len: i32, data: i32| unsafe {
-                env_data.import(len, data, |args: Vec<RawValue>| {
-                    if args.is_empty() {
-                        warn!("Format args is empty.");
-                        RawValue::Unit
-                    } else {
-                        ParsedFormat::parse(
-                            &args[0].get_str(),
-                            &args[1..],
-                            &HashMap::<String, RawValue>::new(),
-                        )
-                        .map(|r| RawValue::Str(r.to_string()))
-                        .unwrap_or_else(|i| {
-                            warn!("Format failed, stopped at {}.", i);
-                            Default::default()
-                        })
-                    }
-                })
-            },
-        );
-
         let import_object = imports! {
             "log" => {
                 "__log" => log_func,
                 "__log_flush" => log_flush_func,
-            },
-            "format" => {
-                "__format" => format_func,
             }
         };
         let wasi_env = WasiState::new("ayaka-runtime")
@@ -246,6 +224,7 @@ impl Runtime {
         let mut modules = HashMap::new();
         let mut action_modules = vec![];
         let mut text_modules = HashMap::new();
+        let mut line_modules = HashMap::new();
         let mut game_modules = vec![];
         let paths = if names.is_empty() {
             std::fs::read_dir(path)?
@@ -296,6 +275,15 @@ impl Runtime {
                     );
                 }
             }
+            for cmd in plugin_type.line {
+                let res = line_modules.insert(cmd.clone(), name.clone());
+                if let Some(old_module) = res {
+                    warn!(
+                        "Command `{}` is overrided by \"{}\" over \"{}\"",
+                        cmd, name, old_module
+                    );
+                }
+            }
             if plugin_type.game {
                 game_modules.push(name.clone());
             }
@@ -305,6 +293,7 @@ impl Runtime {
             modules,
             action_modules,
             text_modules,
+            line_modules,
             game_modules,
         })
     }
diff --git a/utils/ayaka-runtime/src/script.rs b/utils/ayaka-runtime/src/script.rs
index fecb36aa..84219138 100644
--- a/utils/ayaka-runtime/src/script.rs
+++ b/utils/ayaka-runtime/src/script.rs
@@ -1,16 +1,14 @@
 //! The script interpreter.
 
-use crate::{plugin::Runtime, *};
-use ayaka_script_types::*;
-use fallback::Fallback;
+use crate::plugin::Runtime;
+use ayaka_bindings_types::VarMap;
+use ayaka_script::*;
 use log::{error, warn};
 
 /// The variable table in scripts.
 pub struct VarTable<'a> {
     /// The plugin runtime.
     pub runtime: &'a Runtime,
-    /// The resource map.
-    pub res: Fallback<&'a VarMap>,
     /// The context variables.
     pub locals: &'a mut VarMap,
     /// The locale variables.
@@ -19,10 +17,9 @@ pub struct VarTable<'a> {
 
 impl<'a> VarTable<'a> {
     /// Creates a new [`VarTable`].
-    pub fn new(runtime: &'a Runtime, res: Fallback<&'a VarMap>, locals: &'a mut VarMap) -> Self {
+    pub fn new(runtime: &'a Runtime, locals: &'a mut VarMap) -> Self {
         Self {
             runtime,
-            res,
             locals,
             vars: VarMap::default(),
         }
@@ -192,7 +189,6 @@ fn assign(ctx: &mut VarTable, e: &Expr, val: RawValue) -> RawValue {
         Expr::Ref(r) => match r {
             Ref::Var(n) => ctx.vars.insert(n.into(), val),
             Ref::Ctx(n) => ctx.locals.insert(n.into(), val),
-            Ref::Res(_) => unimplemented!("Resources"),
         },
         _ => unreachable!(),
     };
@@ -238,15 +234,6 @@ impl Callable for Ref {
                 warn!("Cannot find context variable `{}`.", n);
                 Default::default()
             }),
-            Self::Res(n) => ctx
-                .res
-                .as_ref()
-                .and_then(|map| map.get(n))
-                .cloned()
-                .unwrap_or_else(|| {
-                    warn!("Cannot find resource `{}`.", n);
-                    Default::default()
-                }),
         }
     }
 }
@@ -256,11 +243,20 @@ impl Callable for Text {
         let mut str = String::new();
         for line in &self.0 {
             match line {
-                Line::Str(s) => str.push_str(s),
-                Line::Cmd(c) => {
-                    if let Command::Exec(p) = c {
-                        str.push_str(&p.call(ctx).get_str())
-                    }
+                SubText::Str(s) => str.push_str(s),
+                SubText::Cmd(c) => {
+                    let value = match c {
+                        Command::Character(_, _) => RawValue::Unit,
+                        Command::Res(_) | Command::Other(_, _) => {
+                            warn!("Unsupported command in text.");
+                            Default::default()
+                        }
+                        Command::Ctx(n) => ctx.locals.get(n).cloned().unwrap_or_else(|| {
+                            warn!("Cannot find variable `{}`.", n);
+                            Default::default()
+                        }),
+                    };
+                    str.push_str(&value.get_str());
                 }
             }
         }
@@ -271,7 +267,6 @@ impl Callable for Text {
 #[cfg(test)]
 mod test {
     use crate::{plugin::Runtime, script::*};
-    use ayaka_script::*;
     use tokio::sync::OnceCell;
 
     static RUNTIME: OnceCell<Runtime> = OnceCell::const_new();
@@ -282,13 +277,13 @@ mod test {
                 let runtime = Runtime::load(
                     "../../examples/plugins",
                     env!("CARGO_MANIFEST_DIR"),
-                    &["format"],
+                    &["random"],
                 );
                 runtime.await.unwrap()
             })
             .await;
         let mut locals = VarMap::default();
-        let mut ctx = VarTable::new(runtime, Fallback::new(None, None), &mut locals);
+        let mut ctx = VarTable::new(runtime, &mut locals);
         f(&mut ctx);
     }
 
@@ -370,20 +365,19 @@ mod test {
     }
 
     #[tokio::test]
-    async fn format() {
+    async fn random() {
         with_ctx(|ctx| {
-            assert_eq!(
-                ProgramParser::new()
+            assert!((0..10).contains(
+                &ProgramParser::new()
                     .parse(
                         r##"
-                            format.fmt("Hello {}!", 114514)
+                            random.rnd(10)
                         "##
                     )
                     .ok()
                     .call(ctx)
-                    .get_str(),
-                "Hello 114514!"
-            )
+                    .get_num()
+            ))
         })
         .await;
     }
diff --git a/utils/ayaka-runtime/src/settings.rs b/utils/ayaka-runtime/src/settings.rs
index cb1a63af..a4144d47 100644
--- a/utils/ayaka-runtime/src/settings.rs
+++ b/utils/ayaka-runtime/src/settings.rs
@@ -1,8 +1,6 @@
-#[doc(no_inline)]
-pub use ayaka_bindings_types::VarMap;
-
 use crate::*;
 use anyhow::{anyhow, Result};
+use ayaka_bindings_types::RawContext;
 use dirs::{config_dir, data_local_dir};
 use serde::{de::DeserializeOwned, Deserialize, Serialize};
 use std::{
@@ -16,6 +14,8 @@ use tryiterator::TryIteratorExt;
 pub struct Settings {
     /// The display language.
     pub lang: Locale,
+    /// The secondary display language.
+    pub sub_lang: Option<Locale>,
 }
 
 impl Settings {
@@ -23,6 +23,7 @@ impl Settings {
     pub fn new() -> Self {
         Self {
             lang: Locale::default(),
+            sub_lang: None,
         }
     }
 }
@@ -35,17 +36,38 @@ pub struct GlobalRecord {
     pub record: HashMap<String, usize>,
 }
 
+impl GlobalRecord {
+    /// Determine if an [`ActionParams`] has been visited,
+    /// by the paragraph tag and action index.
+    pub fn visited(&self, ctx: &RawContext) -> bool {
+        if let Some(max_act) = self.record.get(&ctx.cur_para) {
+            log::debug!("Test act: {}, max act: {}", ctx.cur_act, max_act);
+            *max_act >= ctx.cur_act
+        } else {
+            false
+        }
+    }
+
+    /// Update the global record with the latest [`ActionParams`].
+    pub fn update(&mut self, ctx: &RawContext) {
+        self.record
+            .entry(ctx.cur_para.clone())
+            .and_modify(|act| *act = (*act).max(ctx.cur_act))
+            .or_insert(ctx.cur_act);
+    }
+}
+
 /// The specific record.
 #[derive(Debug, Default, Clone, Deserialize, Serialize)]
 pub struct ActionRecord {
     /// The history actions.
-    pub history: Vec<Action>,
+    pub history: Vec<RawContext>,
 }
 
 impl ActionRecord {
     /// Get the [`RawContext`] object from the last [`Action`] in the history.
     pub fn last_ctx(&self) -> Option<&RawContext> {
-        self.history.last().map(|act| &act.ctx)
+        self.history.last()
     }
 
     /// Get the [`RawContext`] object from the last [`Action`] in the history,
diff --git a/utils/ayaka-script-types/Cargo.toml b/utils/ayaka-script-types/Cargo.toml
deleted file mode 100644
index 0bac3d1c..00000000
--- a/utils/ayaka-script-types/Cargo.toml
+++ /dev/null
@@ -1,14 +0,0 @@
-[package]
-name = "ayaka-script-types"
-version = "0.1.0"
-edition = "2021"
-
-[dependencies]
-serde = { version = "1.0", features = ["derive"] }
-rt-format = { version = "0.3", optional = true }
-
-[dev-dependencies]
-serde_yaml = "0.9"
-
-[features]
-default = []
diff --git a/utils/ayaka-script-types/src/exec.rs b/utils/ayaka-script-types/src/exec.rs
deleted file mode 100644
index 122bfa79..00000000
--- a/utils/ayaka-script-types/src/exec.rs
+++ /dev/null
@@ -1,106 +0,0 @@
-use crate::*;
-use serde::{Deserialize, Serialize};
-
-/// A full script, a collection of expressions.
-///
-/// The last expression is the final value of the script.
-#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq)]
-pub struct Program(pub Vec<Expr>);
-
-/// An expression.
-///
-/// Two expressions should be splited with `;`.
-#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
-pub enum Expr {
-    /// A reference to a variable.
-    Ref(Ref),
-    /// A const value.
-    Const(RawValue),
-    /// A unary operation.
-    Unary(UnaryOp, Box<Expr>),
-    /// A binary operation.
-    Binary(Box<Expr>, BinaryOp, Box<Expr>),
-    /// A call to a function.
-    Call(String, String, Vec<Expr>),
-}
-
-/// Unary operations.
-#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
-pub enum UnaryOp {
-    /// `+`
-    Positive,
-    /// `-`
-    Negative,
-    /// `!`
-    Not,
-}
-
-/// Binary operations.
-#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
-pub enum BinaryOp {
-    /// Value operations.
-    Val(ValBinaryOp),
-    /// Logical operations.
-    Logic(LogicBinaryOp),
-    /// Assignment.
-    Assign,
-    /// Inplace value operations.
-    Inplace(ValBinaryOp),
-}
-
-/// Value binary operations.
-#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
-pub enum ValBinaryOp {
-    /// `+`
-    Add,
-    /// `-`
-    Minus,
-    /// `*`
-    Mul,
-    /// `/`
-    Div,
-    /// `%`
-    Mod,
-    /// `&`
-    And,
-    /// `|`
-    Or,
-    /// `^`
-    Xor,
-}
-
-/// Logical operations.
-#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
-pub enum LogicBinaryOp {
-    /// `&&`
-    And,
-    /// `||`
-    Or,
-    /// `==`
-    Eq,
-    /// `!=`
-    Neq,
-    /// `<`
-    Lt,
-    /// `<=`
-    Le,
-    /// `>`
-    Gt,
-    /// `>=`
-    Ge,
-}
-
-/// Reference of a variable.
-#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
-pub enum Ref {
-    /// A local variable.
-    /// It is only accessible in current program.
-    Var(String),
-    /// A context variable.
-    /// It is stored in the context.
-    /// The variable name is prefixed with `$`.
-    Ctx(String),
-    /// A resource constant.
-    /// The constant name is prefixed with `#`.
-    Res(String),
-}
diff --git a/utils/ayaka-script-types/src/lib.rs b/utils/ayaka-script-types/src/lib.rs
deleted file mode 100644
index f285e15c..00000000
--- a/utils/ayaka-script-types/src/lib.rs
+++ /dev/null
@@ -1,12 +0,0 @@
-//! The types used by script and text parsers.
-
-#![warn(missing_docs)]
-#![deny(unsafe_code)]
-
-mod exec;
-mod primitive;
-mod text;
-
-pub use exec::*;
-pub use primitive::*;
-pub use text::*;
diff --git a/utils/ayaka-script-types/src/text.rs b/utils/ayaka-script-types/src/text.rs
deleted file mode 100644
index 923c78ae..00000000
--- a/utils/ayaka-script-types/src/text.rs
+++ /dev/null
@@ -1,40 +0,0 @@
-use crate::*;
-
-/// A collection of [`Line`].
-#[derive(Debug, Default, PartialEq, Eq)]
-pub struct Text(pub Vec<Line>);
-
-/// A part of a line, either some texts or a command.
-#[derive(Debug, PartialEq, Eq)]
-pub enum Line {
-    /// Raw texts.
-    Str(String),
-    /// A command. See [`Command`].
-    Cmd(Command),
-}
-
-/// A TeX-like command in the text.
-#[derive(Debug, PartialEq, Eq)]
-pub enum Command {
-    /// `\ch{}{}`
-    ///
-    /// Controls the current character.
-    Character(String, String),
-    /// `\exec{}`
-    ///
-    /// Executes a program and calculates the return value into text.
-    Exec(Program),
-    /// `\switch{}{}{}`
-    ///
-    /// A switch.
-    Switch {
-        /// The text of the switch.
-        text: String,
-        /// The action after choosing the switch,
-        action: Program,
-        /// The expression determines whether the switch is enabled.
-        enabled: Option<Program>,
-    },
-    /// Other custom commands.
-    Other(String, Vec<String>),
-}
diff --git a/utils/ayaka-script/Cargo.toml b/utils/ayaka-script/Cargo.toml
index 504dce20..9e140921 100644
--- a/utils/ayaka-script/Cargo.toml
+++ b/utils/ayaka-script/Cargo.toml
@@ -4,10 +4,15 @@ version = "0.1.0"
 edition = "2021"
 
 [dependencies]
-ayaka-script-types = { path = "../ayaka-script-types" }
+anyhow = "1.0"
+serde = { version = "1.0", features = ["derive"] }
+serde_with = "2.0"
 lalrpop-util = { version = "0.19", features = ["lexer"] }
 regex = "1"
 log = "0.4"
 
+[dev-dependencies]
+serde_yaml = "0.9"
+
 [build-dependencies]
 lalrpop = "0.19"
diff --git a/utils/ayaka-script/src/exec/grammer.lalrpop b/utils/ayaka-script/src/exec/grammer.lalrpop
index 2969ab2b..0315b4ef 100644
--- a/utils/ayaka-script/src/exec/grammer.lalrpop
+++ b/utils/ayaka-script/src/exec/grammer.lalrpop
@@ -100,7 +100,6 @@ AssignOp: BinaryOp = {
 pub Ref: Ref = {
     <i:Id> => Ref::Var(i),
     "$" <i:Id> => Ref::Ctx(i),
-    "#" <i:Id> => Ref::Res(i),
 }
 
 Id: String = <s:r"[A-Za-z]\w*"> => s.into();
diff --git a/utils/ayaka-script/src/exec/mod.rs b/utils/ayaka-script/src/exec/mod.rs
index da96152a..f9e8f6fa 100644
--- a/utils/ayaka-script/src/exec/mod.rs
+++ b/utils/ayaka-script/src/exec/mod.rs
@@ -1,8 +1,112 @@
 //! The script parser.
 
+use std::str::FromStr;
+
 use crate::*;
-use ayaka_script_types::*;
 use lalrpop_util::lalrpop_mod;
+use serde::Deserialize;
+
+/// A full script, a collection of expressions.
+///
+/// The last expression is the final value of the script.
+#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)]
+#[serde(try_from = "String")]
+pub struct Program(pub Vec<Expr>);
+
+/// An expression.
+///
+/// Two expressions should be splited with `;`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Expr {
+    /// A reference to a variable.
+    Ref(Ref),
+    /// A const value.
+    Const(RawValue),
+    /// A unary operation.
+    Unary(UnaryOp, Box<Expr>),
+    /// A binary operation.
+    Binary(Box<Expr>, BinaryOp, Box<Expr>),
+    /// A call to a function.
+    Call(String, String, Vec<Expr>),
+}
+
+/// Unary operations.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum UnaryOp {
+    /// `+`
+    Positive,
+    /// `-`
+    Negative,
+    /// `!`
+    Not,
+}
+
+/// Binary operations.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum BinaryOp {
+    /// Value operations.
+    Val(ValBinaryOp),
+    /// Logical operations.
+    Logic(LogicBinaryOp),
+    /// Assignment.
+    Assign,
+    /// Inplace value operations.
+    Inplace(ValBinaryOp),
+}
+
+/// Value binary operations.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ValBinaryOp {
+    /// `+`
+    Add,
+    /// `-`
+    Minus,
+    /// `*`
+    Mul,
+    /// `/`
+    Div,
+    /// `%`
+    Mod,
+    /// `&`
+    And,
+    /// `|`
+    Or,
+    /// `^`
+    Xor,
+}
+
+/// Logical operations.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum LogicBinaryOp {
+    /// `&&`
+    And,
+    /// `||`
+    Or,
+    /// `==`
+    Eq,
+    /// `!=`
+    Neq,
+    /// `<`
+    Lt,
+    /// `<=`
+    Le,
+    /// `>`
+    Gt,
+    /// `>=`
+    Ge,
+}
+
+/// Reference of a variable.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Ref {
+    /// A local variable.
+    /// It is only accessible in current program.
+    Var(String),
+    /// A context variable.
+    /// It is stored in the context.
+    /// The variable name is prefixed with `$`.
+    Ctx(String),
+}
 
 lalrpop_mod!(
     #[allow(missing_docs)]
@@ -13,6 +117,24 @@ lalrpop_mod!(
 
 pub use grammer::{ConstParser, ExprParser, ProgramParser, RefParser};
 
+impl FromStr for Program {
+    type Err = anyhow::Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        ProgramParser::new()
+            .parse(s)
+            .map_err(|e| anyhow::anyhow!("{}", e))
+    }
+}
+
+impl TryFrom<String> for Program {
+    type Error = anyhow::Error;
+
+    fn try_from(value: String) -> Result<Self, Self::Error> {
+        value.parse()
+    }
+}
+
 #[cfg(test)]
 mod test {
     use crate::exec::*;
@@ -109,6 +231,5 @@ mod test {
     fn r#ref() {
         assert_eq!(RefParser::new().parse("a").unwrap(), Ref::Var("a".into()));
         assert_eq!(RefParser::new().parse("$a").unwrap(), Ref::Ctx("a".into()));
-        assert_eq!(RefParser::new().parse("#a").unwrap(), Ref::Res("a".into()));
     }
 }
diff --git a/utils/ayaka-script/src/lib.rs b/utils/ayaka-script/src/lib.rs
index 7db7c35b..23413607 100644
--- a/utils/ayaka-script/src/lib.rs
+++ b/utils/ayaka-script/src/lib.rs
@@ -6,9 +6,13 @@
 #![feature(once_cell)]
 
 mod exec;
+mod line;
+mod primitive;
 mod text;
 
 pub use exec::*;
+pub use line::*;
 #[doc(no_inline)]
 pub use log;
+pub use primitive::*;
 pub use text::*;
diff --git a/utils/ayaka-script/src/line.rs b/utils/ayaka-script/src/line.rs
new file mode 100644
index 00000000..ac9de954
--- /dev/null
+++ b/utils/ayaka-script/src/line.rs
@@ -0,0 +1,138 @@
+use crate::*;
+use serde::Deserialize;
+use serde_with::rust::maps_duplicate_key_is_error;
+use std::{collections::HashMap, str::FromStr};
+
+/// Represents a line in a prograph.
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
+#[serde(untagged)]
+pub enum Line {
+    /// An empty line, usually fallbacks to the base language one.
+    Empty,
+    /// A text line.
+    Text(Text),
+    /// An `exec` line, to execute scripts.
+    Exec {
+        /// The program to execute.
+        exec: Program,
+    },
+    /// Some `switches`.
+    Switch {
+        /// The switch items.
+        switches: Vec<SwitchItem>,
+    },
+    /// Custom line types.
+    #[serde(with = "maps_duplicate_key_is_error")]
+    Custom(HashMap<String, RawValue>),
+}
+
+/// A switch item in the `switches`.
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
+#[serde(try_from = "String")]
+pub struct SwitchItem {
+    /// The display text of the item.
+    pub text: String,
+    /// Whether the item is enabled.
+    /// [`None`] is enabled.
+    pub enabled: Option<Program>,
+    /// The action to execute if the item is choosen.
+    pub action: Program,
+}
+
+impl FromStr for SwitchItem {
+    type Err = anyhow::Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let mut splits = s.split('|');
+        let text = splits.next().unwrap_or_default().to_string();
+        let enabled = if let Some(s) = splits.next() {
+            if s.is_empty() {
+                None
+            } else {
+                Some(s.parse()?)
+            }
+        } else {
+            None
+        };
+        let action = splits.next().unwrap_or_default().parse()?;
+        Ok(Self {
+            text,
+            enabled,
+            action,
+        })
+    }
+}
+
+impl TryFrom<String> for SwitchItem {
+    type Error = anyhow::Error;
+
+    fn try_from(value: String) -> Result<Self, Self::Error> {
+        value.parse()
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use std::collections::HashMap;
+
+    use crate::*;
+
+    #[test]
+    fn parse() {
+        let lines = r#"
+- abc
+- exec: $a
+- switches:
+  - a||$n = 1
+  - b|false|$n = 0
+- video: 0
+-
+        "#;
+        let lines: Vec<Line> = serde_yaml::from_str(lines).unwrap();
+        assert_eq!(lines.len(), 5);
+        assert_eq!(
+            lines[0],
+            Line::Text(Text(vec![SubText::Str("abc".to_string())]))
+        );
+        assert_eq!(
+            lines[1],
+            Line::Exec {
+                exec: Program(vec![Expr::Ref(Ref::Ctx("a".to_string()))])
+            }
+        );
+        assert_eq!(
+            lines[2],
+            Line::Switch {
+                switches: vec![
+                    SwitchItem {
+                        text: "a".to_string(),
+                        enabled: None,
+                        action: Program(vec![Expr::Binary(
+                            Box::new(Expr::Ref(Ref::Ctx("n".to_string()))),
+                            BinaryOp::Assign,
+                            Box::new(Expr::Const(RawValue::Num(1)))
+                        )])
+                    },
+                    SwitchItem {
+                        text: "b".to_string(),
+                        enabled: Some(Program(vec![Expr::Const(RawValue::Bool(false))])),
+                        action: Program(vec![Expr::Binary(
+                            Box::new(Expr::Ref(Ref::Ctx("n".to_string()))),
+                            BinaryOp::Assign,
+                            Box::new(Expr::Const(RawValue::Num(0)))
+                        )])
+                    }
+                ]
+            }
+        );
+        assert_eq!(
+            lines[3],
+            Line::Custom({
+                let mut map = HashMap::new();
+                map.insert("video".to_string(), RawValue::Num(0));
+                map
+            })
+        );
+        assert_eq!(lines[4], Line::Empty);
+    }
+}
diff --git a/utils/ayaka-script-types/src/primitive.rs b/utils/ayaka-script/src/primitive.rs
similarity index 58%
rename from utils/ayaka-script-types/src/primitive.rs
rename to utils/ayaka-script/src/primitive.rs
index dcf37e41..538919c9 100644
--- a/utils/ayaka-script-types/src/primitive.rs
+++ b/utils/ayaka-script/src/primitive.rs
@@ -3,13 +3,14 @@ use std::borrow::Cow;
 
 /// The basic and only type used in scripts.
 /// ```
-/// # use ayaka_script_types::RawValue;
+/// # use ayaka_script::RawValue;
 /// assert_eq!(serde_yaml::from_str::<RawValue>("~").unwrap(), RawValue::Unit);
 /// assert_eq!(serde_yaml::from_str::<RawValue>("true").unwrap(), RawValue::Bool(true));
 /// assert_eq!(serde_yaml::from_str::<RawValue>("123").unwrap(), RawValue::Num(123));
 /// assert_eq!(serde_yaml::from_str::<RawValue>("\"hello\"").unwrap(), RawValue::Str("hello".to_string()));
 /// ```
-#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
+#[serde(untagged)]
 pub enum RawValue {
     /// The unit type. It is empty, just like [`None`] or [`()`] in Rust.
     Unit,
@@ -57,7 +58,7 @@ impl RawValue {
     /// * A [`RawValue::Str`] converts to `false` if and only if it's empty.
     ///
     /// ```
-    /// # use ayaka_script_types::RawValue;
+    /// # use ayaka_script::RawValue;
     /// let unit_value = RawValue::Unit;
     /// assert!(!unit_value.get_bool());
     /// let num_value = RawValue::Num(123);
@@ -82,7 +83,7 @@ impl RawValue {
     /// * A [`RawValue::Str`] converts to the length of the string.
     ///
     /// ```
-    /// # use ayaka_script_types::RawValue;
+    /// # use ayaka_script::RawValue;
     /// let unit_value = RawValue::Unit;
     /// assert_eq!(unit_value.get_num(), 0);
     /// let bool_value = RawValue::Bool(true);
@@ -107,7 +108,7 @@ impl RawValue {
     /// Be careful to use `get_str().into_owned()`, if possible, use `into_str()` instead.
     ///
     /// ```
-    /// # use ayaka_script_types::RawValue;
+    /// # use ayaka_script::RawValue;
     /// let unit_value = RawValue::Unit;
     /// assert_eq!(unit_value.get_str(), "");
     /// let bool_value = RawValue::Bool(true);
@@ -138,147 +139,6 @@ impl RawValue {
     }
 }
 
-impl<'de> Deserialize<'de> for RawValue {
-    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
-    where
-        D: serde::Deserializer<'de>,
-    {
-        use serde::de::Error;
-
-        struct ValueVisitor;
-
-        impl<'de> serde::de::Visitor<'de> for ValueVisitor {
-            type Value = RawValue;
-
-            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
-                formatter.write_str("a unit, boolean, integer, string value")
-            }
-
-            fn visit_unit<E>(self) -> Result<Self::Value, E>
-            where
-                E: Error,
-            {
-                Ok(RawValue::Unit)
-            }
-
-            fn visit_bool<E>(self, v: bool) -> Result<Self::Value, E>
-            where
-                E: Error,
-            {
-                Ok(RawValue::Bool(v))
-            }
-
-            fn visit_i64<E>(self, v: i64) -> Result<Self::Value, E>
-            where
-                E: Error,
-            {
-                Ok(RawValue::Num(v))
-            }
-
-            fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
-            where
-                E: Error,
-            {
-                Ok(RawValue::Num(v as i64))
-            }
-
-            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
-            where
-                E: Error,
-            {
-                Ok(RawValue::Str(v.into()))
-            }
-        }
-        deserializer.deserialize_any(ValueVisitor)
-    }
-}
-
-impl Serialize for RawValue {
-    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
-    where
-        S: serde::Serializer,
-    {
-        match self {
-            Self::Unit => serializer.serialize_unit(),
-            Self::Bool(b) => serializer.serialize_bool(*b),
-            Self::Num(n) => serializer.serialize_i64(*n),
-            Self::Str(s) => serializer.serialize_str(s),
-        }
-    }
-}
-
-#[cfg(feature = "rt-format")]
-use rt_format::{Format, FormatArgument, Specifier};
-
-#[cfg(feature = "rt-format")]
-impl FormatArgument for RawValue {
-    fn supports_format(&self, specifier: &Specifier) -> bool {
-        match self {
-            RawValue::Unit | RawValue::Bool(_) | RawValue::Str(_) => {
-                matches!(specifier.format, Format::Debug | Format::Display)
-            }
-            RawValue::Num(_) => true,
-        }
-    }
-
-    fn fmt_display(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
-        use std::fmt::Display;
-        match self {
-            RawValue::Unit => Ok(()),
-            RawValue::Bool(b) => b.fmt(f),
-            RawValue::Num(n) => n.fmt(f),
-            RawValue::Str(s) => s.fmt(f),
-        }
-    }
-
-    fn fmt_debug(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
-        use std::fmt::Debug;
-        self.fmt(f)
-    }
-
-    fn fmt_octal(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
-        match self {
-            RawValue::Num(n) => std::fmt::Octal::fmt(n, f),
-            _ => Err(std::fmt::Error),
-        }
-    }
-
-    fn fmt_lower_hex(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
-        match self {
-            RawValue::Num(n) => std::fmt::LowerHex::fmt(n, f),
-            _ => Err(std::fmt::Error),
-        }
-    }
-
-    fn fmt_upper_hex(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
-        match self {
-            RawValue::Num(n) => std::fmt::UpperHex::fmt(n, f),
-            _ => Err(std::fmt::Error),
-        }
-    }
-
-    fn fmt_binary(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
-        match self {
-            RawValue::Num(n) => std::fmt::Binary::fmt(n, f),
-            _ => Err(std::fmt::Error),
-        }
-    }
-
-    fn fmt_lower_exp(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
-        match self {
-            RawValue::Num(n) => std::fmt::LowerExp::fmt(n, f),
-            _ => Err(std::fmt::Error),
-        }
-    }
-
-    fn fmt_upper_exp(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
-        match self {
-            RawValue::Num(n) => std::fmt::UpperExp::fmt(n, f),
-            _ => Err(std::fmt::Error),
-        }
-    }
-}
-
 #[cfg(test)]
 mod test {
     use crate::*;
diff --git a/utils/ayaka-script/src/text.rs b/utils/ayaka-script/src/text.rs
index 0077f97f..abf88274 100644
--- a/utils/ayaka-script/src/text.rs
+++ b/utils/ayaka-script/src/text.rs
@@ -1,9 +1,47 @@
 //! The text parser.
 
-use crate::*;
-use ayaka_script_types::*;
 use regex::Regex;
-use std::{error::Error, fmt::Display, iter::Peekable, str::CharIndices, sync::LazyLock};
+use serde::Deserialize;
+use std::{
+    error::Error,
+    fmt::Display,
+    iter::Peekable,
+    str::{CharIndices, FromStr},
+    sync::LazyLock,
+};
+
+/// A collection of [`SubText`].
+#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
+#[serde(try_from = "String")]
+pub struct Text(pub Vec<SubText>);
+
+/// A part of a line, either some texts or a command.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum SubText {
+    /// Raw texts.
+    Str(String),
+    /// A command. See [`Command`].
+    Cmd(Command),
+}
+
+/// A TeX-like command in the text.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Command {
+    /// `\ch{}{}`
+    ///
+    /// Controls the current character.
+    Character(String, String),
+    /// `\var{}`
+    ///
+    /// Get the local variables.
+    Ctx(String),
+    /// `\res{}`
+    ///
+    /// Get the resource consts.
+    Res(String),
+    /// Other custom commands.
+    Other(String, Vec<String>),
+}
 
 static SPACE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(\s+)").unwrap());
 
@@ -437,7 +475,7 @@ impl<'a> TextParser<'a> {
         Ok(Text(self.try_collect()?))
     }
 
-    fn parse_next(&mut self) -> ParseResult<Option<Line>> {
+    fn parse_next(&mut self) -> ParseResult<Option<SubText>> {
         let mut str = String::new();
         while let Some(tok) = self.lexer.peek() {
             match tok {
@@ -455,7 +493,7 @@ impl<'a> TextParser<'a> {
                             let name = name.to_string();
                             let alias = alias.to_string();
                             self.lexer.next();
-                            return Ok(Some(Line::Cmd(Command::Character(name, alias))));
+                            return Ok(Some(SubText::Cmd(Command::Character(name, alias))));
                         } else {
                             break;
                         }
@@ -478,7 +516,7 @@ impl<'a> TextParser<'a> {
         }
         if !str.is_empty() {
             let trimmed_str = SPACE_REGEX.replace_all(&str, " ");
-            Ok(Some(Line::Str(trimmed_str.into_owned())))
+            Ok(Some(SubText::Str(trimmed_str.into_owned())))
         } else {
             Ok(None)
         }
@@ -497,31 +535,6 @@ impl<'a> TextParser<'a> {
         Ok(str)
     }
 
-    fn parse_program(toks: &[RichToken]) -> ParseResult<Program> {
-        let program = Self::concat_params(toks)?;
-        match ProgramParser::new().parse(&program) {
-            Ok(p) => Ok(p),
-            Err(e) => {
-                use lalrpop_util::ParseError as ExecParseError;
-
-                let loc = Loc::from_locs(toks.iter().map(|tok| tok.loc));
-                let loc = match &e {
-                    ExecParseError::InvalidToken { location } => {
-                        Loc(loc.0 + location, loc.0 + location + 1)
-                    }
-                    ExecParseError::UnrecognizedEOF {
-                        location: _,
-                        expected: _,
-                    } => Loc(loc.1, loc.1 + 1),
-                    ExecParseError::UnrecognizedToken { token, expected: _ }
-                    | ExecParseError::ExtraToken { token } => Loc(loc.0 + token.0, loc.0 + token.2),
-                    ExecParseError::User { error: _ } => loc,
-                };
-                parse_error(loc, ParseErrorType::InvalidProgram(e.to_string()))
-            }
-        }
-    }
-
     fn check_params_count(
         count: usize,
         min: usize,
@@ -539,17 +552,9 @@ impl<'a> TextParser<'a> {
         }
     }
 
-    fn parse_command(loc: Loc, name: &str, params: &[Vec<RichToken>]) -> ParseResult<Line> {
+    fn parse_command(loc: Loc, name: &str, params: &[Vec<RichToken>]) -> ParseResult<SubText> {
         let params_count = params.len();
         let cmd = match name {
-            "res" => {
-                Self::check_params_count(params_count, 1, 1, loc, name)?;
-                // Construct a simple program to get the resource.
-                // We don't expose this command to the front end.
-                Command::Exec(Program(vec![Expr::Ref(Ref::Res(Self::concat_params(
-                    &params[0],
-                )?))]))
-            }
             "ch" => {
                 Self::check_params_count(params_count, 1, 2, loc, name)?;
                 Command::Character(
@@ -562,25 +567,13 @@ impl<'a> TextParser<'a> {
                     )?,
                 )
             }
-            "exec" => {
+            "res" => {
                 Self::check_params_count(params_count, 1, 1, loc, name)?;
-                Command::Exec(Self::parse_program(&params[0])?)
+                Command::Res(Self::concat_params(&params[0])?)
             }
-            "switch" => {
-                Self::check_params_count(params_count, 1, 3, loc, name)?;
-                let enabled = match params.get(2) {
-                    Some(toks) => Some(Self::parse_program(toks)?),
-                    None => None,
-                };
-                Command::Switch {
-                    text: Self::concat_params(&params[0])?,
-                    action: if let Some(toks) = params.get(1) {
-                        Self::parse_program(toks)?
-                    } else {
-                        Program::default()
-                    },
-                    enabled,
-                }
+            "var" => {
+                Self::check_params_count(params_count, 1, 1, loc, name)?;
+                Command::Ctx(Self::concat_params(&params[0])?)
             }
             name => {
                 let mut args = vec![];
@@ -590,12 +583,12 @@ impl<'a> TextParser<'a> {
                 Command::Other(name.to_string(), args)
             }
         };
-        Ok(Line::Cmd(cmd))
+        Ok(SubText::Cmd(cmd))
     }
 }
 
 impl<'a> Iterator for TextParser<'a> {
-    type Item = ParseResult<Line>;
+    type Item = ParseResult<SubText>;
 
     fn next(&mut self) -> Option<Self::Item> {
         match self.parse_next() {
@@ -606,6 +599,22 @@ impl<'a> Iterator for TextParser<'a> {
     }
 }
 
+impl FromStr for Text {
+    type Err = ParseError;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        TextParser::new(s).parse()
+    }
+}
+
+impl TryFrom<String> for Text {
+    type Error = ParseError;
+
+    fn try_from(value: String) -> Result<Self, Self::Error> {
+        value.parse()
+    }
+}
+
 #[cfg(test)]
 mod test_lexer {
     use crate::text::*;
@@ -654,11 +663,11 @@ mod test_parser {
     fn basic() {
         assert_eq!(
             TextParser::new("\\\\").parse().unwrap(),
-            Text(vec![Line::Str("\\".to_string())])
+            Text(vec![SubText::Str("\\".to_string())])
         );
         assert_eq!(
             TextParser::new("\\{").parse().unwrap(),
-            Text(vec![Line::Str("{".to_string())])
+            Text(vec![SubText::Str("{".to_string())])
         );
     }
 
@@ -667,54 +676,13 @@ mod test_parser {
         assert_eq!(
             TextParser::new("\\cmd{123} \\cmd{123}").parse().unwrap(),
             Text(vec![
-                Line::Cmd(Command::Other("cmd".to_string(), vec!["123".to_string()])),
-                Line::Str(" ".to_string()),
-                Line::Cmd(Command::Other("cmd".to_string(), vec!["123".to_string()])),
+                SubText::Cmd(Command::Other("cmd".to_string(), vec!["123".to_string()])),
+                SubText::Str(" ".to_string()),
+                SubText::Cmd(Command::Other("cmd".to_string(), vec!["123".to_string()])),
             ])
         );
     }
 
-    #[test]
-    fn exec() {
-        assert_eq!(
-            TextParser::new(r##"\exec{"Hello world!"}"##)
-                .parse()
-                .unwrap(),
-            Text(vec![Line::Cmd(Command::Exec(Program(vec![Expr::Const(
-                RawValue::Str("Hello world!".to_string())
-            )])))])
-        );
-        assert_eq!(
-            TextParser::new(r##"\exec{"Hello world!{}"}"##)
-                .parse()
-                .unwrap(),
-            Text(vec![Line::Cmd(Command::Exec(Program(vec![Expr::Const(
-                RawValue::Str("Hello world!{}".to_string())
-            )])))])
-        );
-        TextParser::new(r##"\exec{format.fmt("Hello {}", "world!")}"##)
-            .parse()
-            .unwrap();
-    }
-
-    #[test]
-    fn switch() {
-        assert_eq!(
-            TextParser::new(r##"\switch{hello}{"Hello world!"}"##)
-                .parse()
-                .unwrap(),
-            Text(vec![Line::Cmd(Command::Switch {
-                text: "hello".to_string(),
-                action: Program(vec![Expr::Const(RawValue::Str("Hello world!".to_string()))]),
-                enabled: None
-            })])
-        );
-
-        TextParser::new(r##"\switch{hello}{$s = 2}{a == b}"##)
-            .parse()
-            .unwrap();
-    }
-
     #[test]
     fn error() {
         assert_eq!(
@@ -727,27 +695,27 @@ mod test_parser {
     fn lf() {
         assert_eq!(
             TextParser::new(" ").parse().unwrap(),
-            Text(vec![Line::Str(" ".to_string())])
+            Text(vec![SubText::Str(" ".to_string())])
         );
         assert_eq!(
             TextParser::new("  ").parse().unwrap(),
-            Text(vec![Line::Str(" ".to_string())])
+            Text(vec![SubText::Str(" ".to_string())])
         );
         assert_eq!(
             TextParser::new(" \n ").parse().unwrap(),
-            Text(vec![Line::Str(" ".to_string())])
+            Text(vec![SubText::Str(" ".to_string())])
         );
         assert_eq!(
             TextParser::new(" 123 ").parse().unwrap(),
-            Text(vec![Line::Str(" 123 ".to_string())])
+            Text(vec![SubText::Str(" 123 ".to_string())])
         );
         assert_eq!(
             TextParser::new(" \n123\t ").parse().unwrap(),
-            Text(vec![Line::Str(" 123 ".to_string())])
+            Text(vec![SubText::Str(" 123 ".to_string())])
         );
         assert_eq!(
             TextParser::new("123").parse().unwrap(),
-            Text(vec![Line::Str("123".to_string())])
+            Text(vec![SubText::Str("123".to_string())])
         );
     }
 }


Test patch: