Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Two-Face

A modern, high-performance multi-frontend client for GemStone IV


What is Two-Face?

Two-Face is a feature-rich terminal client designed specifically for GemStone IV, the legendary text-based MMORPG by Simutronics. Built from the ground up in Rust, Two-Face delivers:

  • 60+ FPS rendering with sub-millisecond event processing
  • Fully customizable layouts with pixel-perfect window positioning
  • Rich theming support with 24-bit true color
  • Multiple connection modes (Lich proxy or direct eAccess authentication)
  • Modern TUI built on ratatui with planned GUI support via egui
┌─────────────────────────────────────────────────────────────────────────┐
│                              Two-Face                                    │
│                                                                          │
│  ┌─────────────────────────┐  ┌────────────────────────────────────┐   │
│  │      Main Window        │  │         Room Description           │   │
│  │                         │  │  [Obvious exits: north, east, out] │   │
│  │  A goblin attacks!      │  └────────────────────────────────────┘   │
│  │  > attack goblin        │  ┌──────────┐  ┌──────────┐              │
│  │  You swing at a goblin! │  │ ◄ N ►    │  │ HP: 100% │              │
│  │                         │  │   S      │  │ MP:  87% │              │
│  └─────────────────────────┘  └──────────┘  └──────────┘              │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │ >                                                                │   │
│  └─────────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────┘

Philosophy

Two-Face is built on these core principles:

1. Performance First

Every design decision prioritizes smooth, responsive gameplay. Generation-based change detection, lazy text wrapping, and efficient memory management ensure Two-Face never gets in your way.

2. Customization Without Limits

Your client should look and behave exactly how you want. Every window can be positioned, sized, styled, and configured independently. Create layouts for hunting, merchanting, roleplaying, or anything else.

3. Modern Architecture

A clean separation between Core (game logic), Data (state), and Frontend (rendering) allows for multiple frontends (TUI today, GUI tomorrow) while keeping the codebase maintainable.

4. Developer Friendly

Written in idiomatic Rust with comprehensive documentation. Want to add a new widget type? Extend the parser? Create a custom browser? The architecture supports it.

Feature Highlights

Layouts

Design your perfect interface with TOML-based layout files. Position windows by row/column or pixel coordinates. Nest windows, create tabs, configure borders and colors per-window.

Highlights

Apply colors and styles to game text with regex patterns. Highlight creature names, player speech, spell effects, or anything else. Fast literal matching via Aho-Corasick for high-frequency patterns.

Keybinds

Bind any key combination to game commands, client actions, or macros. Full modifier support (Ctrl, Alt, Shift). Action-based system supports scrolling, navigation, text editing, and custom commands.

Themes

Complete color control with presets, palettes, and per-widget overrides. Ship with dark and light themes, or create your own.

Sound

Audio alerts for game events with configurable triggers. Text-to-speech support for accessibility.

Connection Modes

Connect via Lich for scripting integration, or directly authenticate with eAccess for standalone operation.

Quick Start

# Via Lich (default)
two-face --port 8000

# Direct connection
two-face --direct --account YOUR_ACCOUNT --character CharName

See Installation for detailed setup instructions.

Documentation Structure

This documentation is organized for multiple audiences:

SectionAudiencePurpose
Getting StartedNew usersInstallation, first launch, quick tour
ConfigurationAll usersConfig file reference
WidgetsAll usersWidget types and properties
CustomizationPower usersLayouts, themes, highlights
TutorialsAll usersStep-by-step guides
CookbookPower usersQuick recipes for specific tasks
ArchitectureDevelopersSystem design and internals
DevelopmentContributorsBuilding, testing, contributing
ReferenceAll usersComplete reference tables

Getting Help

License

Two-Face is open source software. See the repository for license details.


Ready to dive in? Start with Installation.

Getting Started

Welcome to Two-Face! This section will guide you from installation to your first game session.

Overview

Getting Two-Face running involves three steps:

  1. Installation - Download or build Two-Face for your platform
  2. First Launch - Connect to GemStone IV
  3. Quick Tour - Learn the essential controls

Prerequisites

Before you begin, ensure you have:

  • A GemStone IV Account: Create one at play.net
  • A Modern Terminal: Windows Terminal, iTerm2, or any terminal with 256+ color support
  • Optional: Lich: For scripting support, install Lich

Connection Modes

Two-Face offers two ways to connect:

# Start Lich with your character, then:
two-face --port 8000
  • Full Lich script compatibility
  • Scripting and automation support
  • Most users choose this option

Direct Mode (Standalone)

two-face --direct --account YOUR_ACCOUNT --character CharName --game prime
  • No Lich dependency
  • Authenticates directly with eAccess
  • Ideal for lightweight setups

What’s Next?

PageDescription
InstallationPlatform-specific setup instructions
First LaunchConnecting and initial configuration
Quick TourEssential keyboard shortcuts and navigation
UpgradingUpdating to new versions

Ready to install? Continue to Installation.

Installation

This guide covers installing Two-Face on all supported platforms.

Quick Install

Pre-built Binaries

Download the latest release for your platform from GitHub Releases:

PlatformDownload
Windows (x64)two-face-windows-x64.zip
Linux (x64)two-face-linux-x64.tar.gz
macOS (Intel)two-face-macos-x64.tar.gz
macOS (Apple Silicon)two-face-macos-arm64.tar.gz

Extract and Run

Windows:

# Extract to a folder
Expand-Archive two-face-windows-x64.zip -DestinationPath C:\two-face

# Run
C:\two-face\two-face.exe

Linux/macOS:

# Extract
tar xzf two-face-linux-x64.tar.gz

# Make executable
chmod +x two-face

# Run
./two-face

Building from Source

For the latest features or unsupported platforms, build from source.

Prerequisites

  1. Rust Toolchain (1.70+)

    curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
    
  2. Platform-Specific Dependencies

    Windows:

    • Visual Studio Build Tools
    • vcpkg with OpenSSL:
      git clone https://github.com/microsoft/vcpkg.git C:\vcpkg
      cd C:\vcpkg
      .\bootstrap-vcpkg.bat
      .\vcpkg install openssl:x64-windows
      $env:VCPKG_ROOT = "C:\vcpkg"
      

    Linux (Debian/Ubuntu):

    sudo apt install build-essential pkg-config libssl-dev libasound2-dev
    

    Linux (Fedora/RHEL):

    sudo dnf install gcc pkg-config openssl-devel alsa-lib-devel
    

    macOS:

    xcode-select --install
    # OpenSSL usually provided by system
    

Build Steps

# Clone repository
git clone https://github.com/nisugi/two-face.git
cd two-face

# Build release version
cargo build --release

# Binary is at target/release/two-face

Build Options

# Build without sound support (smaller binary)
cargo build --release --no-default-features

# Build with all features
cargo build --release --features sound

Data Directory

Two-Face stores configuration in a data directory:

PlatformDefault Location
Windows%USERPROFILE%\.two-face\
Linux~/.two-face/
macOS~/.two-face/

Structure

~/.two-face/
├── config.toml          # Main configuration
├── layout.toml          # Window layout
├── keybinds.toml        # Key bindings
├── highlights.toml      # Text highlighting
├── colors.toml          # Color theme
├── simu.pem             # eAccess certificate (auto-downloaded)
├── two-face.log         # Debug log
└── profiles/            # Per-character profiles
    └── CharName/
        ├── layout.toml
        └── highlights.toml

Custom Data Directory

Override the default location:

# Via command line
two-face --data-dir /path/to/custom/dir

# Via environment variable
export TWO_FACE_DIR=/path/to/custom/dir
two-face

Verifying Installation

  1. Check version:

    two-face --version
    
  2. View help:

    two-face --help
    
  3. Test terminal colors: Launch Two-Face and check if colors display correctly. If colors look wrong:

    # Set truecolor support
    export COLORTERM=truecolor
    two-face
    

Troubleshooting Installation

Windows: “vcruntime140.dll not found”

Install the Visual C++ Redistributable.

Windows: OpenSSL errors

Ensure VCPKG_ROOT is set and OpenSSL is installed via vcpkg.

Linux: “libasound.so not found”

Install ALSA library:

# Debian/Ubuntu
sudo apt install libasound2

# Fedora
sudo dnf install alsa-lib

macOS: “cannot be opened because the developer cannot be verified”

Right-click the binary, select “Open”, then click “Open” in the dialog.

All Platforms: Colors not working

  1. Use a modern terminal (Windows Terminal, iTerm2, etc.)
  2. Set COLORTERM=truecolor environment variable
  3. Ensure your terminal supports 24-bit color

Next Steps

With Two-Face installed, continue to First Launch to connect to the game.


See Also

First Launch

This guide walks you through connecting to GemStone IV for the first time.

Choose Your Connection Mode

Two-Face supports two connection methods:

ModeBest ForRequirements
Lich ProxyScript users, most playersLich running
DirectStandalone use, lightweightAccount credentials

Option A: Lich Proxy Mode

Step 1: Start Lich

Launch Lich with your character. If you’re new to Lich, see the Lich documentation.

# Example Lich launch (varies by setup)
ruby lich.rb --login CharacterName

Lich will start listening on a port (default: 8000).

Step 2: Launch Two-Face

# Connect to Lich on default port
two-face

# Or specify a different port
two-face --port 8001

Step 3: Verify Connection

You should see:

  1. Two-Face window appears
  2. Game output starts flowing
  3. Prompt (“>”) is visible

If the connection fails:


Option B: Direct Mode

Direct mode connects without Lich, authenticating directly with Simutronics.

Step 1: Launch with Credentials

# Minimal command (will prompt for password)
two-face --direct --account YOUR_ACCOUNT --character CharName

# Specify game world
two-face --direct --account YOUR_ACCOUNT --character CharName --game prime

Game Worlds

WorldFlagDescription
Prime--game primeMain GemStone IV server
Platinum--game platinumPremium subscription server
Shattered--game shatteredTest/development server

Step 2: Enter Password

When prompted, enter your account password. The password is not echoed for security.

Password for account YOUR_ACCOUNT: ********

Step 3: Verify Connection

Two-Face will:

  1. Authenticate with eAccess servers
  2. Download/verify the server certificate (first time only)
  3. Connect to the game server
  4. Display game output

Initial Configuration

On first launch, Two-Face creates default configuration files:

~/.two-face/
├── config.toml      # Created with defaults
├── layout.toml      # Default window layout
├── keybinds.toml    # Default keybindings
├── highlights.toml  # Default highlights
└── colors.toml      # Default color theme

Character Profiles

To use per-character settings:

two-face --character CharName

This loads settings from ~/.two-face/profiles/CharName/ if they exist, falling back to defaults.


Essential First Steps

Once connected, try these:

1. Test Commands

Type a game command and press Enter:

look

2. Test Scrolling

Use Page Up/Down or scroll wheel to navigate history.

3. Open the Menu

Press Ctrl+M to open the main menu. Navigate with arrow keys.

4. Check Keybinds

Press Ctrl+? to view current keybindings.


First Launch Checklist

  • Connected successfully
  • Game output appears
  • Commands work
  • Colors display correctly
  • Menu opens with Ctrl+M

Common First-Launch Issues

“Connection refused”

  • Lich mode: Ensure Lich is running and logged in
  • Direct mode: Check internet connection

No output appears

  • Check that you’re connected to the right port
  • Ensure Lich/game is actually sending data

Colors look wrong

  • Set COLORTERM=truecolor in your terminal
  • Use Windows Terminal (not CMD) on Windows

Password not accepted (Direct mode)

  • Verify account name and password
  • Try logging in via the official client first
  • Delete ~/.two-face/simu.pem and retry

Next Steps

Now that you’re connected, take a Quick Tour to learn the essential controls.


See Also

Quick Tour

A 5-minute introduction to Two-Face’s essential controls and features.

The Interface

┌─────────────────────────────────────────────────────────────────────────┐
│ Two-Face v0.1.0                                                    [?]  │
├─────────────────────────────────────────────────────────────────────────┤
│ ┌─ Main ───────────────────────────┐ ┌─ Room ─────────────────────────┐ │
│ │                                  │ │ Town Square Central            │ │
│ │ A goblin attacks you!            │ │ This is the heart of the town. │ │
│ │ You swing your sword at goblin!  │ │ Obvious paths: n, e, s, w, out │ │
│ │ You hit for 45 damage!           │ │                                │ │
│ │ The goblin falls dead!           │ └────────────────────────────────┘ │
│ │                                  │ ┌─ Vitals ───┐ ┌─ Compass ──────┐ │
│ │ >                                │ │ HP ████████│ │     [N]        │ │
│ │                                  │ │ MP ██████░░│ │  [W] + [E]     │ │
│ │                                  │ │ ST ████████│ │     [S]        │ │
│ └──────────────────────────────────┘ └────────────┘ └────────────────┘ │
│ ┌─ Input ──────────────────────────────────────────────────────────────┐│
│ │ > attack goblin                                                      ││
│ └──────────────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────────────┘

Essential Keybinds

KeyAction
Page Up / Page DownScroll main window
Home / EndJump to top/bottom
Scroll WheelScroll focused window
Ctrl+TabCycle through windows
TabFocus next tabbed window

Input

KeyAction
EnterSend command
Up / DownCommand history
Ctrl+CCopy selection
Ctrl+VPaste
EscapeClear input / Cancel
KeyAction
Ctrl+MOpen main menu
Ctrl+HOpen highlight browser
Ctrl+KOpen keybind browser
Ctrl+EOpen window editor
Ctrl+?Show keybind help
EscapeClose popup

Client Control

KeyAction
Ctrl+QQuit Two-Face
F5Reload configuration
Ctrl+LClear main window

Try It Now

1. Send a Command

Type look and press Enter. The room description appears in the main window.

2. Scroll History

Press Page Up to scroll back through game output. Press End to return to the bottom.

3. Use Command History

Press Up Arrow to recall your previous command. Press Down Arrow to go forward.

4. Open the Menu

Press Ctrl+M. Use arrow keys to navigate, Enter to select, Escape to close.

5. Focus a Window

Click on a window or use Ctrl+Tab to cycle focus. The focused window has a highlighted border.


Window Types

Two-Face displays several types of windows:

Text Windows

Scrollable text output (main game feed, thoughts, speech).

Controls:

  • Scroll with Page Up/Down or mouse wheel
  • Select text by clicking and dragging
  • Copy selection with Ctrl+C

Progress Bars

Visual health, mana, stamina, etc.

Display:

HP ████████████████ 100%
MP ██████████░░░░░░  67%

Compass

Shows available exits with directional indicators.

Hands

Displays items in your left/right hands and prepared spell.

Indicators

Status icons for conditions (stunned, hidden, etc.).


The Main Menu

Press Ctrl+M to access:

┌─ Main Menu ────────────────────────┐
│ ► Window Editor                    │
│   Highlight Browser                │
│   Keybind Browser                  │
│   Color Browser                    │
│   ─────────────────────────        │
│   Reload Configuration             │
│   ─────────────────────────        │
│   Quit                             │
└────────────────────────────────────┘
OptionDescription
Window EditorModify window positions, sizes, styles
Highlight BrowserView and edit text highlighting rules
Keybind BrowserView and edit keybindings
Color BrowserView and edit color settings
Reload ConfigurationApply changes from config files
QuitExit Two-Face

Quick Customization

Change a Keybind

  1. Press Ctrl+K to open the keybind browser
  2. Navigate to the keybind you want to change
  3. Press Enter to edit
  4. Press the new key combination
  5. Press Escape to close

Edit Highlights

  1. Press Ctrl+H to open the highlight browser
  2. Find an existing highlight or add a new one
  3. Edit the pattern, colors, or conditions
  4. Changes take effect immediately

Adjust a Window

  1. Press Ctrl+E to open the window editor
  2. Select a window from the list
  3. Modify position, size, border, colors
  4. Press Escape to close (changes auto-save)

Keyboard Reference Card

Print this for quick reference:

┌─────────────────────────────────────────────────────────────┐
│                  TWO-FACE QUICK REFERENCE                    │
├─────────────────────────────────────────────────────────────┤
│ NAVIGATION          │ MENUS              │ INPUT             │
│ PgUp/PgDn  Scroll   │ Ctrl+M  Main Menu  │ Enter   Send      │
│ Home/End   Top/Bot  │ Ctrl+H  Highlights │ Up/Down History   │
│ Ctrl+Tab   Windows  │ Ctrl+K  Keybinds   │ Ctrl+C  Copy      │
│ Tab        Tabs     │ Ctrl+E  Editor     │ Ctrl+V  Paste     │
│                     │ Escape  Close      │ Escape  Clear     │
├─────────────────────┴────────────────────┴───────────────────┤
│ F5 = Reload Config    Ctrl+L = Clear    Ctrl+Q = Quit       │
└─────────────────────────────────────────────────────────────┘

Next Steps

Now that you know the basics:

  1. Customize Your Layout - Make it yours
  2. Set Up Highlights - Color your world
  3. Configure Keybinds - Optimize your workflow
  4. Explore Widgets - Learn all widget types

See Also

Upgrading

How to update Two-Face to newer versions while preserving your configuration.

Before Upgrading

1. Backup Your Configuration

Your configuration files are precious. Back them up:

# Linux/macOS
cp -r ~/.two-face ~/.two-face.backup

# Windows (PowerShell)
Copy-Item -Recurse $env:USERPROFILE\.two-face $env:USERPROFILE\.two-face.backup

2. Check the Changelog

Review Version History for:

  • Breaking changes
  • New features
  • Deprecated settings
  • Migration requirements

Upgrade Methods

Pre-built Binaries

  1. Download the new version from GitHub Releases
  2. Replace the old binary with the new one
  3. Launch Two-Face

Your configuration files in ~/.two-face/ are preserved automatically.

Building from Source

cd two-face

# Pull latest changes
git pull origin master

# Rebuild
cargo build --release

# New binary is at target/release/two-face

Configuration Compatibility

Automatic Migration

Two-Face attempts to handle configuration changes automatically:

  • Missing keys use default values
  • Deprecated keys are ignored with warnings
  • New features are disabled until configured

Manual Migration

For breaking changes, you may need to update config files manually. Check the version-specific notes below.


Version-Specific Upgrade Notes

v0.1.x → v0.2.x

No breaking changes expected during alpha.

Future Versions

Check Version History for detailed migration guides when released.


Troubleshooting Upgrades

Config file errors after upgrade

  1. Check the error message for the problematic key
  2. Compare your config with the default files
  3. Update or remove the problematic setting

Reset to defaults

If needed, reset configuration:

# Backup first!
cp -r ~/.two-face ~/.two-face.backup

# Remove old config (keeps profiles)
rm ~/.two-face/*.toml

# Launch Two-Face to regenerate defaults
two-face

Partial reset

Reset specific files:

# Reset just keybinds
rm ~/.two-face/keybinds.toml
two-face  # Regenerates default keybinds

Downgrading

To revert to an older version:

  1. Download the older release
  2. Replace the binary
  3. Restore your backed-up configuration if needed

Note: Config files from newer versions may not work with older versions. Use your backup.


See Also

Configuration

Two-Face uses TOML files for all configuration. This section provides complete documentation for each configuration file.

Configuration Files

Two-Face loads configuration from these files in your data directory (~/.two-face/):

FilePurposeHot Reload
config.tomlMain settings, connection, behaviorYes
layout.tomlWindow positions, sizes, arrangementYes
keybinds.tomlKeyboard shortcuts and actionsYes
highlights.tomlText highlighting patternsYes
colors.tomlColor theme, presets, palettesYes

Hot Reload: Press F5 to reload all configuration without restarting.


File Locations

Default Location

PlatformPath
Windows%USERPROFILE%\.two-face\
Linux~/.two-face/
macOS~/.two-face/

Custom Location

Override with --data-dir or TWO_FACE_DIR:

# Command line
two-face --data-dir /custom/path

# Environment variable
export TWO_FACE_DIR=/custom/path

Character Profiles

Per-character configuration lives in ~/.two-face/profiles/<CharName>/:

~/.two-face/
├── config.toml           # Global defaults
├── layout.toml
├── keybinds.toml
├── highlights.toml
├── colors.toml
└── profiles/
    ├── Warrior/          # Character-specific overrides
    │   ├── layout.toml
    │   └── highlights.toml
    └── Wizard/
        ├── layout.toml
        └── highlights.toml

Loading Order

  1. Load global defaults from ~/.two-face/
  2. If --character specified, overlay from profiles/<CharName>/
  3. Character files only need to contain overrides, not complete configs

Usage

# Load Warrior's profile
two-face --character Warrior

Configuration Syntax

All config files use TOML syntax:

# Comments start with #

# Simple key-value
setting = "value"
number = 42
enabled = true

# Nested tables
[section]
key = "value"

# Inline tables
point = { x = 10, y = 20 }

# Arrays
list = ["one", "two", "three"]

# Array of tables
[[items]]
name = "first"

[[items]]
name = "second"

Color Values

Colors can be specified as:

# Hex RGB
color = "#FF5500"

# Hex RRGGBB (same as above)
color = "#ff5500"

# Named color (from palette)
color = "bright_red"

# Preset reference
color = "@speech"  # Uses the 'speech' preset color

Size Values

# Fixed pixels
width = 40

# Percentage of parent
width = "50%"

Default Files

Two-Face ships with sensible defaults embedded in the binary. On first run, these are written to your data directory if the files don’t exist.

See Default Files Reference for complete default content.


Validation

Two-Face validates configuration on load:

  • Errors: Invalid syntax or required fields prevent loading
  • Warnings: Unknown keys or deprecated settings log warnings but allow loading
  • Missing files: Created from defaults

Check ~/.two-face/two-face.log for configuration warnings.


Quick Reference

Most Common Settings

SettingFileKey
Connection portconfig.tomlconnection.port
Default characterconfig.tomlconnection.character
Main window sizelayout.toml[[windows]] with name = "main"
Monsterbold colorcolors.toml[presets.monsterbold]
Attack keybindkeybinds.tomlEntry with action = "send"

Reload Configuration

  • F5: Reload all config files
  • Ctrl+M → Reload: Same via menu
  • Restart: Always picks up changes

File Reference

PageContents
config.tomlConnection, sound, TTS, general behavior
layout.tomlWindow definitions, positions, sizes
keybinds.tomlKey mappings, actions, modifiers
highlights.tomlPattern matching, colors, conditions
colors.tomlTheme colors, presets, UI styling
profiles.mdPer-character configuration

See Also

config.toml Reference

The main configuration file controlling Two-Face’s behavior, connection settings, and general preferences.

Location

~/.two-face/config.toml


Complete Reference

#
# Two-Face Configuration
#

[connection]
# Default host for Lich proxy mode
host = "127.0.0.1"

# Default port for Lich proxy mode
port = 8000

# Default character name (used for profile loading)
character = ""

# Timeout for connection attempts (seconds)
timeout = 30

[interface]
# Enable clickable links in game text
links = true

# Show window borders by default
show_borders = true

# Default border style: "plain", "rounded", "double", "thick"
border_style = "rounded"

# Mouse support
mouse = true

# Scroll speed (lines per scroll event)
scroll_speed = 3

# Command history size
history_size = 1000

[sound]
# Enable sound effects
enabled = true

# Master volume (0.0 - 1.0)
volume = 0.8

# Play startup music
startup_music = true

# Sound file paths (relative to data dir or absolute)
# alert_sound = "sounds/alert.wav"

[tts]
# Enable text-to-speech
enabled = false

# TTS rate (words per minute, platform dependent)
rate = 150

# Which streams to speak
streams = ["main"]

# Speak room descriptions
speak_rooms = true

# Speak player speech
speak_speech = true

[logging]
# Log level: "error", "warn", "info", "debug", "trace"
level = "warn"

# Log file path (relative to data dir)
file = "two-face.log"

# Log to console (for debugging)
console = false

[behavior]
# Auto-scroll to bottom on new text
auto_scroll = true

# Flash window on important events
flash_on_alert = false

# Notification sound on important events
sound_on_alert = false

# Pause input during roundtime (experimental)
pause_on_rt = false

[performance]
# Maximum lines to buffer per text window
max_buffer_lines = 2000

# Frame rate target
target_fps = 60

# Enable performance metrics collection
collect_metrics = true

Section Details

[connection]

Controls how Two-Face connects to the game.

KeyTypeDefaultDescription
hoststring"127.0.0.1"Host for Lich proxy mode
portinteger8000Port for Lich proxy mode
characterstring""Default character name for profiles
timeoutinteger30Connection timeout in seconds

Note: Direct mode (--direct) ignores these settings and uses command-line credentials.

[interface]

Visual and interaction settings.

KeyTypeDefaultDescription
linksbooleantrueEnable clickable game links
show_bordersbooleantrueShow window borders by default
border_stylestring"rounded"Default border style
mousebooleantrueEnable mouse support
scroll_speedinteger3Lines scrolled per mouse wheel tick
history_sizeinteger1000Command history entries to remember

Border styles:

  • "plain" - Single line: ─│─│┌┐└┘
  • "rounded" - Rounded corners: ─│─│╭╮╰╯
  • "double" - Double line: ═║═║╔╗╚╝
  • "thick" - Thick line: ━┃━┃┏┓┗┛

[sound]

Audio settings.

KeyTypeDefaultDescription
enabledbooleantrueMaster sound toggle
volumefloat0.8Master volume (0.0 to 1.0)
startup_musicbooleantruePlay music on launch

Note: Requires the sound feature to be compiled in.

[tts]

Text-to-speech accessibility features.

KeyTypeDefaultDescription
enabledbooleanfalseEnable TTS
rateinteger150Speech rate (WPM)
streamsarray["main"]Streams to speak
speak_roomsbooleantrueSpeak room descriptions
speak_speechbooleantrueSpeak player dialogue

Supported streams: "main", "speech", "thoughts", "combat"

[logging]

Diagnostic logging configuration.

KeyTypeDefaultDescription
levelstring"warn"Minimum log level
filestring"two-face.log"Log file path
consolebooleanfalseAlso log to console

Log levels (from least to most verbose):

  • "error" - Only errors
  • "warn" - Errors and warnings
  • "info" - Normal operation info
  • "debug" - Debugging details
  • "trace" - Very verbose tracing

[behavior]

Client behavior tweaks.

KeyTypeDefaultDescription
auto_scrollbooleantrueAuto-scroll on new text
flash_on_alertbooleanfalseFlash window on alerts
sound_on_alertbooleanfalsePlay sound on alerts
pause_on_rtbooleanfalsePause input during RT

[performance]

Performance tuning.

KeyTypeDefaultDescription
max_buffer_linesinteger2000Max lines per text window
target_fpsinteger60Target frame rate
collect_metricsbooleantrueEnable performance stats

Examples

Minimal Config

[connection]
port = 8000
character = "MyCharacter"

High-Performance Config

[performance]
max_buffer_lines = 1000
target_fps = 120
collect_metrics = false

[interface]
links = false  # Disable link parsing for speed

Accessibility Config

[tts]
enabled = true
rate = 175
streams = ["main", "speech"]
speak_rooms = true
speak_speech = true

[interface]
scroll_speed = 5

Environment Variable Overrides

Some settings can be overridden via environment variables:

VariableOverrides
TWO_FACE_DIRData directory location
RUST_LOGLogging level
COLORTERMTerminal color support

See Also

layout.toml Reference

The layout configuration file defines all windows, their positions, sizes, and visual properties.

Location

~/.two-face/layout.toml


Structure Overview

# Global layout settings
[layout]
columns = 120
rows = 40

# Window definitions
[[windows]]
name = "main"
type = "text"
# ... window properties

[[windows]]
name = "room"
type = "room"
# ... window properties

[layout] Section

Global layout dimensions and defaults.

[layout]
# Terminal grid size
columns = 120        # Total columns
rows = 40            # Total rows

# Default window settings
default_border = true
default_border_style = "rounded"
KeyTypeDefaultDescription
columnsinteger120Layout width in columns
rowsinteger40Layout height in rows
default_borderbooleantrueDefault border visibility
default_border_stylestring"rounded"Default border style

[[windows]] Array

Each [[windows]] entry defines a single window.

Common Properties

These properties apply to all window types:

[[windows]]
# Identity
name = "main"              # Unique identifier (required)
type = "text"              # Widget type (required)

# Position (choose one method)
row = 0                    # Row position
col = 0                    # Column position
# OR
x = 0                      # Pixel X position
y = 0                      # Pixel Y position

# Size
width = 80                 # Width in columns
height = 30                # Height in rows
# OR percentage
width = "60%"              # Percentage of parent width
height = "80%"             # Percentage of parent height

# Visual
show_border = true         # Show window border
border_style = "rounded"   # Border style
border_sides = "all"       # Which sides have borders
show_title = true          # Show title bar
title = "Main Window"      # Custom title (default: name)
transparent_background = false  # Transparent background

# Colors (override theme)
border_color = "#5588AA"   # Border color
background_color = "#000000"  # Background color
text_color = "#CCCCCC"     # Text color

Position Properties

KeyTypeDescription
rowintegerRow position (0 = top)
colintegerColumn position (0 = left)
xintegerPixel X position
yintegerPixel Y position

Note: Use row/col for grid-based layouts, x/y for pixel-precise positioning.

Size Properties

KeyTypeDescription
widthinteger or stringWidth in columns or percentage
heightinteger or stringHeight in rows or percentage

Percentage sizing:

width = "50%"    # 50% of layout width
height = "100%"  # Full layout height

Border Properties

KeyTypeDefaultDescription
show_borderbooleantrueShow border
border_stylestring"rounded"Border style
border_sidesstring"all"Which sides
border_colorstringthemeBorder color

Border sides:

  • "all" - All four sides
  • "none" - No borders
  • "top" - Top only
  • "bottom" - Bottom only
  • "left" - Left only
  • "right" - Right only
  • "top,bottom" - Specific sides (comma-separated)
  • "horizontal" - Top and bottom
  • "vertical" - Left and right

Window Types

Text Window

Scrollable text display for game output.

[[windows]]
name = "main"
type = "text"
row = 0
col = 0
width = 80
height = 30

# Text-specific options
stream = "main"           # Game stream to display
buffer_size = 2000        # Maximum lines to buffer
word_wrap = true          # Enable word wrapping
KeyTypeDefaultDescription
streamstringsame as nameGame stream to display
buffer_sizeinteger2000Max buffered lines
word_wrapbooleantrueEnable word wrap

Common streams: main, speech, thoughts, combat, death, logons, familiar, group

Tabbed Text Window

Multiple streams in tabbed interface.

[[windows]]
name = "channels"
type = "tabbed_text"
row = 0
col = 80
width = 40
height = 30

# Tab configuration
tabs = ["speech", "thoughts", "combat"]
default_tab = "speech"
show_tab_bar = true
KeyTypeDefaultDescription
tabsarray[]List of stream names
default_tabstringfirst tabInitially active tab
show_tab_barbooleantrueShow tab headers

Command Input

Text input field for commands.

[[windows]]
name = "input"
type = "command_input"
row = 38
col = 0
width = 120
height = 2

# Input-specific options
prompt = "> "
history = true
KeyTypeDefaultDescription
promptstring"> "Input prompt
historybooleantrueEnable command history

Progress Bar

Displays a value as a filled bar.

[[windows]]
name = "health"
type = "progress"
row = 0
col = 80
width = 20
height = 1

# Progress-specific options
stat = "health"           # Stat to track
show_value = true         # Show "425/500"
show_percentage = false   # Show "85%"
bar_color = "#00FF00"     # Bar fill color
KeyTypeDefaultDescription
statstringrequiredStat ID (health, mana, etc.)
show_valuebooleantrueShow numeric value
show_percentagebooleanfalseShow percentage
bar_colorstringthemeBar fill color

Stat IDs: health, mana, spirit, stamina, encumbrance

Countdown

Countdown timer display.

[[windows]]
name = "roundtime"
type = "countdown"
row = 2
col = 80
width = 20
height = 1

# Countdown-specific options
countdown_type = "roundtime"  # "roundtime" or "casttime"
show_seconds = true
icon = "⏱"
KeyTypeDefaultDescription
countdown_typestringrequiredroundtime or casttime
show_secondsbooleantrueShow remaining seconds
iconstring""Icon to display

Compass

Directional compass display.

[[windows]]
name = "compass"
type = "compass"
row = 5
col = 100
width = 10
height = 5

# Compass-specific options
style = "graphical"       # "graphical" or "text"
show_labels = true        # Show N/S/E/W labels
KeyTypeDefaultDescription
stylestring"graphical"Display style
show_labelsbooleantrueShow direction labels

Hand

Left/right hand or spell display.

[[windows]]
name = "right_hand"
type = "hand"
row = 10
col = 100
width = 15
height = 1

# Hand-specific options
hand_type = "right"       # "left", "right", or "spell"
icon = "🤚"
KeyTypeDefaultDescription
hand_typestringrequiredleft, right, or spell
iconstring""Icon to display

Indicator

Status indicator widget.

[[windows]]
name = "status"
type = "indicator"
row = 12
col = 100
width = 20
height = 1

# Indicator-specific options
indicators = ["stunned", "hidden", "poisoned"]
style = "icons"           # "icons" or "text"
KeyTypeDefaultDescription
indicatorsarrayallWhich indicators to show
stylestring"icons"Display style

Indicator IDs: stunned, hidden, webbed, poisoned, diseased, bleeding, prone, kneeling, sitting

Room Window

Room description display.

[[windows]]
name = "room"
type = "room"
row = 0
col = 80
width = 40
height = 10

# Room-specific options
show_title = true         # Show room name
show_description = true   # Show room desc
show_exits = true         # Show obvious exits
show_objects = true       # Show items/creatures
show_players = true       # Show other players

Injury Doll

Body injury display.

[[windows]]
name = "injuries"
type = "injury_doll"
row = 15
col = 100
width = 15
height = 10

# Injury-specific options
show_labels = true
compact = false

Performance Monitor

Performance metrics display.

[[windows]]
name = "performance"
type = "performance"
row = 0
col = 0
width = 30
height = 15

# Which metrics to show
show_fps = true
show_frame_times = true
show_render_times = true
show_net = true
show_parse = true
show_memory = true
show_lines = true
show_uptime = true

Layout Examples

Basic Two-Column Layout

[layout]
columns = 120
rows = 40

[[windows]]
name = "main"
type = "text"
row = 0
col = 0
width = 80
height = 38

[[windows]]
name = "room"
type = "room"
row = 0
col = 80
width = 40
height = 15

[[windows]]
name = "vitals"
type = "progress"
stat = "health"
row = 15
col = 80
width = 40
height = 1

[[windows]]
name = "input"
type = "command_input"
row = 38
col = 0
width = 120
height = 2

Hunting Layout

See Hunting Setup Tutorial for a complete example.

Minimal Layout

[layout]
columns = 80
rows = 24

[[windows]]
name = "main"
type = "text"
row = 0
col = 0
width = 80
height = 22
show_border = false

[[windows]]
name = "input"
type = "command_input"
row = 22
col = 0
width = 80
height = 2
show_border = false

See Also

keybinds.toml Reference

The keybinds configuration file maps keyboard shortcuts to actions.

Location

~/.two-face/keybinds.toml


Structure Overview

[[keybinds]]
key = "Enter"
action = "send"

[[keybinds]]
key = "Ctrl+Q"
action = "quit"

[[keybinds]]
key = "F1"
action = "send"
argument = "look"

Keybind Properties

Each [[keybinds]] entry defines one key mapping:

[[keybinds]]
# Required
key = "Ctrl+S"            # Key combination
action = "send"           # Action to perform

# Optional
argument = "save"         # Action argument
description = "Save game" # Description for help
context = "input"         # When this binding is active
PropertyTypeRequiredDescription
keystringyesKey combination
actionstringyesAction name
argumentstringnoAction argument
descriptionstringnoHelp text
contextstringnoActive context

Key Syntax

Basic Keys

key = "A"         # Letter A
key = "1"         # Number 1
key = "F1"        # Function key
key = "Enter"     # Enter key
key = "Space"     # Space bar
key = "Tab"       # Tab key
key = "Escape"    # Escape key

Modifier Keys

Combine modifiers with +:

key = "Ctrl+S"          # Ctrl + S
key = "Alt+F4"          # Alt + F4
key = "Shift+Tab"       # Shift + Tab
key = "Ctrl+Shift+Z"    # Ctrl + Shift + Z
key = "Ctrl+Alt+Delete" # Multiple modifiers

Available modifiers:

  • Ctrl - Control key
  • Alt - Alt key
  • Shift - Shift key

Special Keys

Key NameDescription
EnterEnter/Return
SpaceSpace bar
TabTab key
EscapeEscape key
BackspaceBackspace
DeleteDelete key
InsertInsert key
HomeHome key
EndEnd key
PageUpPage Up
PageDownPage Down
UpUp arrow
DownDown arrow
LeftLeft arrow
RightRight arrow
F1 - F12Function keys

Numpad Keys

key = "Numpad0"   # Numpad 0
key = "Numpad+"   # Numpad plus
key = "Numpad*"   # Numpad multiply
key = "NumpadEnter"  # Numpad enter

Actions

Input Actions

ActionArgumentDescription
sendcommandSend command to game
send_silentcommandSend without echo
inserttextInsert text at cursor
clear_input-Clear input field
history_prev-Previous command
history_next-Next command

Examples:

[[keybinds]]
key = "Enter"
action = "send"

[[keybinds]]
key = "F1"
action = "send"
argument = "attack"

[[keybinds]]
key = "Ctrl+K"
action = "clear_input"
ActionArgumentDescription
scroll_uplinesScroll up
scroll_downlinesScroll down
scroll_page_up-Page up
scroll_page_down-Page down
scroll_top-Jump to top
scroll_bottom-Jump to bottom
focus_next-Focus next window
focus_prev-Focus previous window
focus_windownameFocus specific window

Examples:

[[keybinds]]
key = "PageUp"
action = "scroll_page_up"

[[keybinds]]
key = "Ctrl+Tab"
action = "focus_next"

[[keybinds]]
key = "Ctrl+1"
action = "focus_window"
argument = "main"
ActionArgumentDescription
open_menu-Open main menu
open_highlight_browser-Open highlight editor
open_keybind_browser-Open keybind editor
open_color_browser-Open color editor
open_window_editor-Open window editor
close_popup-Close current popup

Examples:

[[keybinds]]
key = "Ctrl+M"
action = "open_menu"

[[keybinds]]
key = "Ctrl+H"
action = "open_highlight_browser"

[[keybinds]]
key = "Escape"
action = "close_popup"

Client Actions

ActionArgumentDescription
quit-Exit Two-Face
reload_config-Reload configuration
clear_windownameClear window content
toggle_links-Toggle clickable links
toggle_bordernameToggle window border
copy-Copy selection
paste-Paste clipboard

Examples:

[[keybinds]]
key = "Ctrl+Q"
action = "quit"

[[keybinds]]
key = "F5"
action = "reload_config"

[[keybinds]]
key = "Ctrl+L"
action = "clear_window"
argument = "main"

Game Actions

ActionArgumentDescription
movedirectionMove in direction
look-Look around
inventory-Check inventory

Examples:

[[keybinds]]
key = "Numpad8"
action = "move"
argument = "north"

[[keybinds]]
key = "Numpad2"
action = "move"
argument = "south"

Contexts

Keybinds can be limited to specific contexts:

ContextActive When
globalAlways (default)
inputInput field focused
menuMenu/popup open
browserBrowser popup open
editorEditor popup open

Examples:

# Only active when input is focused
[[keybinds]]
key = "Tab"
action = "autocomplete"
context = "input"

# Only active in menus
[[keybinds]]
key = "Enter"
action = "select"
context = "menu"

Default Keybinds

Two-Face ships with these defaults:

[[keybinds]]
key = "PageUp"
action = "scroll_page_up"

[[keybinds]]
key = "PageDown"
action = "scroll_page_down"

[[keybinds]]
key = "Home"
action = "scroll_top"

[[keybinds]]
key = "End"
action = "scroll_bottom"

[[keybinds]]
key = "Ctrl+Tab"
action = "focus_next"

Input

[[keybinds]]
key = "Enter"
action = "send"

[[keybinds]]
key = "Up"
action = "history_prev"

[[keybinds]]
key = "Down"
action = "history_next"

[[keybinds]]
key = "Ctrl+C"
action = "copy"

[[keybinds]]
key = "Ctrl+V"
action = "paste"
[[keybinds]]
key = "Ctrl+M"
action = "open_menu"

[[keybinds]]
key = "Ctrl+H"
action = "open_highlight_browser"

[[keybinds]]
key = "Ctrl+K"
action = "open_keybind_browser"

[[keybinds]]
key = "Ctrl+E"
action = "open_window_editor"

[[keybinds]]
key = "Escape"
action = "close_popup"

Client

[[keybinds]]
key = "Ctrl+Q"
action = "quit"

[[keybinds]]
key = "F5"
action = "reload_config"

[[keybinds]]
key = "Ctrl+L"
action = "clear_window"
argument = "main"

Example Configurations

Numpad Movement

[[keybinds]]
key = "Numpad8"
action = "send"
argument = "north"
description = "Move north"

[[keybinds]]
key = "Numpad2"
action = "send"
argument = "south"

[[keybinds]]
key = "Numpad4"
action = "send"
argument = "west"

[[keybinds]]
key = "Numpad6"
action = "send"
argument = "east"

[[keybinds]]
key = "Numpad7"
action = "send"
argument = "northwest"

[[keybinds]]
key = "Numpad9"
action = "send"
argument = "northeast"

[[keybinds]]
key = "Numpad1"
action = "send"
argument = "southwest"

[[keybinds]]
key = "Numpad3"
action = "send"
argument = "southeast"

[[keybinds]]
key = "Numpad5"
action = "send"
argument = "out"

Combat Macros

[[keybinds]]
key = "F1"
action = "send"
argument = "attack"
description = "Basic attack"

[[keybinds]]
key = "F2"
action = "send"
argument = "incant 101"
description = "Cast Spirit Warding I"

[[keybinds]]
key = "F3"
action = "send"
argument = "stance defensive"
description = "Defensive stance"

See Also

highlights.toml Reference

The highlights configuration file defines text patterns and their visual styling.

Location

~/.two-face/highlights.toml


Structure Overview

[[highlights]]
name = "creatures"
pattern = "(goblin|orc|troll)"
fg = "#FF0000"
bold = true

[[highlights]]
name = "player_speech"
pattern = '^\w+ (says|asks|exclaims),'
fg = "#00FF00"
stream = "main"

Highlight Properties

Each [[highlights]] entry defines one highlighting rule:

[[highlights]]
# Identity
name = "my_highlight"       # Unique name (required)
pattern = "text to match"   # Regex pattern (required)

# Colors
fg = "#FF0000"              # Foreground color
bg = "#000000"              # Background color

# Styling
bold = false                # Bold text
italic = false              # Italic text
underline = false           # Underlined text

# Scope
stream = "main"             # Limit to stream (optional)
span_type = "Normal"        # Only match span type (optional)

# Performance
fast_parse = false          # Use literal matching
enabled = true              # Enable/disable
priority = 0                # Match priority (higher = first)

Required Properties

PropertyTypeDescription
namestringUnique identifier
patternstringRegex pattern to match

Color Properties

PropertyTypeDefaultDescription
fgstring-Foreground color
bgstring-Background color

Color formats:

fg = "#FF5500"        # Hex RGB
fg = "bright_red"     # Palette name
fg = "@speech"        # Preset reference

Style Properties

PropertyTypeDefaultDescription
boldbooleanfalseBold text
italicbooleanfalseItalic text
underlinebooleanfalseUnderline text

Scope Properties

PropertyTypeDefaultDescription
streamstringallLimit to specific stream
span_typestringallOnly match span type

Stream values: main, speech, thoughts, combat, etc.

Span types:

  • Normal - Regular text
  • Link - Clickable links
  • Monsterbold - Creature names
  • Speech - Player dialogue
  • Spell - Spell names

Performance Properties

PropertyTypeDefaultDescription
fast_parsebooleanfalseUse Aho-Corasick
enabledbooleantrueEnable this highlight
priorityinteger0Match order (higher first)

Pattern Syntax

Highlights use Rust regex syntax. Key features:

Basic Patterns

# Literal text
pattern = "goblin"

# Case insensitive
pattern = "(?i)goblin"

# Word boundaries
pattern = "\\bgoblin\\b"

# Multiple words
pattern = "goblin|orc|troll"

Character Classes

# Any digit
pattern = "\\d+"

# Any word character
pattern = "\\w+"

# Any whitespace
pattern = "\\s+"

# Custom class
pattern = "[A-Z][a-z]+"

Anchors

# Start of line
pattern = "^You attack"

# End of line
pattern = "falls dead!$"

# Word boundary
pattern = "\\bgoblin\\b"

Groups and Captures

# Non-capturing group
pattern = "(?:goblin|orc)"

# Capturing group (for future use)
pattern = "(\\w+) says,"

Escaping Special Characters

These characters must be escaped with \\:

. * + ? ^ $ { } [ ] ( ) | \
# Match literal period
pattern = "Mr\\. Smith"

# Match literal asterisk
pattern = "\\*\\*emphasis\\*\\*"

Fast Parse Mode

For simple literal patterns, fast_parse = true uses Aho-Corasick algorithm:

[[highlights]]
name = "names"
pattern = "Bob|Alice|Charlie"
fg = "#FFFF00"
fast_parse = true

When to use fast_parse:

  • Pattern is literal strings (no regex)
  • Pattern has many alternatives (names, items)
  • High-frequency matching needed

When NOT to use fast_parse:

  • Pattern uses regex features
  • Pattern needs word boundaries
  • Pattern uses anchors (^, $)

Priority System

When multiple highlights match, priority determines which wins:

# Higher priority wins
[[highlights]]
name = "important"
pattern = "critical"
fg = "#FF0000"
priority = 100

# Lower priority, same match
[[highlights]]
name = "general"
pattern = ".*"
fg = "#808080"
priority = 0

Default priorities:

  • Monsterbold spans: Highest
  • Link spans: High
  • User highlights: Medium (configurable)
  • Preset colors: Low

Stream Filtering

Limit highlights to specific streams:

# Only in main window
[[highlights]]
name = "combat"
pattern = "You (hit|miss)"
fg = "#FF0000"
stream = "main"

# Only in thoughts
[[highlights]]
name = "esp"
pattern = "\\[ESP\\]"
fg = "#00FFFF"
stream = "thoughts"

Common Patterns

Creature Names

[[highlights]]
name = "creatures"
pattern = "(?i)\\b(goblin|orc|troll|kobold|giant)s?\\b"
fg = "#FF6600"
bold = true

Player Speech

[[highlights]]
name = "speech"
pattern = '^(\\w+) (says|asks|exclaims|whispers),'
fg = "#00FF00"

Your Character Name

[[highlights]]
name = "my_name"
pattern = "\\bYourCharacterName\\b"
fg = "#FFFF00"
bold = true

Damage Numbers

[[highlights]]
name = "damage"
pattern = "\\b\\d+\\s+points?\\s+of\\s+damage"
fg = "#FF0000"
bold = true

Silver/Gold

[[highlights]]
name = "silver"
pattern = "\\d+\\s+silver"
fg = "#C0C0C0"
bold = true

[[highlights]]
name = "gold"
pattern = "\\d+\\s+gold"
fg = "#FFD700"
bold = true

Room Exits

[[highlights]]
name = "exits"
pattern = "Obvious (paths|exits):"
fg = "#00FFFF"

ESP/Thoughts

[[highlights]]
name = "esp"
pattern = "^\\[.*?\\]"
fg = "#FF00FF"
stream = "thoughts"

Complete Example

# Two-Face Highlights Configuration

# Creature highlighting
[[highlights]]
name = "creatures"
pattern = "(?i)\\b(goblin|orc|troll|kobold|giant rat|wolf|bear)s?\\b"
fg = "#FF6600"
bold = true
priority = 50

# Player names (customize this list)
[[highlights]]
name = "friends"
pattern = "\\b(Alice|Bob|Charlie)\\b"
fg = "#00FF00"
fast_parse = true
priority = 60

# Damage taken
[[highlights]]
name = "damage_taken"
pattern = "strikes you|hits you|bites you"
fg = "#FF0000"
bold = true
priority = 70

# Damage dealt
[[highlights]]
name = "damage_dealt"
pattern = "You (hit|strike|slash)"
fg = "#00FF00"
priority = 70

# Currency
[[highlights]]
name = "silver"
pattern = "\\d+\\s+silvers?"
fg = "#C0C0C0"

[[highlights]]
name = "gold"
pattern = "\\b(gold|golden)\\b"
fg = "#FFD700"

# Important messages
[[highlights]]
name = "roundtime"
pattern = "Roundtime:"
fg = "#FF00FF"
bold = true

[[highlights]]
name = "death"
pattern = "You have died|DEAD"
fg = "#FF0000"
bg = "#400000"
bold = true
priority = 100

See Also

colors.toml Reference

The colors configuration file defines the visual theme including presets, palettes, and UI colors.

Location

~/.two-face/colors.toml


Structure Overview

# Named presets (game text styling)
[presets]
speech = { fg = "#53a684" }
monsterbold = { fg = "#a29900" }

# Color palette (named colors)
[palette]
bright_red = "#FF5555"
bright_green = "#55FF55"

# UI element colors
[ui]
border = "#5588AA"
background = "#000000"

# Prompt colors
[prompt]
default = "#808080"

[presets] Section

Presets define colors for game text elements:

[presets]
# Speech/dialogue
speech = { fg = "#53a684" }

# Creature names (monsterbold)
monsterbold = { fg = "#a29900" }

# Clickable links
links = { fg = "#477ab3" }

# Command echoes
commands = { fg = "#477ab3" }

# Whispers
whisper = { fg = "#4682B4" }

# Room name
roomName = { fg = "#9BA2B2", bg = "#395573" }

# Room description
roomDesc = { fg = "#CCCCCC" }

# Thoughts/ESP
thought = { fg = "#FF00FF" }

# Combat messages
combat = { fg = "#FF6600" }

Preset Format

Each preset can have:

[presets]
name = { fg = "#RRGGBB", bg = "#RRGGBB", bold = true }
PropertyTypeDescription
fgstringForeground color
bgstringBackground color
boldbooleanBold styling

Standard Presets

These presets are used by the game protocol:

PresetUsed For
speechPlayer dialogue
monsterboldCreature names
linksClickable game objects
commandsCommand echoes
whisperWhispered text
roomNameRoom titles
roomDescRoom descriptions
thoughtESP/thoughts stream

[palette] Section

Named colors for use elsewhere in configuration:

[palette]
# Base colors
black = "#000000"
white = "#FFFFFF"
red = "#AA0000"
green = "#00AA00"
blue = "#0000AA"
yellow = "#AAAA00"
cyan = "#00AAAA"
magenta = "#AA00AA"

# Bright variants
bright_black = "#555555"
bright_white = "#FFFFFF"
bright_red = "#FF5555"
bright_green = "#55FF55"
bright_blue = "#5555FF"
bright_yellow = "#FFFF55"
bright_cyan = "#55FFFF"
bright_magenta = "#FF55FF"

# Custom colors
health_high = "#00FF00"
health_mid = "#FFFF00"
health_low = "#FF0000"
mana_color = "#0088FF"

Using Palette Colors

Reference palette colors in other config files:

# In highlights.toml
[[highlights]]
name = "damage"
pattern = "damage"
fg = "bright_red"  # Uses palette.bright_red

# In layout.toml
[[windows]]
name = "health"
type = "progress"
bar_color = "health_high"  # Uses palette.health_high

[ui] Section

Colors for UI elements:

[ui]
# Borders
border = "#5588AA"
border_focused = "#88AACC"

# Backgrounds
background = "#000000"
window_background = "#0A0A0A"

# Text
text = "#CCCCCC"
text_dim = "#808080"
text_highlight = "#FFFFFF"

# Selection
selection_bg = "#264F78"
selection_fg = "#FFFFFF"

# Scrollbar
scrollbar_track = "#1A1A1A"
scrollbar_thumb = "#404040"

# Menu
menu_bg = "#1A1A1A"
menu_fg = "#CCCCCC"
menu_selected_bg = "#264F78"
menu_selected_fg = "#FFFFFF"

# Input
input_bg = "#0A0A0A"
input_fg = "#FFFFFF"
input_cursor = "#FFFFFF"

UI Color Reference

ColorUsed For
borderWindow borders
border_focusedFocused window border
backgroundGlobal background
window_backgroundWindow backgrounds
textDefault text
text_dimDimmed/secondary text
text_highlightHighlighted text
selection_bgSelection background
selection_fgSelection foreground

[prompt] Section

Colors for the game prompt:

[prompt]
# Default prompt color
default = "#808080"

# Combat/RT prompt
combat = "#FF0000"

# Safe prompt
safe = "#00FF00"

[spell] Section

Colors for spell circle indicators:

[spell]
# Major Elemental
major_elemental = "#FF6600"

# Minor Elemental
minor_elemental = "#FFAA00"

# Major Spiritual
major_spiritual = "#00AAFF"

# Minor Spiritual
minor_spiritual = "#0066FF"

# Bard
bard = "#FF00FF"

# Wizard
wizard = "#AA00FF"

# Sorcerer
sorcerer = "#660066"

# Ranger
ranger = "#00AA00"

# Paladin
paladin = "#FFFF00"

# Cleric
cleric = "#FFFFFF"

# Empath
empath = "#00FFAA"

Color Formats

Hex RGB

color = "#FF5500"    # 6-digit hex
color = "#F50"       # 3-digit hex (expanded to #FF5500)

Named Colors

Reference palette entries:

color = "bright_red"     # From [palette]
color = "health_high"    # Custom palette entry

Preset References

Reference preset colors with @:

color = "@speech"        # Uses presets.speech.fg
color = "@monsterbold"   # Uses presets.monsterbold.fg

Complete Theme Example

# Two-Face Dark Theme

[presets]
speech = { fg = "#53a684" }
monsterbold = { fg = "#e8b923", bold = true }
links = { fg = "#477ab3" }
commands = { fg = "#477ab3" }
whisper = { fg = "#4682B4" }
roomName = { fg = "#9BA2B2", bg = "#395573" }
roomDesc = { fg = "#B0B0B0" }
thought = { fg = "#DA70D6" }

[palette]
# Base colors
black = "#000000"
white = "#FFFFFF"
red = "#CC0000"
green = "#00CC00"
blue = "#0066CC"
yellow = "#CCCC00"
cyan = "#00CCCC"
magenta = "#CC00CC"

# Bright variants
bright_red = "#FF5555"
bright_green = "#55FF55"
bright_blue = "#5588FF"
bright_yellow = "#FFFF55"
bright_cyan = "#55FFFF"
bright_magenta = "#FF55FF"

# Custom
health = "#00FF00"
mana = "#0088FF"
spirit = "#00FFFF"
stamina = "#FFFF00"

[ui]
border = "#3A5F7A"
border_focused = "#5588AA"
background = "#0A0A0A"
window_background = "#0F0F0F"
text = "#CCCCCC"
text_dim = "#666666"
text_highlight = "#FFFFFF"
selection_bg = "#264F78"
selection_fg = "#FFFFFF"
menu_bg = "#1A1A1A"
menu_fg = "#CCCCCC"
menu_selected_bg = "#264F78"
menu_selected_fg = "#FFFFFF"

[prompt]
default = "#666666"
combat = "#FF4444"
safe = "#44FF44"

Theme Switching

Two-Face supports hot theme switching:

  1. Edit colors.toml
  2. Press F5 to reload
  3. New colors apply immediately

Or create multiple theme files and switch by copying:

cp themes/solarized.toml ~/.two-face/colors.toml
# Then press F5 in Two-Face

See Also

Character Profiles

Two-Face supports per-character configuration through profiles, allowing different layouts, highlights, and settings for each character.

Overview

Profiles let you:

  • Use different layouts for different playstyles (hunting warrior vs. merchant)
  • Customize highlights per character (different friend lists, creature lists)
  • Override any global setting on a per-character basis

Profile Structure

Profiles are stored in ~/.two-face/profiles/<CharacterName>/:

~/.two-face/
├── config.toml           # Global defaults
├── layout.toml           # Global layout
├── keybinds.toml         # Global keybinds
├── highlights.toml       # Global highlights
├── colors.toml           # Global colors
│
└── profiles/
    ├── Warrior/          # Warrior's profile
    │   ├── layout.toml   # Warrior's layout
    │   └── highlights.toml
    │
    ├── Wizard/           # Wizard's profile
    │   ├── layout.toml
    │   ├── highlights.toml
    │   └── keybinds.toml
    │
    └── Merchant/         # Merchant's profile
        └── layout.toml

Using Profiles

Loading a Profile

Specify the character name when launching:

two-face --character Warrior

Or set a default in config.toml:

[connection]
character = "Warrior"

Profile Loading Order

  1. Load global config from ~/.two-face/
  2. If profile exists, overlay files from profiles/<CharName>/
  3. Missing profile files fall back to global

Example flow for --character Warrior:

FileSource
config.tomlGlobal (no profile override)
layout.tomlprofiles/Warrior/layout.toml
keybinds.tomlGlobal (no profile override)
highlights.tomlprofiles/Warrior/highlights.toml
colors.tomlGlobal (no profile override)

Creating a Profile

Method 1: Manual Creation

# Create profile directory
mkdir -p ~/.two-face/profiles/MyCharacter

# Copy files you want to customize
cp ~/.two-face/layout.toml ~/.two-face/profiles/MyCharacter/
cp ~/.two-face/highlights.toml ~/.two-face/profiles/MyCharacter/

# Edit the copies
vim ~/.two-face/profiles/MyCharacter/layout.toml

Method 2: Start from Defaults

Launch with a new character name:

two-face --character NewCharacter

Two-Face uses global defaults. Then copy and customize as needed.


Profile-Specific Settings

Layout Differences

A warrior might want:

# profiles/Warrior/layout.toml

[[windows]]
name = "main"
type = "text"
width = "70%"
height = "80%"

# Large status area for combat
[[windows]]
name = "vitals"
type = "dashboard"
width = "30%"
height = "40%"

A merchant might prefer:

# profiles/Merchant/layout.toml

[[windows]]
name = "main"
type = "text"
width = "100%"
height = "90%"

# No combat widgets, maximize text area

Highlight Differences

Warrior with creature highlights:

# profiles/Warrior/highlights.toml

[[highlights]]
name = "hunting_targets"
pattern = "(?i)\\b(goblin|orc|troll|giant)s?\\b"
fg = "#FF6600"
bold = true

Merchant with item highlights:

# profiles/Merchant/highlights.toml

[[highlights]]
name = "valuable_items"
pattern = "(?i)\\b(gold|silver|gem|diamond)s?\\b"
fg = "#FFD700"
bold = true

[[highlights]]
name = "materials"
pattern = "(?i)\\b(leather|silk|velvet|mithril)\\b"
fg = "#00FFFF"

Keybind Differences

Combat character:

# profiles/Warrior/keybinds.toml

[[keybinds]]
key = "F1"
action = "send"
argument = "attack"

[[keybinds]]
key = "F2"
action = "send"
argument = "feint"

Spellcaster:

# profiles/Wizard/keybinds.toml

[[keybinds]]
key = "F1"
action = "send"
argument = "incant 901"

[[keybinds]]
key = "F2"
action = "send"
argument = "incant 903"

Shared vs. Profile-Specific

Keep Global (Shared)

  • colors.toml - Usually same theme everywhere
  • config.toml - Connection settings shared

Make Profile-Specific

  • layout.toml - Different layouts per playstyle
  • highlights.toml - Different creatures/items/friends
  • keybinds.toml - Different combat macros

Profile Tips

1. Start Simple

Only create profile overrides for files you need to change. Let others fall back to global.

2. Use Consistent Names

Match your character name exactly (case-sensitive on some systems):

# Good
--character Nisugi
~/.two-face/profiles/Nisugi/

# May not work
--character nisugi
~/.two-face/profiles/Nisugi/  # Case mismatch!

3. Share Common Elements

For settings shared across some (but not all) characters, create a “base” profile and copy from it:

# Create a hunting base
cp -r ~/.two-face/profiles/Warrior ~/.two-face/profiles/hunting-base

# New hunting character
cp -r ~/.two-face/profiles/hunting-base ~/.two-face/profiles/NewHunter

4. Version Control

Consider version controlling your profiles:

cd ~/.two-face
git init
git add .
git commit -m "Initial Two-Face configuration"

Troubleshooting Profiles

Profile Not Loading

  1. Check character name spelling (case-sensitive)
  2. Verify directory exists: ls ~/.two-face/profiles/
  3. Check file permissions
  4. Look for errors in ~/.two-face/two-face.log

Wrong Settings Applied

  1. Check which profile is loaded (shown on startup)
  2. Verify the file exists in the profile directory
  3. Check for syntax errors in the profile file

Resetting a Profile

# Remove profile to use global defaults
rm -rf ~/.two-face/profiles/CharName/

# Or reset specific file
rm ~/.two-face/profiles/CharName/layout.toml

Example: Multi-Character Setup

~/.two-face/
├── config.toml
├── layout.toml           # Balanced default layout
├── keybinds.toml         # Common keybinds
├── highlights.toml       # Common highlights
├── colors.toml           # Dark theme for all
│
└── profiles/
    ├── Ranger/
    │   ├── layout.toml   # Compact hunting layout
    │   ├── highlights.toml # Creature + foraging
    │   └── keybinds.toml # Ranged combat macros
    │
    ├── Empath/
    │   ├── layout.toml   # Group-focused layout
    │   └── highlights.toml # Wounds + group members
    │
    └── Bard/
        ├── layout.toml   # RP-friendly layout
        └── highlights.toml # Song + speech focus

See Also

Widgets

Widgets are the visual building blocks of Two-Face. Each widget type displays a specific kind of game information.

Overview

Two-Face provides these widget types:

WidgetTypePurpose
Text WindowtextScrollable game text
Tabbed Texttabbed_textMultiple streams in tabs
Command Inputcommand_inputCommand entry field
Progress BarprogressHealth, mana, etc.
CountdowncountdownRT, cast time
CompasscompassAvailable exits
HandhandItems in hands
IndicatorindicatorStatus conditions
Injury Dollinjury_dollBody injuries
Active Effectsactive_effectsBuffs/debuffs
Room WindowroomRoom description
InventoryinventoryItem list
SpellsspellsKnown spells
DashboarddashboardComposite status
PerformanceperformanceDebug metrics

Common Properties

All widgets share these properties in layout.toml:

Identity

[[windows]]
name = "my_widget"     # Unique identifier (required)
type = "text"          # Widget type (required)

Position

# Grid-based positioning
row = 0                # Row (0 = top)
col = 0                # Column (0 = left)

# OR pixel positioning
x = 100                # X coordinate
y = 50                 # Y coordinate

Size

# Fixed size
width = 40             # Columns wide
height = 10            # Rows tall

# OR percentage
width = "50%"          # Half of layout width
height = "100%"        # Full layout height

Borders

show_border = true           # Display border
border_style = "rounded"     # Style: plain, rounded, double, thick
border_sides = "all"         # Which sides: all, none, top, bottom, etc.
border_color = "#5588AA"     # Border color

Title

show_title = true            # Show title bar
title = "Custom Title"       # Override default title

Colors

background_color = "#000000" # Background
text_color = "#CCCCCC"       # Default text
transparent_background = false  # See-through background

Widget Categories

Text Displays

Widgets that show game text:

  • Text Window - Main game output, speech, thoughts
  • Tabbed Text - Multiple streams in one widget
  • Room Window - Room name, description, exits

Status Displays

Widgets that show character status:

  • Progress Bar - Health, mana, stamina, spirit
  • Countdown - Roundtime, cast time
  • Hand - Left hand, right hand, spell
  • Indicator - Stunned, hidden, webbed, etc.
  • Injury Doll - Body part injuries
  • Active Effects - Spells, buffs, cooldowns

Widgets for game navigation:

  • Compass - Available exits with directions

Lists

Widgets that display lists:

  • Inventory - Items carried
  • Spells - Known spells

Composite

Widgets combining multiple elements:

  • Dashboard - Configurable status panel

Debug

Developer/debugging widgets:

  • Performance - Frame rate, memory, network stats

Stream Mapping

Text-based widgets receive data from game streams:

StreamContentDefault Widget
mainPrimary game outputMain text window
speechPlayer dialogueSpeech tab/window
thoughtsESP/telepathyThoughts tab/window
combatCombat messagesCombat tab/window
deathDeath messagesDeath window
logonsLogin/logoutArrivals window
familiarFamiliar messagesFamiliar window
groupGroup informationGroup window
roomRoom dataRoom window
invInventory dataInventory window

Configure stream mapping in layout:

[[windows]]
name = "my_window"
type = "text"
stream = "speech"    # Display speech stream

Creating Custom Layouts

Combine widgets to create your ideal interface:

[layout]
columns = 120
rows = 40

# Main game text (left side)
[[windows]]
name = "main"
type = "text"
row = 0
col = 0
width = 80
height = 35

# Channels on right
[[windows]]
name = "channels"
type = "tabbed_text"
tabs = ["speech", "thoughts", "combat"]
row = 0
col = 80
width = 40
height = 20

# Status area
[[windows]]
name = "health"
type = "progress"
stat = "health"
row = 20
col = 80
width = 40
height = 1

# Command input at bottom
[[windows]]
name = "input"
type = "command_input"
row = 38
col = 0
width = 120
height = 2

Widget Interaction

Focus

  • Click a widget to focus it
  • Use Ctrl+Tab to cycle focus
  • Focused widget has highlighted border

Scrolling

  • Page Up / Page Down - Scroll focused text widget
  • Mouse wheel - Scroll widget under cursor
  • Home / End - Jump to top/bottom

Selection

  • Click and drag to select text
  • Ctrl+C to copy selection
  • Double-click to select word

If links = true in config:

  • Click game objects to interact
  • Right-click for context menu

Performance Considerations

Buffer Sizes

Text widgets buffer a limited number of lines:

[[windows]]
name = "main"
type = "text"
buffer_size = 2000    # Max lines (default)

Larger buffers use more memory. Reduce for secondary windows.

Widget Count

Each widget has rendering overhead. For performance:

  • Use tabbed windows instead of multiple text windows
  • Disable unused widgets
  • Reduce buffer sizes on secondary windows

See Also

Text Windows

Text windows display scrollable game text. They’re the primary widgets for viewing game output.

Overview

Text windows:

  • Display one game stream (main, speech, thoughts, etc.)
  • Support scrolling through history
  • Apply highlights and colors
  • Handle clickable links
  • Buffer a configurable number of lines

Configuration

[[windows]]
name = "main"
type = "text"

# Position and size
row = 0
col = 0
width = 80
height = 30

# Text-specific options
stream = "main"           # Game stream to display
buffer_size = 2000        # Maximum lines to keep
word_wrap = true          # Enable word wrapping

# Visual options
show_border = true
border_style = "rounded"
show_title = true
title = "Game"            # Custom title
background_color = "#000000"
text_color = "#CCCCCC"

Properties

stream

The game stream to display:

StreamContent
mainPrimary game output
speechPlayer dialogue
thoughtsESP/telepathy
combatCombat messages
deathDeath messages
logonsArrivals/departures
familiarFamiliar messages
groupGroup info

If not specified, defaults to the widget name.

buffer_size

Maximum lines to keep in memory:

buffer_size = 2000    # Default
buffer_size = 500     # Smaller for secondary windows
buffer_size = 5000    # Large for main window

Older lines are removed when the buffer fills. Larger buffers use more memory.

word_wrap

Whether to wrap long lines:

word_wrap = true      # Wrap at window width (default)
word_wrap = false     # Allow horizontal scrolling

Interaction

Scrolling

InputAction
Page UpScroll up one page
Page DownScroll down one page
HomeJump to oldest line
EndJump to newest line
Mouse wheelScroll up/down
Ctrl+UpScroll up one line
Ctrl+DownScroll down one line

Selection

InputAction
Click + dragSelect text
Double-clickSelect word
Triple-clickSelect line
Ctrl+CCopy selection
Ctrl+ASelect all

When links = true in config.toml:

InputAction
Click linkPrimary action (look/get)
Right-clickContext menu
Ctrl+clickAlternative action

Text Styling

Game Colors

The server sends color information through the XML protocol:

  • <color fg='...' bg='...'> - Explicit colors
  • <preset id='...'> - Named presets (speech, monsterbold)
  • <pushBold/><popBold/> - Bold/monsterbold

Highlights

Your highlights.toml patterns are applied after game colors:

[[highlights]]
name = "creatures"
pattern = "goblin|orc"
fg = "#FF6600"
bold = true

Priority

Color priority (highest to lowest):

  1. Explicit <color> tags
  2. Monsterbold (<pushBold/>)
  3. User highlights
  4. Preset colors
  5. Default text color

Auto-Scroll Behavior

Text windows auto-scroll to show new content when:

  • Window is scrolled to the bottom
  • New text arrives

Auto-scroll pauses when:

  • User scrolls up
  • Window is not at bottom

To resume auto-scroll:

  • Press End to jump to bottom
  • Scroll to the bottom manually

Configure in config.toml:

[behavior]
auto_scroll = true    # Default

Memory Management

Text windows use generation-based change detection for efficient updates:

  1. Each add_line() increments a generation counter
  2. Sync only copies lines newer than last sync
  3. Buffer trimming removes oldest lines when full

This enables smooth performance even with high text throughput.

Examples

Main Game Window

[[windows]]
name = "main"
type = "text"
stream = "main"
row = 0
col = 0
width = "70%"
height = "90%"
buffer_size = 3000
show_title = false
border_style = "rounded"

Speech Window

[[windows]]
name = "speech"
type = "text"
stream = "speech"
row = 0
col = 85
width = 35
height = 15
buffer_size = 500
title = "Speech"

Thoughts Window

[[windows]]
name = "thoughts"
type = "text"
stream = "thoughts"
row = 15
col = 85
width = 35
height = 15
buffer_size = 500
title = "Thoughts"

Minimal (No Border)

[[windows]]
name = "main"
type = "text"
row = 0
col = 0
width = "100%"
height = "100%"
show_border = false
show_title = false
transparent_background = true

Troubleshooting

Text not appearing

  1. Check stream matches intended source
  2. Verify window is within layout bounds
  3. Check enabled = true (default)

Colors wrong

  1. Verify COLORTERM=truecolor is set
  2. Check highlights aren’t overriding game colors
  3. Review preset colors in colors.toml

Performance issues

  1. Reduce buffer_size on secondary windows
  2. Reduce number of highlight patterns
  3. Use fast_parse = true for literal patterns

See Also

Tabbed Text Windows

Tabbed text windows combine multiple game streams into a single widget with selectable tabs.

Overview

Tabbed text windows:

  • Display multiple streams in one widget
  • Switch between tabs to view different content
  • Share a single buffer per tab
  • Save screen space vs. multiple windows

Configuration

[[windows]]
name = "channels"
type = "tabbed_text"

# Position and size
row = 0
col = 80
width = 40
height = 20

# Tab configuration
tabs = ["speech", "thoughts", "combat"]
default_tab = "speech"
show_tab_bar = true

# Visual options
tab_bar_position = "top"    # "top" or "bottom"
buffer_size = 1000          # Per-tab buffer

Properties

tabs (required)

List of streams to display as tabs:

tabs = ["speech", "thoughts", "combat"]

Each tab shows content from the named stream.

default_tab

Initially selected tab:

default_tab = "speech"    # Open to speech tab

If not specified, opens to first tab.

show_tab_bar

Whether to display the tab bar:

show_tab_bar = true     # Show tabs (default)
show_tab_bar = false    # Hide tab bar

tab_bar_position

Position of the tab bar:

tab_bar_position = "top"      # Tabs at top (default)
tab_bar_position = "bottom"   # Tabs at bottom

buffer_size

Lines to buffer per tab:

buffer_size = 1000    # Default per tab

Tab Bar Display

┌─ Channels ──────────────────────────┐
│ [Speech] [Thoughts] [Combat]        │
├─────────────────────────────────────┤
│ Alice says, "Hello everyone!"       │
│ Bob asks, "How are you?"            │
│ Charlie exclaims, "Great day!"      │
│                                     │
└─────────────────────────────────────┘

Active tab is highlighted, inactive tabs are dimmed.

Interaction

Tab Navigation

InputAction
TabNext tab
Shift+TabPrevious tab
Click tabSelect tab
1-9Select tab by number

Content Navigation

InputAction
Page Up/DownScroll current tab
Home/EndTop/bottom of current tab
Mouse wheelScroll current tab

Text Operations

InputAction
Click+dragSelect text
Ctrl+CCopy selection

Examples

Communication Channels

[[windows]]
name = "channels"
type = "tabbed_text"
tabs = ["speech", "thoughts", "whisper"]
default_tab = "speech"
row = 0
col = 80
width = 40
height = 20
title = "Channels"

Combat and Notifications

[[windows]]
name = "alerts"
type = "tabbed_text"
tabs = ["combat", "death", "logons"]
default_tab = "combat"
row = 20
col = 80
width = 40
height = 15
title = "Alerts"

All Streams

[[windows]]
name = "all_streams"
type = "tabbed_text"
tabs = ["speech", "thoughts", "combat", "death", "logons", "familiar", "group"]
default_tab = "speech"
row = 0
col = 80
width = 40
height = 35
buffer_size = 500    # Smaller buffer for many tabs

Hidden Tab Bar

[[windows]]
name = "hidden_tabs"
type = "tabbed_text"
tabs = ["speech", "thoughts"]
show_tab_bar = false
row = 0
col = 80
width = 40
height = 20

Use keybinds to switch tabs when bar is hidden.

Tab Indicators

Unread Content

Tabs with new unread content show an indicator:

[Speech*] [Thoughts] [Combat]

The * indicates unread content in that tab.

Activity Highlighting

Active tabs can use different colors:

active_tab_color = "#FFFFFF"
inactive_tab_color = "#808080"
unread_tab_color = "#FFFF00"

Memory Considerations

Each tab maintains its own buffer:

Total memory ≈ buffer_size × number_of_tabs × line_size

For many tabs, reduce buffer_size:

# 7 tabs × 500 lines = 3500 lines total
tabs = ["speech", "thoughts", "combat", "death", "logons", "familiar", "group"]
buffer_size = 500

Stream Reference

Common streams for tabs:

StreamContent
speechPlayer dialogue
thoughtsESP/telepathy
combatCombat messages
deathDeath notifications
logonsArrivals/departures
familiarFamiliar messages
groupGroup information
whisperPrivate whispers

Troubleshooting

Tab not receiving content

  1. Verify stream name is correct
  2. Check game is sending that stream
  3. Some streams require game features

Tabs not switching

  1. Ensure widget has focus
  2. Check keybinds for conflicts
  3. Try clicking tabs directly

Memory issues with many tabs

  1. Reduce buffer_size
  2. Use fewer tabs
  3. Remove unused streams

See Also

Command Input

The command input widget provides the text entry field for sending commands to the game.

Overview

The command input:

  • Accepts keyboard input for game commands
  • Maintains command history
  • Supports editing operations (cut, copy, paste)
  • Can display a custom prompt

Configuration

[[windows]]
name = "input"
type = "command_input"

# Position and size
row = 38
col = 0
width = 120
height = 2

# Input-specific options
prompt = "> "             # Input prompt
history = true            # Enable command history
history_size = 1000       # Commands to remember

# Visual options
show_border = true
border_style = "rounded"
background_color = "#0A0A0A"
text_color = "#FFFFFF"
cursor_color = "#FFFFFF"

Properties

prompt

The prompt displayed before input:

prompt = "> "         # Default
prompt = ">> "        # Double arrow
prompt = "[cmd] "     # Custom label
prompt = ""           # No prompt

history

Enable command history:

history = true        # Enable (default)
history = false       # Disable

history_size

Number of commands to remember:

history_size = 1000   # Default
history_size = 100    # Smaller history
history_size = 5000   # Large history

Input Operations

Basic Input

KeyAction
EnterSend command
EscapeClear input
Any textInsert at cursor

Cursor Movement

KeyAction
LeftMove cursor left
RightMove cursor right
HomeMove to start
EndMove to end
Ctrl+LeftPrevious word
Ctrl+RightNext word

Editing

KeyAction
BackspaceDelete before cursor
DeleteDelete at cursor
Ctrl+BackspaceDelete word before
Ctrl+DeleteDelete word after
Ctrl+UClear line
Ctrl+KDelete to end

Clipboard

KeyAction
Ctrl+CCopy selection
Ctrl+XCut selection
Ctrl+VPaste
Ctrl+ASelect all

History

KeyAction
UpPrevious command
DownNext command
Ctrl+RSearch history (if supported)

Display

Single Line

> look

Standard single-line input.

Multi-Line

[[windows]]
name = "input"
type = "command_input"
height = 3    # Multiple lines visible

Shows more context:

┌─ Input ────────────────────────────┐
│ > look                             │
│                                    │
└────────────────────────────────────┘

No Border

[[windows]]
name = "input"
type = "command_input"
show_border = false
show_title = false
height = 1

Minimal footprint:

> look█

Examples

Standard Input

[[windows]]
name = "input"
type = "command_input"
row = 38
col = 0
width = 120
height = 2
prompt = "> "
history = true
show_border = true
border_style = "rounded"
title = "Command"

Full-Width Minimal

[[windows]]
name = "input"
type = "command_input"
row = 39
col = 0
width = "100%"
height = 1
show_border = false
background_color = "#0A0A0A"

Centered Input

[[windows]]
name = "input"
type = "command_input"
row = 38
col = 20
width = 80
height = 2
prompt = ">> "
border_style = "double"

Command Processing

Command Flow

  1. User types command
  2. User presses Enter
  3. Command added to history
  4. Command sent to game server
  5. Input cleared for next command

Special Commands

Two-Face intercepts some commands:

CommandAction
;commandClient command (not sent to game)
/quitExit Two-Face
/reloadReload configuration

History Features

  • Up cycles through older commands
  • Down cycles through newer commands
  • Current input is preserved when browsing

Persistence

Command history is saved between sessions in:

~/.two-face/history

Duplicate Handling

Consecutive duplicate commands are not added to history.

Focus Behavior

The command input:

  • Automatically receives focus on startup
  • Maintains focus during normal gameplay
  • Returns focus when popups close
  • Can be focused with Ctrl+I or clicking

Troubleshooting

Commands not sending

  1. Check input has focus (cursor visible)
  2. Verify connection is active
  3. Check for keybind conflicts with Enter

History not working

  1. Verify history = true
  2. Check history_size > 0
  3. Check file permissions on history file

Cursor not visible

  1. Check cursor_color is visible
  2. Verify widget has focus
  3. Check width is sufficient

See Also

Progress Bars

Progress bars display character statistics as visual bars with optional numeric values.

Overview

Progress bars:

  • Show health, mana, stamina, spirit, encumbrance
  • Update automatically from game data
  • Support custom colors and styling
  • Can show value, percentage, or both

Configuration

[[windows]]
name = "health"
type = "progress"

# Position and size
row = 0
col = 80
width = 30
height = 1

# Progress-specific options
stat = "health"           # Stat to track (required)
show_value = true         # Show "425/500"
show_percentage = false   # Show "85%"
show_label = true         # Show stat name

# Colors
bar_color = "#00FF00"     # Bar fill color
bar_empty_color = "#333333"  # Empty portion
text_color = "#FFFFFF"    # Text color

Properties

stat (required)

The character statistic to display:

StatDescription
healthHealth points
manaMana points
spiritSpirit points
staminaStamina points
encumbranceEncumbrance level

show_value

Display the numeric value:

show_value = true     # Shows "425/500"
show_value = false    # Bar only

show_percentage

Display percentage instead of/with value:

show_percentage = true    # Shows "85%"
show_percentage = false   # Shows value or nothing

show_label

Display the stat name:

show_label = true     # Shows "HP: 425/500"
show_label = false    # Shows "425/500"

bar_color

Color of the filled portion:

bar_color = "#00FF00"     # Green
bar_color = "health"      # From palette
bar_color = "@health"     # From preset

Dynamic Colors

Use different colors based on value:

[[windows]]
name = "health"
type = "progress"
stat = "health"

# Color thresholds (checked in order)
[windows.color_thresholds]
75 = "#00FF00"    # Green above 75%
50 = "#FFFF00"    # Yellow 50-75%
25 = "#FF8800"    # Orange 25-50%
0 = "#FF0000"     # Red below 25%

Display Formats

Compact (1 row)

[[windows]]
name = "health"
type = "progress"
stat = "health"
width = 20
height = 1
show_label = true
show_value = true

Result: HP ████████░░ 80%

Minimal (Bar only)

[[windows]]
name = "health"
type = "progress"
stat = "health"
width = 15
height = 1
show_label = false
show_value = false

Result: ████████████░░░

Vertical Bar

[[windows]]
name = "health"
type = "progress"
stat = "health"
width = 3
height = 10
orientation = "vertical"

Examples

Health Bar (Green)

[[windows]]
name = "health"
type = "progress"
stat = "health"
row = 0
col = 80
width = 25
height = 1
bar_color = "#00FF00"
show_value = true

Mana Bar (Blue)

[[windows]]
name = "mana"
type = "progress"
stat = "mana"
row = 1
col = 80
width = 25
height = 1
bar_color = "#0088FF"
show_value = true

Spirit Bar (Cyan)

[[windows]]
name = "spirit"
type = "progress"
stat = "spirit"
row = 2
col = 80
width = 25
height = 1
bar_color = "#00FFFF"
show_value = true

Stamina Bar (Yellow)

[[windows]]
name = "stamina"
type = "progress"
stat = "stamina"
row = 3
col = 80
width = 25
height = 1
bar_color = "#FFFF00"
show_value = true

Encumbrance Bar

[[windows]]
name = "encumbrance"
type = "progress"
stat = "encumbrance"
row = 4
col = 80
width = 25
height = 1
bar_color = "#888888"
show_percentage = true

Stacked Vitals

# All vitals in one area
[[windows]]
name = "health"
type = "progress"
stat = "health"
row = 0
col = 100
width = 20
height = 1

[[windows]]
name = "mana"
type = "progress"
stat = "mana"
row = 1
col = 100
width = 20
height = 1

[[windows]]
name = "spirit"
type = "progress"
stat = "spirit"
row = 2
col = 100
width = 20
height = 1

[[windows]]
name = "stamina"
type = "progress"
stat = "stamina"
row = 3
col = 100
width = 20
height = 1

Data Source

Progress bars receive data from <progressBar> XML elements:

<progressBar id='health' value='85' text='health 425/500' />

The parser extracts:

  • id: Stat type
  • value: Percentage (0-100)
  • text: Parse current/max values

Troubleshooting

Bar not updating

  1. Verify stat matches a valid stat ID
  2. Check game is sending progress bar data
  3. Ensure widget is enabled

Wrong colors

  1. Check bar_color syntax
  2. Verify palette/preset references exist
  3. Check color threshold order

Value display issues

  1. Ensure show_value or show_percentage is true
  2. Check width is sufficient for text
  3. Verify text_color is visible against background

See Also

Countdowns

Countdown widgets display roundtime and cast time remaining.

Overview

Countdown widgets:

  • Show remaining seconds for roundtime or cast time
  • Update every second automatically
  • Support visual bar and numeric display
  • Customize icons and colors

Configuration

[[windows]]
name = "roundtime"
type = "countdown"

# Position and size
row = 5
col = 80
width = 15
height = 1

# Countdown-specific options
countdown_type = "roundtime"  # "roundtime" or "casttime"
show_bar = true               # Show progress bar
show_seconds = true           # Show numeric seconds
icon = "⏱"                    # Icon prefix

# Colors
bar_color = "#FF0000"         # Progress bar color
text_color = "#FFFFFF"        # Seconds text color
background_color = "#000000"

Properties

countdown_type (required)

Which countdown to display:

TypeDescription
roundtimeCombat/action roundtime
casttimeSpell casting time

show_bar

Display a progress bar:

show_bar = true       # Show visual bar (default)
show_bar = false      # Numbers only

show_seconds

Display numeric seconds remaining:

show_seconds = true   # Show "5s" (default)
show_seconds = false  # Bar only

icon

Icon displayed before countdown:

icon = "⏱"        # Timer emoji
icon = "RT:"       # Text prefix
icon = ""          # No icon

Display Modes

Bar with Seconds

⏱ ██████░░░░ 6s

Shows visual progress and numeric value.

Bar Only

██████████░░░░░░░░░░

Clean visual indicator.

Seconds Only

RT: 6

Minimal numeric display.

Compact

width = 8
height = 1
show_bar = false
icon = ""

Result: 6s

Visual Behavior

Active Countdown

When time is remaining:

  • Bar fills proportionally
  • Seconds count down
  • Color indicates urgency

Zero/Expired

When countdown reaches zero:

  • Bar empty or hidden
  • Shows “0” or disappears
  • Can trigger alert

Color Progression

Optional color changes based on time:

# High time (>5s)
high_color = "#00FF00"

# Medium time (2-5s)
medium_color = "#FFFF00"

# Low time (<2s)
low_color = "#FF0000"

Examples

Standard Roundtime

[[windows]]
name = "roundtime"
type = "countdown"
countdown_type = "roundtime"
row = 5
col = 80
width = 20
height = 1
icon = "RT:"
show_bar = true
show_seconds = true
bar_color = "#FF4444"

Cast Time

[[windows]]
name = "casttime"
type = "countdown"
countdown_type = "casttime"
row = 6
col = 80
width = 20
height = 1
icon = "CT:"
show_bar = true
show_seconds = true
bar_color = "#4444FF"

Minimal RT

[[windows]]
name = "rt"
type = "countdown"
countdown_type = "roundtime"
row = 0
col = 0
width = 5
height = 1
icon = ""
show_bar = false
show_border = false
text_color = "#FF0000"

Dual Countdowns

# Side by side
[[windows]]
name = "roundtime"
type = "countdown"
countdown_type = "roundtime"
row = 10
col = 80
width = 15
height = 1
icon = "RT"
bar_color = "#FF0000"

[[windows]]
name = "casttime"
type = "countdown"
countdown_type = "casttime"
row = 10
col = 96
width = 15
height = 1
icon = "CT"
bar_color = "#0088FF"

Progress Bar Style

[[windows]]
name = "roundtime"
type = "countdown"
countdown_type = "roundtime"
row = 15
col = 80
width = 30
height = 1
show_seconds = false
show_border = false
bar_color = "#FF6600"
transparent_background = true

Data Source

Countdown widgets receive data from XML elements:

<roundTime value='1234567890'/>
<castTime value='1234567895'/>

The value is a Unix timestamp when the countdown ends. Two-Face calculates remaining seconds.

Accuracy

Countdowns update based on:

  • Game server timestamps
  • Local system clock
  • ~1 second precision

Minor drift from game may occur due to network latency.

Alerts

Configure alerts when countdown ends:

# In config.toml
[alerts]
roundtime_end_sound = "sounds/ding.wav"
roundtime_end_flash = true

Troubleshooting

Countdown not appearing

  1. Check countdown_type is correct
  2. Verify game is sending countdown data
  3. Actions must trigger roundtime

Time seems wrong

  1. Check system clock accuracy
  2. Network latency can cause slight drift
  3. Verify countdown is for expected action

Bar not showing

  1. Check show_bar = true
  2. Verify width is sufficient
  3. Check bar_color is visible

See Also

Compass

The compass widget displays available room exits as directional indicators.

Overview

The compass:

  • Shows all available exits from current room
  • Updates automatically when you move
  • Supports graphical and text display modes
  • Highlights cardinal and special directions

Configuration

[[windows]]
name = "compass"
type = "compass"

# Position and size
row = 0
col = 100
width = 11
height = 5

# Compass-specific options
style = "graphical"       # "graphical" or "text"
show_labels = true        # Show N/S/E/W labels
compact = false           # Compact display mode

# Colors
active_color = "#FFFFFF"  # Available exit color
inactive_color = "#333333"  # Unavailable direction
special_color = "#00FFFF" # Special exits (out, up, down)

Properties

style

Display style:

StyleDescription
graphicalVisual compass rose
textText list of exits

show_labels

Show direction labels:

show_labels = true    # Shows N, S, E, W, etc.
show_labels = false   # Icons/indicators only

compact

Use compact layout:

compact = false   # Full 5x11 compass (default)
compact = true    # Smaller 3x7 compass

Direction Mapping

The compass recognizes these directions:

DirectionAbbreviationPosition
northnTop center
southsBottom center
easteRight center
westwLeft center
northeastneTop right
northwestnwTop left
southeastseBottom right
southwestswBottom left
upuTop (special)
downdBottom (special)
outoutCenter or special

Display Modes

Graphical Mode (Default)

    [N]
[NW]   [NE]
[W]  +  [E]
[SW]   [SE]
    [S]

Available exits are highlighted, unavailable are dimmed.

Text Mode

Exits: n, e, sw, out

Simple text list of available directions.

Compact Mode

 N
W+E
 S

Smaller footprint, cardinal directions only.

Examples

Standard Compass

[[windows]]
name = "compass"
type = "compass"
row = 5
col = 100
width = 11
height = 5
style = "graphical"
show_labels = true
border_style = "rounded"
title = "Exits"

Compact Compass

[[windows]]
name = "compass"
type = "compass"
row = 0
col = 110
width = 7
height = 3
style = "graphical"
compact = true
show_border = false

Text List

[[windows]]
name = "exits"
type = "compass"
row = 10
col = 80
width = 25
height = 1
style = "text"
show_border = false

Colored Compass

[[windows]]
name = "compass"
type = "compass"
row = 5
col = 100
width = 11
height = 5
active_color = "#00FF00"      # Green for available
inactive_color = "#1A1A1A"    # Very dark for unavailable
special_color = "#FFFF00"     # Yellow for up/down/out

Interaction

The compass is display-only by default. With links = true:

InputAction
Click directionMove that direction
HoverHighlight direction

Data Source

The compass receives data from <compass> XML elements:

<compass>
  <dir value="n"/>
  <dir value="e"/>
  <dir value="out"/>
</compass>

The <nav> element provides additional room information:

<nav rm='123456'/>

Troubleshooting

Compass not updating

  1. Verify you’re receiving compass data (check main window)
  2. Some areas don’t send compass info
  3. Check widget is properly configured

Exits showing incorrectly

  1. The game controls compass data
  2. Some rooms have unusual exit names
  3. Special exits may not appear in compass

Display issues

  1. Check width and height are sufficient
  2. Verify style matches your preference
  3. Check colors are visible against background

See Also

Hands

Hand widgets display items held in your character’s hands and prepared spells.

Overview

Hand widgets:

  • Show left hand, right hand, or spell contents
  • Update automatically when items change
  • Support clickable links to items
  • Display custom icons

Configuration

[[windows]]
name = "right_hand"
type = "hand"

# Position and size
row = 10
col = 100
width = 20
height = 1

# Hand-specific options
hand_type = "right"       # "left", "right", or "spell"

# Display options
icon = "🤚"               # Icon before text
icon_color = "#FFFFFF"    # Icon color
text_color = "#CCCCCC"    # Item text color

# Visual options
show_border = true
border_style = "rounded"

Properties

hand_type (required)

Which hand/slot to display:

TypeContent
leftLeft hand item
rightRight hand item
spellPrepared spell

icon

Icon displayed before content:

# Hand icons
icon = "🤚"       # Generic hand
icon = "👋"       # Wave (left)
icon = "✋"       # Palm (right)
icon = "✨"       # Sparkle (spell)

# Text icons
icon = "[L]"      # Left bracket
icon = "[R]"      # Right bracket
icon = "[S]"      # Spell bracket

# No icon
icon = ""

icon_color

Color of the icon:

icon_color = "#FFFFFF"    # White
icon_color = "bright_cyan"  # Palette color
icon_color = "@links"     # Preset color

text_color

Color of the item text:

text_color = "#CCCCCC"    # Default gray
text_color = "@links"     # Link color from presets

Display Modes

With Icon

🤚 a gleaming vultite sword

Text Only

[R] a gleaming vultite sword

Compact

show_border = false
icon = ""
width = 25
height = 1

Result: a gleaming vultite sword

Interaction

With links = true in config.toml:

InputAction
Click itemPrimary action
Right-clickContext menu

Actions depend on the item type:

  • Weapons: Attack commands
  • Containers: Open/search
  • General: Look/inspect

Examples

Right Hand

[[windows]]
name = "right_hand"
type = "hand"
hand_type = "right"
row = 5
col = 80
width = 25
height = 1
icon = "✋"
icon_color = "#AAAAAA"
title = "Right"

Left Hand

[[windows]]
name = "left_hand"
type = "hand"
hand_type = "left"
row = 6
col = 80
width = 25
height = 1
icon = "🤚"
icon_color = "#AAAAAA"
title = "Left"

Spell Hand

[[windows]]
name = "spell"
type = "hand"
hand_type = "spell"
row = 7
col = 80
width = 25
height = 1
icon = "✨"
icon_color = "#FFFF00"
title = "Spell"
text_color = "#FF88FF"

Stacked Hands

# All three hands vertically
[[windows]]
name = "right_hand"
type = "hand"
hand_type = "right"
row = 10
col = 100
width = 20
height = 1
icon = "R:"
show_border = false

[[windows]]
name = "left_hand"
type = "hand"
hand_type = "left"
row = 11
col = 100
width = 20
height = 1
icon = "L:"
show_border = false

[[windows]]
name = "spell"
type = "hand"
hand_type = "spell"
row = 12
col = 100
width = 20
height = 1
icon = "S:"
show_border = false

Bordered Group

[[windows]]
name = "hands"
type = "container"
row = 10
col = 100
width = 22
height = 5
title = "Hands"
border_style = "rounded"

[[windows]]
name = "right_hand"
type = "hand"
hand_type = "right"
row = 11
col = 101
width = 20
height = 1
show_border = false

[[windows]]
name = "left_hand"
type = "hand"
hand_type = "left"
row = 12
col = 101
width = 20
height = 1
show_border = false

[[windows]]
name = "spell"
type = "hand"
hand_type = "spell"
row = 13
col = 101
width = 20
height = 1
show_border = false

Data Source

Hand widgets receive data from XML elements:

<right exist="123456" noun="sword">a gleaming vultite sword</right>
<left exist="789012" noun="shield">a battered wooden shield</left>
<spell>Minor Sanctuary</spell>

The exist and noun attributes enable link functionality.

Empty Hands

When a hand is empty:

  • Display shows “Empty” or nothing
  • Configure empty display:
empty_text = "Empty"      # Show "Empty"
empty_text = "-"          # Show dash
empty_text = ""           # Show nothing

Troubleshooting

Hand not updating

  1. Check hand_type is correct
  2. Verify game is sending hand data
  3. Check widget is positioned correctly
  1. Enable links = true in config.toml
  2. Verify item has exist ID
  3. Check for click handler conflicts

Text truncated

  1. Increase widget width
  2. Remove icon to save space
  3. Use smaller font if available

See Also

Status Indicators

Status indicator widgets display active character conditions like stunned, hidden, poisoned, etc.

Overview

Indicator widgets:

  • Show active status conditions
  • Update automatically when conditions change
  • Support icon or text display modes
  • Can show multiple conditions

Configuration

[[windows]]
name = "status"
type = "indicator"

# Position and size
row = 8
col = 100
width = 20
height = 3

# Indicator-specific options
indicators = ["stunned", "hidden", "webbed", "poisoned"]
style = "icons"           # "icons" or "text"
layout = "horizontal"     # "horizontal" or "vertical"
show_inactive = false     # Show inactive indicators dimmed

# Colors
active_color = "#FF0000"
inactive_color = "#333333"

Properties

indicators

Which conditions to display:

# Show specific indicators
indicators = ["stunned", "hidden", "poisoned"]

# Show all indicators
indicators = "all"

Available indicators:

IDCondition
stunnedCharacter stunned
hiddenCharacter hidden
webbedCaught in web
poisonedPoisoned
diseasedDiseased
bleedingBleeding wound
proneLying down
kneelingKneeling
sittingSitting
deadDead

style

Display style:

style = "icons"     # Show icons (default)
style = "text"      # Show text labels
style = "both"      # Icon and text

layout

Indicator arrangement:

layout = "horizontal"   # Side by side (default)
layout = "vertical"     # Stacked
layout = "grid"         # Grid layout

show_inactive

Whether to show inactive indicators:

show_inactive = false   # Hide inactive (default)
show_inactive = true    # Show dimmed

Display Modes

Icons Only

⚡ 🕸️ ☠️

Compact, visual indicators.

Text Only

STUNNED  WEBBED  POISONED

Clear text labels.

Icons with Text

⚡ Stunned  🕸️ Webbed

Combined display.

Vertical

⚡ Stunned
🕸️ Webbed
☠️ Poisoned

Stacked layout.

Indicator Icons

Default icons for each condition:

ConditionIconDescription
stunnedLightning bolt
hidden👁️Eye
webbed🕸️Spider web
poisoned☠️Skull
diseased🦠Microbe
bleeding🩸Blood drop
prone⬇️Down arrow
kneeling🧎Kneeling
sitting🪑Chair
dead💀Skull

Custom Icons

Override default icons:

[indicator_icons]
stunned = "STUN"
hidden = "HIDE"
poisoned = "POIS"

Examples

Combat Status Bar

[[windows]]
name = "combat_status"
type = "indicator"
indicators = ["stunned", "prone", "webbed"]
row = 0
col = 80
width = 30
height = 1
style = "icons"
layout = "horizontal"
active_color = "#FF4444"

Full Status Panel

[[windows]]
name = "status"
type = "indicator"
indicators = "all"
row = 10
col = 100
width = 15
height = 10
style = "text"
layout = "vertical"
show_inactive = true
active_color = "#FF0000"
inactive_color = "#333333"

Compact Icons

[[windows]]
name = "status_icons"
type = "indicator"
indicators = ["stunned", "hidden", "poisoned", "diseased"]
row = 5
col = 110
width = 10
height = 1
style = "icons"
show_border = false

Health Conditions

[[windows]]
name = "health_status"
type = "indicator"
indicators = ["poisoned", "diseased", "bleeding"]
row = 4
col = 80
width = 20
height = 1
style = "both"
active_color = "#FF8800"
title = "Conditions"

Condition Colors

Different colors for different severity:

[indicator_colors]
# Immediate threats (red)
stunned = "#FF0000"
prone = "#FF0000"

# Combat conditions (orange)
webbed = "#FF8800"
bleeding = "#FF8800"

# Health conditions (yellow)
poisoned = "#FFFF00"
diseased = "#FFFF00"

# Status (blue)
hidden = "#0088FF"
kneeling = "#0088FF"
sitting = "#0088FF"

Animation

Indicators can flash or pulse:

# Flash active indicators
flash_active = true
flash_rate = 500        # Milliseconds

# Pulse critical conditions
pulse_critical = true
critical_indicators = ["stunned", "dead"]

Data Source

Indicators receive data from XML elements:

<indicator id="IconSTUNNED" visible="y"/>
<indicator id="IconHIDDEN" visible="n"/>
<indicator id="IconPOISONED" visible="y"/>

The visible attribute determines active state.

Troubleshooting

Indicators not showing

  1. Verify condition is actually active
  2. Check indicators list includes the condition
  3. Ensure show_inactive = false isn’t hiding it

Wrong icons

  1. Check style setting
  2. Verify icon font support in terminal
  3. Use text fallbacks if needed

Layout issues

  1. Adjust width and height
  2. Change layout mode
  3. Reduce number of indicators

See Also

Injury Doll

The injury doll widget displays a visual representation of injuries to different body parts.

Overview

Injury doll widgets:

  • Show body part injury severity visually
  • Display wounds, scars, and blood loss
  • Update as injuries change
  • Support multiple display styles

Configuration

[[windows]]
name = "injuries"
type = "injury_doll"

# Position and size
row = 0
col = 100
width = 20
height = 12

# Injury-specific options
style = "ascii"           # "ascii", "simple", "detailed"
show_labels = true        # Show body part names
show_severity = true      # Show injury level numbers
orientation = "vertical"  # "vertical" or "horizontal"

# Colors by severity
healthy_color = "#00FF00"
minor_color = "#FFFF00"
moderate_color = "#FF8800"
severe_color = "#FF0000"
critical_color = "#FF0000"

Properties

style

Display style for the injury doll:

style = "ascii"      # ASCII art body (default)
style = "simple"     # Text list only
style = "detailed"   # Full visual with numbers

show_labels

Display body part names:

show_labels = true    # Show "Head", "Chest", etc.
show_labels = false   # Visual only

show_severity

Display injury severity numbers:

show_severity = true    # Show severity level (1-4)
show_severity = false   # Color-coded only

orientation

Layout orientation:

orientation = "vertical"    # Tall layout (default)
orientation = "horizontal"  # Wide layout

Body Parts

The injury doll tracks these body parts:

PartXML IDDescription
HeadheadHead injuries
NeckneckNeck injuries
ChestchestChest/torso
AbdomenabdomenStomach area
BackbackBack injuries
Left ArmleftArmLeft arm
Right ArmrightArmRight arm
Left HandleftHandLeft hand
Right HandrightHandRight hand
Left LegleftLegLeft leg
Right LegrightLegRight leg
Left EyeleftEyeLeft eye
Right EyerightEyeRight eye
NervesnsysNervous system

Severity Levels

LevelNameColor (default)Description
0HealthyGreenNo injury
1MinorYellowMinor wound
2ModerateOrangeModerate injury
3SevereRedSevere wound
4CriticalBright RedCritical injury

Display Styles

ASCII Art Style

┌─ Injuries ───────────┐
│       ( o o )        │
│         ─┬─          │
│        ──┼──         │
│          │           │
│         / \          │
│        /   \         │
└──────────────────────┘

Body parts are colored by severity.

Simple Text Style

┌─ Injuries ───────────┐
│ Head: Minor          │
│ Chest: Moderate      │
│ Right Arm: Severe    │
│ Left Leg: Minor      │
└──────────────────────┘

Detailed Style

┌─ Injuries ───────────┐
│ HEAD     [████░░] 2  │
│ CHEST    [██████] 3  │
│ R.ARM    [████████] 4│
│ L.LEG    [██░░░░] 1  │
│ BACK     [░░░░░░] 0  │
└──────────────────────┘

Examples

Standard Injury Doll

[[windows]]
name = "injuries"
type = "injury_doll"
row = 0
col = 100
width = 20
height = 12
style = "ascii"
show_labels = true
show_severity = true
border_style = "rounded"

Compact Text List

[[windows]]
name = "injury_list"
type = "injury_doll"
row = 0
col = 100
width = 25
height = 8
style = "simple"
show_severity = true
title = "Wounds"

Minimal Status

[[windows]]
name = "injury_mini"
type = "injury_doll"
row = 0
col = 100
width = 15
height = 5
style = "simple"
show_labels = false
show_severity = false
show_border = false

Wide Layout

[[windows]]
name = "injuries_wide"
type = "injury_doll"
row = 20
col = 0
width = 60
height = 5
style = "detailed"
orientation = "horizontal"

Severity Colors

Customize colors for each severity level:

[[windows]]
name = "injuries"
type = "injury_doll"

# Custom severity colors
[windows.colors]
healthy = "#00FF00"    # Green
minor = "#FFFF00"      # Yellow
moderate = "#FF8800"   # Orange
severe = "#FF4444"     # Red
critical = "#FF0000"   # Bright red
bleeding = "#880000"   # Dark red for blood
scarred = "#808080"    # Gray for scars

Wound Types

Different wound states may display differently:

StateVisualDescription
Fresh woundBright colorRecent injury
BleedingPulsing/darkActive bleeding
ScarredGrayOld injury
HealingFadingBeing healed

Animation

Injuries can animate to draw attention:

# Flash critical injuries
flash_critical = true
flash_rate = 500        # Milliseconds

# Pulse bleeding wounds
pulse_bleeding = true

Data Source

Injury data comes from XML elements:

<indicator id="IconBLEEDING" visible="y"/>
<body>
  <part id="head" value="0"/>
  <part id="chest" value="2"/>
  <part id="rightArm" value="3"/>
</body>

The value attribute indicates severity (0-4).

Integration with Other Widgets

Combined with Health Bars

# Health bar
[[windows]]
name = "health"
type = "progress"
source = "health"
row = 0
col = 80
width = 20
height = 1

# Injury doll below
[[windows]]
name = "injuries"
type = "injury_doll"
row = 1
col = 80
width = 20
height = 10

Part of Status Panel

# Status indicators
[[windows]]
name = "status"
type = "indicator"
row = 0
col = 100
width = 20
height = 2

# Injuries below status
[[windows]]
name = "injuries"
type = "injury_doll"
row = 2
col = 100
width = 20
height = 10

Troubleshooting

Injuries not updating

  1. Verify receiving body data from game
  2. Check window is using correct type
  3. Ensure body part IDs match XML

Wrong colors

  1. Check severity color configuration
  2. Verify theme colors aren’t overriding
  3. Test with default colors first

Display too small

  1. Increase width and height
  2. Try “simple” style for compact spaces
  3. Adjust orientation for available space

See Also

Active Effects

The active effects widget displays current buffs, debuffs, spells, and other timed effects on your character.

Overview

Active effects widgets:

  • Show currently active spells and abilities
  • Display remaining duration
  • Track buff and debuff status
  • Update in real-time as effects expire

Configuration

[[windows]]
name = "effects"
type = "active_effects"

# Position and size
row = 0
col = 100
width = 30
height = 15

# Effect display options
show_duration = true       # Show remaining time
show_icons = true          # Show effect icons
sort_by = "duration"       # "duration", "name", "type"
group_by_type = false      # Group buffs/debuffs

# Filter options
show_buffs = true
show_debuffs = true
show_spells = true
show_abilities = true

# Colors
buff_color = "#00FF00"
debuff_color = "#FF0000"
spell_color = "#00FFFF"
expiring_color = "#FFFF00"

Properties

show_duration

Display remaining time for effects:

show_duration = true    # Show "Spirit Shield (2:45)"
show_duration = false   # Show "Spirit Shield" only

show_icons

Display icons for effects:

show_icons = true     # Show icons/symbols
show_icons = false    # Text only

sort_by

How to sort the effect list:

sort_by = "duration"   # Shortest remaining first
sort_by = "name"       # Alphabetical
sort_by = "type"       # Buffs, then debuffs, etc.

group_by_type

Group effects by category:

group_by_type = true    # Separate sections
group_by_type = false   # Single list (default)

Filter Options

Control which effect types to display:

show_buffs = true       # Beneficial effects
show_debuffs = true     # Harmful effects
show_spells = true      # Active spells
show_abilities = true   # Active abilities

Display Format

Standard List

┌─ Active Effects ─────────────┐
│ ✦ Spirit Shield      (5:23)  │
│ ✦ Elemental Defense  (4:15)  │
│ ✦ Haste              (2:30)  │
│ ✧ Poison             (1:45)  │
│ ✦ Minor Sanctuary    (0:58)  │
└──────────────────────────────┘
  • ✦ = Buff (beneficial)
  • ✧ = Debuff (harmful)

Grouped Display

┌─ Active Effects ─────────────┐
│ BUFFS:                       │
│   Spirit Shield      (5:23)  │
│   Elemental Defense  (4:15)  │
│   Haste              (2:30)  │
│                              │
│ DEBUFFS:                     │
│   Poison             (1:45)  │
└──────────────────────────────┘

Compact Display

┌─ Effects ────────────────────┐
│ Shield:5:23 Defense:4:15     │
│ Haste:2:30 Poison:1:45       │
└──────────────────────────────┘

Effect Types

TypeDescriptionDefault Color
BuffBeneficial effectGreen
DebuffHarmful effectRed
SpellActive spellCyan
AbilityCharacter abilityBlue
ItemItem effectPurple

Duration Display

Time Formats

RemainingDisplay
> 1 hour1:23:45
> 1 minute5:23
< 1 minute0:45
Expiring soonFlashing
Permanent∞ or –

Expiring Warning

Effects close to expiring can be highlighted:

expiring_threshold = 30    # Seconds before expiring
expiring_color = "#FFFF00" # Yellow warning
expiring_flash = true      # Flash when expiring

Examples

Full Effects Panel

[[windows]]
name = "effects"
type = "active_effects"
row = 0
col = 100
width = 30
height = 20
show_duration = true
show_icons = true
sort_by = "duration"
group_by_type = true
title = "Active Effects"

Buffs Only

[[windows]]
name = "buffs"
type = "active_effects"
row = 0
col = 100
width = 25
height = 10
show_buffs = true
show_debuffs = false
show_spells = true
buff_color = "#00FF00"
title = "Buffs"

Debuff Monitor

[[windows]]
name = "debuffs"
type = "active_effects"
row = 10
col = 100
width = 25
height = 8
show_buffs = false
show_debuffs = true
debuff_color = "#FF4444"
expiring_color = "#FFFF00"
title = "Debuffs"

Compact Status Bar

[[windows]]
name = "effect_bar"
type = "active_effects"
row = 0
col = 50
width = 50
height = 2
show_icons = true
show_duration = false
sort_by = "type"
show_border = false

Spell Timer

[[windows]]
name = "spells"
type = "active_effects"
row = 15
col = 100
width = 25
height = 12
show_buffs = false
show_debuffs = false
show_spells = true
sort_by = "duration"
title = "Active Spells"

Effect Colors

Customize colors by effect type:

[[windows]]
name = "effects"
type = "active_effects"

[windows.colors]
buff = "#00FF00"          # Green
debuff = "#FF0000"        # Red
spell = "#00FFFF"         # Cyan
ability = "#0088FF"       # Blue
item = "#FF00FF"          # Magenta
expiring = "#FFFF00"      # Yellow
permanent = "#FFFFFF"     # White

Severity-Based Colors

For debuffs, color by severity:

[windows.debuff_colors]
minor = "#FFFF00"      # Yellow
moderate = "#FF8800"   # Orange
severe = "#FF0000"     # Red
critical = "#FF0000"   # Bright red + flash

Animation

Expiring Effects

# Flash effects about to expire
flash_expiring = true
flash_threshold = 30      # Seconds
flash_rate = 500          # Milliseconds

# Fade out expired effects
fade_on_expire = true
fade_duration = 1000      # Milliseconds

New Effects

# Highlight newly added effects
highlight_new = true
highlight_duration = 2000  # Milliseconds
highlight_color = "#FFFFFF"

Data Source

Effects are tracked from multiple XML sources:

<spell>Spirit Shield</spell>
<duration value="323"/>

<effect name="Poison" type="debuff" duration="105"/>

<component id="activeSpells">
  Spirit Shield, Elemental Defense
</component>

Integration Examples

Combined with Spell List

# Active effects
[[windows]]
name = "active"
type = "active_effects"
row = 0
col = 100
width = 25
height = 10

# Known spells below
[[windows]]
name = "spells"
type = "spells"
row = 10
col = 100
width = 25
height = 15

Part of Combat Dashboard

# Health/vitals
[[windows]]
name = "vitals"
type = "progress"
row = 0
col = 80
width = 40
height = 4

# Active effects
[[windows]]
name = "effects"
type = "active_effects"
row = 4
col = 80
width = 20
height = 8

# Debuff alerts
[[windows]]
name = "debuffs"
type = "active_effects"
row = 4
col = 100
width = 20
height = 8
show_buffs = false
show_debuffs = true

Troubleshooting

Effects not showing

  1. Verify game is sending effect data
  2. Check filter settings (show_buffs, etc.)
  3. Ensure correct widget type

Duration not updating

  1. Effects may not send duration updates
  2. Check timer refresh rate
  3. Some effects show as permanent

Missing effect types

  1. Enable all show_* options to test
  2. Check if game sends that effect type
  3. Verify effect parsing in logs

See Also

Room Window

The room window displays current room information including name, description, exits, objects, and other players.

Overview

Room windows:

  • Show the current room name and description
  • Display obvious exits
  • List objects and creatures in the room
  • Show other players present
  • Update when you move

Configuration

[[windows]]
name = "room"
type = "room"

# Position and size
row = 0
col = 80
width = 40
height = 15

# Room-specific options
show_title = true         # Show room name as title
show_description = true   # Show room description
show_exits = true         # Show obvious exits
show_objects = true       # Show items/creatures
show_players = true       # Show other players

# Visual options
title_color = "#FFFFFF"
description_color = "#CCCCCC"
exits_color = "#00FFFF"
objects_color = "#AAAAAA"
players_color = "#00FF00"

Properties

show_title

Display room name as widget title:

show_title = true     # Room name in title bar
show_title = false    # No title/generic title

show_description

Display the room description:

show_description = true    # Full description
show_description = false   # Hide description

show_exits

Display obvious exits:

show_exits = true     # Show "Obvious exits: n, e, out"
show_exits = false    # Hide exits

show_objects

Display items and creatures:

show_objects = true   # Show objects in room
show_objects = false  # Hide objects

show_players

Display other players:

show_players = true   # Show player names
show_players = false  # Hide players

Display Layout

┌─ Town Square Central ───────────────┐
│ This is the heart of the town,      │
│ where merchants hawk their wares    │
│ and travelers gather to share news. │
│                                     │
│ You also see a wooden bench, a      │
│ town crier, and a young squire.     │
│                                     │
│ Also here: Adventurer, Merchant     │
│                                     │
│ Obvious paths: north, east, south,  │
│ west, out                           │
└─────────────────────────────────────┘

Component Colors

Style each component separately:

[[windows]]
name = "room"
type = "room"

# Component colors
[windows.colors]
title = "#9BA2B2"           # Room name
title_bg = "#395573"        # Room name background
description = "#B0B0B0"     # Description text
exits = "#00CCCC"           # Exit directions
objects = "#888888"         # Items/creatures
players = "#00FF00"         # Other players
creatures = "#FF6600"       # Hostile creatures

Examples

Standard Room Window

[[windows]]
name = "room"
type = "room"
row = 0
col = 80
width = 40
height = 15
show_title = true
show_description = true
show_exits = true
show_objects = true
show_players = true
border_style = "rounded"

Compact Room Info

[[windows]]
name = "room"
type = "room"
row = 0
col = 80
width = 40
height = 5
show_description = false   # Hide description
show_objects = false       # Hide objects
show_title = true
show_exits = true
show_players = true

Full Width

[[windows]]
name = "room"
type = "room"
row = 0
col = 0
width = "100%"
height = 8
show_border = false
transparent_background = true

Description Only

[[windows]]
name = "room_desc"
type = "room"
row = 0
col = 80
width = 40
height = 10
show_description = true
show_exits = false
show_objects = false
show_players = false
title = "Description"

Exits Only

[[windows]]
name = "exits"
type = "room"
row = 10
col = 80
width = 40
height = 2
show_description = false
show_objects = false
show_players = false
show_exits = true
show_border = false

Room Components

Room Name

Set by <streamWindow id='room' subtitle='Room Name'/>:

title_color = "#9BA2B2"
title_bg = "#395573"      # Distinct background

Description

Set by <component id='room desc'>:

description_color = "#B0B0B0"
word_wrap = true          # Wrap long descriptions

Exits

Set by <compass> and displayed as text:

exits_color = "#00CCCC"
exits_prefix = "Obvious paths: "

Objects/Creatures

Set by <component id='room objs'>:

objects_color = "#888888"
creature_highlight = true    # Highlight creatures
creature_color = "#FF6600"   # Creature color

Players

Set by <component id='room players'>:

players_color = "#00FF00"
players_prefix = "Also here: "

Interaction

With links = true:

InputAction
Click objectLook/interact
Click playerLook at player
Click exitMove that direction
Right-clickContext menu

Data Source

Room windows receive data from multiple XML elements:

<streamWindow id='room' subtitle='Town Square'/>
<component id='room desc'>This is the description...</component>
<component id='room objs'>You also see a bench...</component>
<component id='room players'>Also here: Alice, Bob</component>
<compass><dir value='n'/><dir value='e'/></compass>

Troubleshooting

Room not updating

  1. Check you’re receiving room data
  2. Verify window stream is correct
  3. Some areas suppress room info

Missing components

  1. Enable specific show_* options
  2. Check game is sending that component
  3. Verify component colors are visible

Text overflow

  1. Increase window height
  2. Enable word wrap
  3. Hide less important components

See Also

Inventory Widget

The inventory widget displays your character’s carried items and container contents.

Overview

Inventory widgets:

  • Show items in containers (backpack, cloak, etc.)
  • Display item counts and descriptions
  • Support expandable container trees
  • Enable quick item access

Configuration

[[windows]]
name = "inventory"
type = "inventory"

# Position and size
row = 0
col = 80
width = 40
height = 25

# Inventory-specific options
show_containers = true     # Show container hierarchy
expand_containers = false  # Auto-expand all containers
show_count = true          # Show item counts
show_weight = true         # Show weight (if available)
sort_by = "name"           # "name", "type", "none"

# Interaction
clickable = true           # Click to interact
double_click_action = "look"  # "look", "get", "open"

Properties

show_containers

Display container hierarchy:

show_containers = true    # Show nested structure
show_containers = false   # Flat item list

expand_containers

Auto-expand containers:

expand_containers = false   # Collapsed by default
expand_containers = true    # All expanded

show_count

Display item counts:

show_count = true    # "5 silver coins"
show_count = false   # "silver coins"

show_weight

Display item weight (if available):

show_weight = true    # Show weight info
show_weight = false   # Hide weight

sort_by

Item sort order:

sort_by = "name"    # Alphabetical
sort_by = "type"    # By item type
sort_by = "none"    # Game order

Display Format

Tree View

┌─ Inventory ────────────────────────┐
│ ▼ a leather backpack               │
│   ├─ some silver coins (23)        │
│   ├─ a healing potion              │
│   ├─ a bronze lockpick             │
│   └─ ▶ a small pouch               │
│                                    │
│ ▼ a dark cloak                     │
│   ├─ a silver dagger               │
│   └─ a vial of poison              │
│                                    │
│ a sturdy shield                    │
│ a steel longsword                  │
└────────────────────────────────────┘
  • ▼ = Expanded container
  • ▶ = Collapsed container

Flat View

┌─ Inventory ────────────────────────┐
│ some silver coins (23)             │
│ a healing potion                   │
│ a bronze lockpick                  │
│ a silver dagger                    │
│ a vial of poison                   │
│ a sturdy shield                    │
│ a steel longsword                  │
└────────────────────────────────────┘

Compact View

┌─ Items ───────────┐
│ coins(23) potion  │
│ lockpick dagger   │
│ poison shield     │
│ longsword         │
└───────────────────┘

Examples

Full Inventory Panel

[[windows]]
name = "inventory"
type = "inventory"
row = 0
col = 80
width = 40
height = 30
show_containers = true
expand_containers = false
show_count = true
clickable = true
title = "Inventory"
border_style = "rounded"

Compact Item List

[[windows]]
name = "items"
type = "inventory"
row = 0
col = 100
width = 25
height = 15
show_containers = false
show_count = true
sort_by = "name"
title = "Items"

Container Focus

[[windows]]
name = "backpack"
type = "inventory"
row = 10
col = 80
width = 30
height = 15
container = "backpack"     # Focus on specific container
expand_containers = true
title = "Backpack"

Merchant View

[[windows]]
name = "merchant_inv"
type = "inventory"
row = 0
col = 60
width = 50
height = 35
show_containers = true
expand_containers = true
show_weight = true
sort_by = "type"
clickable = true
double_click_action = "look"

Item Interaction

Click Actions

ActionResult
Single clickSelect item
Double clickConfigurable action
Right clickContext menu

Context Menu Options

┌────────────────┐
│ Look           │
│ Get            │
│ Drop           │
│ Put in...      │
│ Give to...     │
│ ────────────── │
│ Examine        │
│ Appraise       │
└────────────────┘

Keyboard Navigation

KeyAction
↑/↓Navigate items
EnterDefault action
SpaceExpand/collapse
lLook at item
gGet item
dDrop item

Item Colors

Color items by type or rarity:

[[windows]]
name = "inventory"
type = "inventory"

[windows.item_colors]
weapon = "#FF8800"       # Orange
armor = "#888888"        # Gray
potion = "#00FF00"       # Green
scroll = "#FFFF00"       # Yellow
gem = "#FF00FF"          # Magenta
coin = "#FFD700"         # Gold
container = "#00FFFF"    # Cyan
default = "#FFFFFF"      # White

Rarity Colors

[windows.rarity_colors]
common = "#FFFFFF"       # White
uncommon = "#00FF00"     # Green
rare = "#0088FF"         # Blue
epic = "#FF00FF"         # Purple
legendary = "#FFD700"    # Gold

Container Options

Specific Container

Focus on a single container:

container = "backpack"    # Only show backpack contents
container = "cloak"       # Only show cloak contents

Container Depth

Limit nesting depth:

max_depth = 2    # Only show 2 levels deep
max_depth = 0    # Show all levels (default)

Auto-Refresh

auto_refresh = true       # Update when inventory changes
refresh_rate = 1000       # Milliseconds (if polling)

Data Source

Inventory data comes from XML elements:

<inv id="stow">
  <item>a leather backpack</item>
  <item>a dark cloak</item>
</inv>

<container id="backpack">
  <item>some silver coins</item>
  <item>a healing potion</item>
</container>

Integration Examples

With Hands Display

# What you're holding
[[windows]]
name = "hands"
type = "hand"
row = 0
col = 80
width = 30
height = 3

# Full inventory below
[[windows]]
name = "inventory"
type = "inventory"
row = 3
col = 80
width = 30
height = 20

Merchant Layout

# Your inventory (left)
[[windows]]
name = "my_items"
type = "inventory"
row = 0
col = 0
width = 40
height = 30
title = "Your Items"

# Merchant inventory (right)
[[windows]]
name = "merchant"
type = "text"
stream = "merchant"
row = 0
col = 50
width = 40
height = 30
title = "Merchant"

Quick Access Bar

# Horizontal quick items
[[windows]]
name = "quick_items"
type = "inventory"
row = 0
col = 0
width = 80
height = 3
show_containers = false
sort_by = "type"
layout = "horizontal"

Weight Tracking

If weight tracking is enabled:

┌─ Inventory ────────────────────────┐
│ Carried: 45.2 lbs / 100 lbs        │
│ ──────────────────────────────────│
│ ▼ leather backpack (12.5 lbs)      │
│   ├─ silver coins (2.3 lbs)        │
│   └─ healing potion (0.5 lbs)      │
└────────────────────────────────────┘
show_weight = true
show_encumbrance = true
encumbrance_warning = 80    # Warn at 80% capacity

Troubleshooting

Inventory not updating

  1. Verify receiving inventory data
  2. Check window is correct type
  3. Try manual refresh command

Containers not expanding

  1. Check expand_containers setting
  2. Verify container data is being sent
  3. Click expand arrow manually

Missing items

  1. Check container filter settings
  2. Verify all containers are included
  3. Check item parsing in debug log

Performance with large inventory

  1. Set expand_containers = false
  2. Reduce max_depth
  3. Focus on specific containers

See Also

Spells Widget

The spells widget displays your character’s known spells, spell preparation status, and casting availability.

Overview

Spells widgets:

  • List known spells and abilities
  • Show spell preparation status
  • Display mana costs and cooldowns
  • Enable quick spell casting

Configuration

[[windows]]
name = "spells"
type = "spells"

# Position and size
row = 0
col = 100
width = 30
height = 20

# Spell display options
show_prepared = true       # Show preparation status
show_cost = true           # Show mana cost
show_circle = true         # Show spell circle
group_by_circle = true     # Group by spell circle
sort_by = "circle"         # "circle", "name", "cost"

# Interaction
clickable = true           # Click to cast
show_tooltip = true        # Show spell info on hover

# Colors
prepared_color = "#00FF00"
unprepared_color = "#808080"
unavailable_color = "#FF0000"

Properties

show_prepared

Display spell preparation status:

show_prepared = true    # Show ✓/✗ for prepared
show_prepared = false   # List only

show_cost

Display mana cost:

show_cost = true     # Show "(5 mana)"
show_cost = false    # Name only

show_circle

Display spell circle number:

show_circle = true    # Show circle/level
show_circle = false   # Hide circle

group_by_circle

Group spells by circle:

group_by_circle = true    # Sections by circle
group_by_circle = false   # Single list

sort_by

Sort order for spells:

sort_by = "circle"   # By spell circle
sort_by = "name"     # Alphabetical
sort_by = "cost"     # By mana cost

Display Format

Grouped by Circle

┌─ Spells ─────────────────────────┐
│ ─── Minor Spirit (100s) ───      │
│ ✓ Spirit Warding I        (1)    │
│ ✓ Spirit Defense II       (2)    │
│ ✗ Spirit Fog              (6)    │
│                                  │
│ ─── Major Spirit (200s) ───      │
│ ✓ Spirit Shield           (3)    │
│ ✗ Elemental Defense       (5)    │
└──────────────────────────────────┘

Simple List

┌─ Spells ─────────────────────────┐
│ ✓ Spirit Warding I        (1)    │
│ ✓ Spirit Defense II       (2)    │
│ ✓ Spirit Shield           (3)    │
│ ✗ Elemental Defense       (5)    │
│ ✗ Spirit Fog              (6)    │
└──────────────────────────────────┘

Compact Format

┌─ Spells ──────────────┐
│ 101✓ 102✓ 103✗ 104✓   │
│ 201✓ 202✗ 203✓ 204✗   │
└───────────────────────┘

Spell Status

SymbolMeaningColor (default)
PreparedGreen
Not preparedGray
Currently castingCyan
UnavailableRed
On cooldownYellow

Examples

Full Spell Panel

[[windows]]
name = "spells"
type = "spells"
row = 0
col = 100
width = 35
height = 25
show_prepared = true
show_cost = true
show_circle = true
group_by_circle = true
clickable = true
title = "Known Spells"

Compact Spell Bar

[[windows]]
name = "spell_bar"
type = "spells"
row = 0
col = 50
width = 40
height = 3
group_by_circle = false
show_cost = false
layout = "horizontal"
show_border = false

Prepared Spells Only

[[windows]]
name = "prepared"
type = "spells"
row = 10
col = 100
width = 25
height = 10
filter = "prepared"    # Only show prepared
show_prepared = false  # No status icon needed
title = "Ready Spells"

Quick Cast Panel

[[windows]]
name = "quick_cast"
type = "spells"
row = 0
col = 100
width = 20
height = 15
favorite_spells = [101, 103, 107, 201, 206]
clickable = true
double_click_action = "cast"
title = "Quick Cast"

Spell Interaction

Click Actions

ActionResult
Single clickSelect spell
Double clickCast spell
Right clickSpell info menu
Shift+clickPrepare spell

Context Menu

┌────────────────────┐
│ Cast               │
│ Prepare            │
│ ────────────────── │
│ Spell Info         │
│ Show Incantation   │
│ ────────────────── │
│ Add to Quick Cast  │
│ Set Hotkey         │
└────────────────────┘

Keyboard Shortcuts

KeyAction
↑/↓Navigate spells
EnterCast selected
pPrepare selected
iShow info
1-9Quick cast by position

Spell Colors

Color spells by type or circle:

[[windows]]
name = "spells"
type = "spells"

[windows.spell_colors]
minor_spirit = "#00FFFF"    # Cyan (100s)
major_spirit = "#0088FF"    # Blue (200s)
cleric = "#FFFFFF"          # White (300s)
minor_elemental = "#FF8800" # Orange (400s)
major_elemental = "#FF0000" # Red (500s)
ranger = "#00FF00"          # Green (600s)
sorcerer = "#FF00FF"        # Purple (700s)
wizard = "#FFFF00"          # Yellow (900s)

Status Colors

[windows.status_colors]
prepared = "#00FF00"        # Green
unprepared = "#808080"      # Gray
casting = "#00FFFF"         # Cyan
cooldown = "#FFFF00"        # Yellow
unavailable = "#FF0000"     # Red
insufficient_mana = "#FF8800" # Orange

Mana Cost Display

Show mana costs with current mana:

show_cost = true
show_current_mana = true   # Show current/max
highlight_affordable = true # Dim unaffordable
┌─ Spells ─────────────────────────┐
│ Mana: 45/100                     │
│ ──────────────────────────────── │
│ ✓ Spirit Shield           (3)   │
│ ✓ Elemental Defense       (5)   │
│ ✗ Major Sanctuary        (50)   │  ← Dimmed (can't afford)
└──────────────────────────────────┘

Cooldown Display

Show cooldown timers:

show_cooldowns = true
cooldown_format = "remaining"   # "remaining", "ready_at", "bar"
┌─ Spells ─────────────────────────┐
│ ✓ Spirit Shield           Ready  │
│ ⏱ Mass Sanctuary         (0:45)  │
│ ⏱ Divine Intervention    (2:30)  │
└──────────────────────────────────┘

Data Source

Spell data comes from XML elements:

<spellList>
  <spell id="101" name="Spirit Warding I" prepared="y"/>
  <spell id="102" name="Spirit Barrier" prepared="n"/>
</spellList>

<spell>Spirit Shield</spell>
<duration value="300"/>

Integration Examples

With Active Effects

# Active spells/effects
[[windows]]
name = "active"
type = "active_effects"
row = 0
col = 100
width = 25
height = 10
title = "Active"

# Known spells below
[[windows]]
name = "spells"
type = "spells"
row = 10
col = 100
width = 25
height = 20
title = "Spells"

Combat Spell Panel

# Mana bar
[[windows]]
name = "mana"
type = "progress"
source = "mana"
row = 0
col = 80
width = 30
height = 1

# Offensive spells
[[windows]]
name = "attack_spells"
type = "spells"
row = 1
col = 80
width = 15
height = 10
filter_circles = [400, 500, 700]
title = "Attack"

# Defensive spells
[[windows]]
name = "defense_spells"
type = "spells"
row = 1
col = 95
width = 15
height = 10
filter_circles = [100, 200, 300]
title = "Defense"

Troubleshooting

Spells not showing

  1. Verify spell list data is being received
  2. Check filter settings
  3. Ensure widget type is correct

Preparation status wrong

  1. Check game is sending status updates
  2. Verify spell ID matching
  3. Manual refresh may help

Can’t click to cast

  1. Verify clickable = true
  2. Check double_click_action setting
  3. Ensure widget has focus

Missing spell circles

  1. Check filter_circles setting
  2. Verify all circles are enabled
  3. Check data parsing

See Also

Dashboard Widget

The dashboard widget combines multiple data displays into a single composite panel, providing an at-a-glance overview of character status.

Overview

Dashboard widgets:

  • Combine multiple metrics in one widget
  • Provide compact status overview
  • Reduce window count
  • Customizable component selection

Configuration

[[windows]]
name = "dashboard"
type = "dashboard"

# Position and size
row = 0
col = 0
width = 40
height = 15

# Components to include
components = [
    "vitals",
    "experience",
    "stance",
    "encumbrance",
    "wounds"
]

# Layout
layout = "vertical"       # "vertical", "horizontal", "grid"
compact = false           # Condensed display

# Visual options
show_labels = true
show_dividers = true

Properties

components

Which status elements to display:

# Full dashboard
components = [
    "vitals",       # Health, mana, stamina, spirit
    "experience",   # XP info
    "stance",       # Combat stance
    "encumbrance",  # Weight carried
    "wounds",       # Injury summary
    "status",       # Status indicators
    "wealth",       # Silver/gold
    "position"      # Standing/sitting/etc.
]

# Minimal dashboard
components = ["vitals", "stance"]

layout

Component arrangement:

layout = "vertical"     # Stacked top to bottom
layout = "horizontal"   # Side by side
layout = "grid"         # 2-column grid

compact

Condensed display mode:

compact = false   # Full labels and spacing
compact = true    # Minimal spacing, abbreviations

show_labels

Display component labels:

show_labels = true    # "Health: 100/100"
show_labels = false   # "100/100"

show_dividers

Display lines between components:

show_dividers = true    # Lines between sections
show_dividers = false   # No dividers

Available Components

vitals

Health, mana, stamina, spirit bars:

Health:  [████████████████░░░░] 80%
Mana:    [████████████░░░░░░░░] 60%
Stamina: [██████████████████░░] 90%
Spirit:  [████████████████████] 100%

experience

Experience and level info:

Level: 42
Exp: 12,345,678 / 15,000,000
Mind: Clear

stance

Combat stance display:

Stance: Offensive (100/80)

encumbrance

Weight and capacity:

Encumbrance: 45.2 / 100 lbs (45%)

wounds

Injury summary:

Wounds: Head(1) Chest(2) RArm(3)

status

Active status conditions:

Status: Stunned, Poisoned

wealth

Currency display:

Silver: 12,345

position

Character position:

Position: Standing

roundtime

Current roundtime:

RT: 3 seconds

target

Current target:

Target: a massive troll

Display Formats

Standard Vertical

┌─ Dashboard ──────────────────────┐
│ Health:  [████████░░] 80/100     │
│ Mana:    [██████░░░░] 60/100     │
│ Stamina: [█████████░] 90/100     │
│ ──────────────────────────────── │
│ Level 42 | Mind: Clear           │
│ Exp: 12.3M / 15M                 │
│ ──────────────────────────────── │
│ Stance: Offensive (100/80)       │
│ Position: Standing               │
│ ──────────────────────────────── │
│ Wounds: None                     │
│ Status: Clear                    │
└──────────────────────────────────┘

Compact Horizontal

┌─ Status ─────────────────────────────────────────────┐
│ HP:80% MP:60% ST:90% | L42 | Off(100/80) | Standing  │
└──────────────────────────────────────────────────────┘

Grid Layout

┌─ Dashboard ──────────────────────┐
│ Health:  80% │ Level: 42         │
│ Mana:    60% │ Mind: Clear       │
│ Stamina: 90% │ Exp: 12.3M        │
│ ─────────────┼────────────────── │
│ Stance: Off  │ Position: Stand   │
│ RT: 0        │ Silver: 12,345    │
└──────────────────────────────────┘

Examples

Full Status Dashboard

[[windows]]
name = "dashboard"
type = "dashboard"
row = 0
col = 80
width = 40
height = 20
components = [
    "vitals",
    "experience",
    "stance",
    "encumbrance",
    "wounds",
    "status",
    "wealth",
    "position"
]
layout = "vertical"
show_dividers = true
title = "Character Status"

Combat Dashboard

[[windows]]
name = "combat_dash"
type = "dashboard"
row = 0
col = 80
width = 35
height = 12
components = [
    "vitals",
    "stance",
    "roundtime",
    "target",
    "wounds"
]
layout = "vertical"
compact = true
title = "Combat"

Minimal Status Bar

[[windows]]
name = "status_bar"
type = "dashboard"
row = 0
col = 0
width = 80
height = 2
components = ["vitals", "stance", "roundtime"]
layout = "horizontal"
compact = true
show_border = false
show_dividers = false

Experience Focus

[[windows]]
name = "exp_dash"
type = "dashboard"
row = 10
col = 100
width = 25
height = 8
components = ["experience", "wealth"]
layout = "vertical"
title = "Progress"

Hunting Dashboard

[[windows]]
name = "hunting"
type = "dashboard"
row = 0
col = 80
width = 40
height = 15
components = [
    "vitals",
    "stance",
    "wounds",
    "target",
    "roundtime",
    "encumbrance"
]
layout = "grid"
compact = false
title = "Hunting Status"

Component Colors

Customize colors for each component:

[[windows]]
name = "dashboard"
type = "dashboard"

[windows.colors]
# Vital bar colors
health = "#FF0000"
mana = "#0088FF"
stamina = "#00FF00"
spirit = "#FFFF00"

# Status colors
wounded = "#FF8800"
stunned = "#FF0000"
hidden = "#00FFFF"

# Text colors
labels = "#808080"
values = "#FFFFFF"
dividers = "#404040"

Threshold Colors

Change colors based on values:

[windows.health_thresholds]
high = "#00FF00"      # > 75%
medium = "#FFFF00"    # 25-75%
low = "#FF8800"       # 10-25%
critical = "#FF0000"  # < 10%

Data Sources

Dashboard pulls from multiple XML sources:

<dialogData id="health" text="80/100"/>
<dialogData id="mana" text="60/100"/>
<dialogData id="stamina" text="90/100"/>
<dialogData id="spirit" text="100/100"/>

<dialogData id="stance" text="offensive"/>
<indicator id="IconSTANDING" visible="y"/>

<exp>12345678</exp>
<nextExp>15000000</nextExp>

Building Custom Dashboards

Selective Components

Only include what you need:

# Minimalist - just vitals
components = ["vitals"]

# Combat-focused
components = ["vitals", "stance", "wounds", "roundtime"]

# Exploration-focused
components = ["vitals", "encumbrance", "position"]

# Progression-focused
components = ["experience", "wealth"]

Multiple Dashboards

Use different dashboards for different activities:

# Main status (always visible)
[[windows]]
name = "main_dash"
type = "dashboard"
row = 0
col = 80
width = 40
height = 8
components = ["vitals", "stance"]

# Combat details (show during hunting)
[[windows]]
name = "combat_dash"
type = "dashboard"
row = 8
col = 80
width = 40
height = 10
components = ["wounds", "target", "roundtime"]
visible = false  # Toggle via keybind

Troubleshooting

Components not showing

  1. Verify component name is correct
  2. Check game is sending that data
  3. Ensure dashboard has sufficient size

Layout looks wrong

  1. Adjust width/height for layout type
  2. Try different layout option
  3. Enable/disable compact mode

Data not updating

  1. Verify XML data is being received
  2. Check component is correctly configured
  3. Test individual widgets for same data

Performance issues

  1. Reduce number of components
  2. Use compact mode
  3. Increase refresh interval

See Also

Performance Monitor

The performance widget displays real-time performance metrics for debugging and optimization.

Overview

The performance widget shows:

  • Frame rate (FPS)
  • Frame timing statistics
  • Network throughput
  • Parser performance
  • Memory usage
  • Application uptime

Configuration

[[windows]]
name = "performance"
type = "performance"

# Position and size
row = 0
col = 0
width = 35
height = 15

# Which metrics to display
show_fps = true
show_frame_times = true
show_render_times = true
show_ui_times = false
show_wrap_times = false
show_net = true
show_parse = true
show_events = false
show_memory = true
show_lines = true
show_uptime = true
show_jitter = false
show_frame_spikes = false
show_event_lag = false
show_memory_delta = false

Metric Options

Frame Metrics

OptionDescription
show_fpsFrames per second
show_frame_timesFrame time min/avg/max
show_jitterFrame time variance
show_frame_spikesCount of slow frames (>33ms)

Render Metrics

OptionDescription
show_render_timesTotal render duration
show_ui_timesUI widget render time
show_wrap_timesText wrapping time

Network Metrics

OptionDescription
show_netBytes received/sent per second

Parser Metrics

OptionDescription
show_parseParse time and chunks/sec

Event Metrics

OptionDescription
show_eventsEvent processing time
show_event_lagTime since last event

Memory Metrics

OptionDescription
show_memoryEstimated memory usage
show_linesTotal lines buffered
show_memory_deltaMemory change rate

General

OptionDescription
show_uptimeApplication uptime

Display Example

┌─ Performance ──────────────────────┐
│ FPS: 60.0                          │
│ Frame: 0.8/1.2/2.1 ms              │
│ Render: 0.6 ms                     │
│                                    │
│ Net In: 1.2 KB/s                   │
│ Net Out: 0.1 KB/s                  │
│                                    │
│ Parse: 45 µs (12/s)                │
│                                    │
│ Memory: ~2.4 MB                    │
│ Lines: 12,345                      │
│                                    │
│ Uptime: 01:23:45                   │
└────────────────────────────────────┘

Examples

Full Metrics

[[windows]]
name = "performance"
type = "performance"
row = 0
col = 0
width = 35
height = 18
show_fps = true
show_frame_times = true
show_render_times = true
show_ui_times = true
show_wrap_times = true
show_net = true
show_parse = true
show_events = true
show_memory = true
show_lines = true
show_uptime = true
show_jitter = true
show_frame_spikes = true

Minimal FPS

[[windows]]
name = "fps"
type = "performance"
row = 0
col = 0
width = 15
height = 1
show_fps = true
show_frame_times = false
show_render_times = false
show_net = false
show_parse = false
show_memory = false
show_lines = false
show_uptime = false
show_border = false

Network Focus

[[windows]]
name = "network"
type = "performance"
row = 0
col = 100
width = 20
height = 5
show_fps = false
show_net = true
show_parse = true
show_memory = false
title = "Network"

Memory Focus

[[windows]]
name = "memory"
type = "performance"
row = 5
col = 100
width = 20
height = 5
show_fps = false
show_net = false
show_memory = true
show_lines = true
show_memory_delta = true
title = "Memory"

Metric Details

FPS (Frames Per Second)

FPS: 60.0

Target is 60 FPS. Lower values indicate performance issues.

Frame Times

Frame: 0.8/1.2/2.1 ms

Shows min/avg/max frame time over recent samples. Lower is better.

Frame Jitter

Jitter: 0.3 ms

Standard deviation of frame times. Lower means smoother animation.

Frame Spikes

Spikes: 2

Count of frames taking >33ms (below 30 FPS). Should be 0 or very low.

Render Time

Render: 0.6 ms

Time spent rendering all widgets. Should be well under 16ms.

Network

Net In: 1.2 KB/s
Net Out: 0.1 KB/s

Network throughput. High values during combat/activity are normal.

Parse Time

Parse: 45 µs (12/s)

Average XML parse time and chunks processed per second.

Memory

Memory: ~2.4 MB
Lines: 12,345

Estimated memory usage and total lines in all buffers.

Uptime

Uptime: 01:23:45

Time since Two-Face started (HH:MM:SS).

Performance Overhead

The performance widget itself has minimal overhead:

  • Only enabled metrics are collected
  • Collection can be disabled entirely
# In config.toml
[performance]
collect_metrics = false   # Disable all collection

Troubleshooting Performance

Low FPS

  1. Reduce buffer sizes in text windows
  2. Disable unused widgets
  3. Reduce highlight pattern count
  4. Use fast_parse = true for highlights

High Memory

  1. Reduce buffer_size on windows
  2. Close unused tabs
  3. Check for memory leaks (rising delta)

Network Issues

  1. Check connection stability
  2. Monitor for packet loss
  3. Consider bandwidth limitations

See Also

Customization Overview

Two-Face is highly customizable, allowing you to tailor every aspect of your gameplay experience.

Customization Areas

Two-Face can be customized in these areas:

AreaPurposeConfiguration
LayoutsWindow arrangementlayout.toml
ThemesColors and stylingcolors.toml
HighlightsText pattern matchinghighlights.toml
KeybindsKeyboard shortcutskeybinds.toml
SoundsAudio alertsPer-highlight
TTSText-to-speechconfig.toml

Quick Customization Commands

CommandOpens
.layoutLayout editor
.highlightsHighlight browser
.keybindsKeybind browser
.colorsColor palette browser
.themesTheme browser
.window <name>Window editor

Customization Guides

This section covers:

Per-Character Customization

Each character can have unique settings:

~/.two-face/
├── config.toml              # Global defaults
├── layout.toml              # Default layout
├── colors.toml              # Default colors
├── highlights.toml          # Default highlights
├── keybinds.toml            # Default keybinds
└── characters/
    └── MyCharacter/
        ├── layout.toml      # Character-specific layout
        ├── colors.toml      # Character-specific colors
        ├── highlights.toml  # Character-specific highlights
        └── keybinds.toml    # Character-specific keybinds

Configuration Loading Order

  1. Character-specific file (if exists)
  2. Global file (fallback)
  3. Embedded defaults (final fallback)

Example: Profession-Based Layouts

Create different layouts for different professions:

~/.two-face/characters/
├── Warrior/
│   └── layout.toml    # Combat-focused layout
├── Empath/
│   └── layout.toml    # Healing-focused layout
└── Wizard/
    └── layout.toml    # Spell-focused layout

Hot-Reloading

Many settings can be reloaded without restarting:

CommandReloads
.reload colorsColor configuration
.reload highlightsHighlight patterns
.reload keybindsKeyboard shortcuts
.reload layoutWindow layout
.reload configAll configuration

Backup Your Customizations

Before major changes:

# Backup all configuration
cp -r ~/.two-face ~/.two-face-backup

# Backup specific file
cp ~/.two-face/layout.toml ~/.two-face/layout.toml.bak

Sharing Customizations

Share your configurations with others:

  1. Export relevant .toml files
  2. Share via GitHub Gist, Discord, etc.
  3. Others can copy to their ~/.two-face/ directory

The Two-Face community shares layout packs:

  • Hunting layouts
  • Merchant layouts
  • Roleplay layouts
  • Accessibility layouts

Customization Tips

Start Simple

Begin with default settings and customize incrementally:

  1. Play with defaults for a few sessions
  2. Identify pain points
  3. Make one change at a time
  4. Test each change before moving on

Use the Editors

The built-in editors are often easier than editing files:

.layout          # Visual layout editor
.highlights      # Browse and edit highlights
.keybinds        # Browse and edit keybinds
.window main     # Edit main window properties

Keep Notes

Document your customizations:

# In any .toml file, add comments:

# Combat creature highlighting
# Red for dangerous, orange for normal
[creature_dangerous]
pattern = "(?i)\\b(dragon|lich|demon)\\b"
fg = "#FF0000"

Test in Safe Areas

Test new configurations in safe game areas before combat:

  1. Make changes
  2. Reload: .reload highlights
  3. Test with game output
  4. Adjust as needed

See Also

Creating Layouts

Layouts define the arrangement of widgets on your screen. This guide walks you through creating custom layouts.

Layout Basics

A layout is a collection of window definitions in layout.toml:

[[windows]]
name = "main"
type = "text"
row = 0
col = 0
width = 80
height = 40
streams = ["main", "death"]

[[windows]]
name = "health"
type = "progress"
source = "health"
row = 0
col = 80
width = 30
height = 1

Coordinate System

Two-Face uses a row/column grid:

(0,0)────────────────────────→ col
│
│    ┌─────────────┐
│    │  Widget     │
│    │  row=5      │
│    │  col=10     │
│    └─────────────┘
│
↓ row
  • row - Vertical position (0 = top)
  • col - Horizontal position (0 = left)
  • width - Widget width in columns
  • height - Widget height in rows

Percentage-Based Sizing

Use percentages for responsive layouts:

[[windows]]
name = "main"
type = "text"
row = 0
col = 0
width = "60%"    # 60% of screen width
height = "100%"  # Full screen height

[[windows]]
name = "sidebar"
type = "text"
row = 0
col = "60%"      # Start at 60% from left
width = "40%"    # Remaining 40%
height = "100%"

Layout Planning

Step 1: Sketch Your Layout

Draw your desired layout on paper or in a text editor:

┌─────────────────────┬──────────────┐
│                     │   Health     │
│                     ├──────────────┤
│    Main Window      │   Mana       │
│                     ├──────────────┤
│                     │   Compass    │
│                     ├──────────────┤
│                     │   Room       │
├─────────────────────┴──────────────┤
│         Command Input              │
└────────────────────────────────────┘

Step 2: Calculate Dimensions

Determine sizes based on your terminal:

Terminal: 120 columns × 40 rows

Main window: 80 cols × 35 rows (0,0)
Sidebar: 40 cols × 35 rows (0,80)
Command: 120 cols × 5 rows (35,0)

Step 3: Define Windows

Create layout.toml:

# Main game window
[[windows]]
name = "main"
type = "text"
row = 0
col = 0
width = 80
height = 35
streams = ["main", "death"]
buffer_size = 2000

# Health bar
[[windows]]
name = "health"
type = "progress"
source = "health"
row = 0
col = 80
width = 40
height = 1

# Mana bar
[[windows]]
name = "mana"
type = "progress"
source = "mana"
row = 1
col = 80
width = 40
height = 1

# Compass
[[windows]]
name = "compass"
type = "compass"
row = 3
col = 80
width = 20
height = 5

# Room info
[[windows]]
name = "room"
type = "room"
row = 8
col = 80
width = 40
height = 15

# Command input
[[windows]]
name = "input"
type = "command_input"
row = 35
col = 0
width = 120
height = 5

Common Layout Patterns

Full-Width Main

[[windows]]
name = "main"
type = "text"
row = 0
col = 0
width = "100%"
height = "90%"

[[windows]]
name = "input"
type = "command_input"
row = "90%"
col = 0
width = "100%"
height = "10%"

Side Panel

[[windows]]
name = "main"
type = "text"
row = 0
col = 0
width = "70%"
height = "100%"

[[windows]]
name = "sidebar"
type = "tabbed_text"
row = 0
col = "70%"
width = "30%"
height = "100%"
tabs = ["speech", "thoughts", "combat"]

Three-Column

# Left column - Status
[[windows]]
name = "status"
type = "dashboard"
row = 0
col = 0
width = "20%"
height = "100%"

# Center column - Main
[[windows]]
name = "main"
type = "text"
row = 0
col = "20%"
width = "50%"
height = "100%"

# Right column - Info
[[windows]]
name = "info"
type = "tabbed_text"
row = 0
col = "70%"
width = "30%"
height = "100%"

Top/Bottom Split

# Main game (top 70%)
[[windows]]
name = "main"
type = "text"
row = 0
col = 0
width = "100%"
height = "70%"

# Combat log (bottom 30%)
[[windows]]
name = "combat"
type = "text"
row = "70%"
col = 0
width = "100%"
height = "30%"
streams = ["combat"]

Widget Types

Choose the right widget type for each purpose:

TypePurposeExample
textGeneral text displayMain window, logs
tabbed_textMultiple streams in tabsSpeech, thoughts
command_inputCommand entryInput bar
progressBars (health, mana)Vitals
countdownTimers (RT, cast)Roundtime
compassNavigationExits
roomRoom informationRoom panel
handHeld itemsHands display
indicatorStatus iconsConditions
injury_dollBody injuriesWounds
active_effectsBuffs/debuffsSpells
dashboardCombined statusStatus panel
performanceDebug metricsFPS, memory

Window Properties

Common Properties

[[windows]]
name = "example"          # Unique name (required)
type = "text"             # Widget type (required)
row = 0                   # Position
col = 0
width = 40
height = 20

# Visual
title = "My Window"       # Custom title
show_title = true         # Show title bar
show_border = true        # Show border
border_style = "rounded"  # plain, rounded, double

# Colors
bg_color = "#000000"
text_color = "#FFFFFF"
border_color = "#00FFFF"

# Behavior
visible = true            # Initially visible
focusable = true          # Can receive focus

Text Window Properties

[[windows]]
name = "main"
type = "text"
streams = ["main"]        # Stream IDs to display
buffer_size = 1000        # Max lines to keep
wordwrap = true           # Wrap long lines
timestamps = false        # Show timestamps

Progress Bar Properties

[[windows]]
name = "health"
type = "progress"
source = "health"         # health, mana, spirit, stamina
show_label = true         # Show "Health"
show_numbers = true       # Show "85/100"
show_percent = false      # Show "85%"
bar_color = "#FF0000"

Using the Layout Editor

The built-in editor is often easier:

.layout

Features:

  • Visual widget placement
  • Drag to reposition
  • Resize handles
  • Property editing
  • Live preview

Testing Your Layout

Quick Test

  1. Save layout.toml
  2. Run .reload layout
  3. Check appearance
  4. Adjust and repeat

Terminal Size Testing

Test at different sizes:

# Resize terminal and reload
resize -s 30 80    # Small
.reload layout

resize -s 40 120   # Medium
.reload layout

resize -s 50 160   # Large
.reload layout

Troubleshooting

Overlapping Windows

Windows are drawn in order. Later windows cover earlier ones:

# Background windows first
[[windows]]
name = "background"
# ...

# Foreground windows last
[[windows]]
name = "overlay"
# ...

Windows Cut Off

Check terminal size vs layout dimensions:

# Use percentages for safety
width = "50%"   # Instead of: width = 80

# Or check minimum sizes
min_width = 20
min_height = 5

Responsive Issues

Test with percentages:

# Good: Adapts to terminal size
row = 0
col = "60%"
width = "40%"
height = "100%"

# Risky: Fixed size may not fit
row = 0
col = 100
width = 60
height = 50

Layout Examples

Minimalist

[[windows]]
name = "main"
type = "text"
row = 0
col = 0
width = "100%"
height = "95%"
show_border = false

[[windows]]
name = "input"
type = "command_input"
row = "95%"
col = 0
width = "100%"
height = "5%"
show_border = false

Information Dense

See the Hunting Setup Tutorial for a complex combat layout.

Roleplay Focused

See the Roleplay Setup Tutorial for a communication-focused layout.

See Also

Creating Themes

Themes control colors throughout Two-Face. This guide shows how to create custom color schemes.

Theme Basics

Themes are defined in colors.toml:

# UI Colors
[ui]
text_color = "#FFFFFF"
background_color = "#000000"
border_color = "#00FFFF"
focused_border_color = "#FFFF00"

# Text Presets
[presets.speech]
fg = "#53a684"

[presets.monsterbold]
fg = "#a29900"

Color Formats

Two-Face supports multiple color formats:

FormatExampleDescription
Hex RGB#FF5500Standard hex
NamedcrimsonFrom palette
Transparent"-"Clear background

UI Colors

Configuration

[ui]
# Text
text_color = "#FFFFFF"           # Default text
command_echo_color = "#FFFFFF"   # Command echo

# Backgrounds
background_color = "#000000"     # Widget background
textarea_background = "-"        # Input background ("-" = transparent)

# Borders
border_color = "#00FFFF"         # Unfocused borders
focused_border_color = "#FFFF00" # Focused border

# Selection
selection_bg_color = "#4a4a4a"   # Text selection

Example: Dark Theme

[ui]
text_color = "#E0E0E0"
background_color = "#1A1A1A"
border_color = "#333333"
focused_border_color = "#007ACC"
selection_bg_color = "#264F78"

Example: Light Theme

[ui]
text_color = "#333333"
background_color = "#FFFFFF"
border_color = "#CCCCCC"
focused_border_color = "#0066CC"
selection_bg_color = "#ADD6FF"

Text Presets

Presets color semantic text types from the game:

Default Presets

[presets.speech]
fg = "#53a684"       # Player dialogue

[presets.monsterbold]
fg = "#a29900"       # Creature names

[presets.roomName]
fg = "#9BA2B2"
bg = "#395573"       # Room titles

[presets.links]
fg = "#477ab3"       # Clickable objects

[presets.whisper]
fg = "#60b4bf"       # Whispers

[presets.thought]
fg = "#FF8080"       # ESP/thoughts

Custom Preset

[presets.my_custom]
fg = "#FF6600"
bg = "#1A1A1A"

Prompt Colors

Color individual prompt characters:

[[prompt_colors]]
character = "R"         # Roundtime
color = "#FF0000"

[[prompt_colors]]
character = "S"         # Spell RT
color = "#FFFF00"

[[prompt_colors]]
character = "H"         # Hidden
color = "#9370DB"

[[prompt_colors]]
character = ">"         # Ready
color = "#A9A9A9"

Spell Colors

Color spell bars by spell number:

# Minor Spirit (500s)
[[spell_colors]]
spells = [503, 506, 507, 508, 509]
color = "#5c0000"

# Major Spirit (900s)
[[spell_colors]]
spells = [905, 911, 913]
color = "#9370db"

# Ranger (600s)
[[spell_colors]]
spells = [601, 602, 604, 605]
color = "#1c731c"

Color Palette

Define named colors for use in highlights:

[[color_palette]]
name = "danger"
color = "#FF0000"
category = "custom"

[[color_palette]]
name = "warning"
color = "#FFA500"
category = "custom"

[[color_palette]]
name = "success"
color = "#00FF00"
category = "custom"

Use in highlights:

[critical_hit]
pattern = "critical hit"
fg = "danger"      # Resolved from palette

Theme Design Tips

Color Harmony

Use complementary colors:

  • Analogous: Colors next to each other on wheel
  • Complementary: Opposite colors
  • Triadic: Three evenly spaced colors

Contrast

Ensure readable text:

  • Light text on dark background
  • Dark text on light background
  • Minimum 4.5:1 contrast ratio

Consistency

Use a limited color palette:

  • 2-3 accent colors
  • Neutral backgrounds
  • Semantic colors (red=danger, green=success)

Complete Theme Examples

Nord Theme

[ui]
text_color = "#D8DEE9"
background_color = "#2E3440"
border_color = "#3B4252"
focused_border_color = "#88C0D0"
selection_bg_color = "#4C566A"

[presets.speech]
fg = "#A3BE8C"

[presets.monsterbold]
fg = "#D08770"

[presets.roomName]
fg = "#ECEFF4"
bg = "#434C5E"

[presets.links]
fg = "#81A1C1"

[presets.whisper]
fg = "#B48EAD"

[[prompt_colors]]
character = "R"
color = "#BF616A"

[[prompt_colors]]
character = ">"
color = "#616E88"

Solarized Dark

[ui]
text_color = "#839496"
background_color = "#002B36"
border_color = "#073642"
focused_border_color = "#2AA198"
selection_bg_color = "#073642"

[presets.speech]
fg = "#859900"

[presets.monsterbold]
fg = "#CB4B16"

[presets.roomName]
fg = "#93A1A1"
bg = "#073642"

[presets.links]
fg = "#268BD2"

[[prompt_colors]]
character = "R"
color = "#DC322F"

[[prompt_colors]]
character = ">"
color = "#586E75"

High Contrast

[ui]
text_color = "#FFFFFF"
background_color = "#000000"
border_color = "#FFFFFF"
focused_border_color = "#FFFF00"
selection_bg_color = "#0000FF"

[presets.speech]
fg = "#00FF00"

[presets.monsterbold]
fg = "#FF0000"

[presets.roomName]
fg = "#FFFFFF"
bg = "#0000AA"

[presets.links]
fg = "#00FFFF"

[[prompt_colors]]
character = "R"
color = "#FF0000"

[[prompt_colors]]
character = ">"
color = "#FFFFFF"

Monokai

[ui]
text_color = "#F8F8F2"
background_color = "#272822"
border_color = "#49483E"
focused_border_color = "#F92672"
selection_bg_color = "#49483E"

[presets.speech]
fg = "#A6E22E"

[presets.monsterbold]
fg = "#FD971F"

[presets.roomName]
fg = "#F8F8F2"
bg = "#3E3D32"

[presets.links]
fg = "#66D9EF"

[[prompt_colors]]
character = "R"
color = "#F92672"

[[prompt_colors]]
character = ">"
color = "#75715E"

Applying Themes

Hot-Reload

.reload colors

Per-Character

Place theme in character folder:

~/.two-face/characters/MyChar/colors.toml

Creating Variants

Dark/Light Toggle

Create two theme files:

~/.two-face/themes/dark.toml
~/.two-face/themes/light.toml

Switch by copying:

cp ~/.two-face/themes/dark.toml ~/.two-face/colors.toml

Profession Variants

Create profession-specific themes:

~/.two-face/characters/Warrior/colors.toml   # Combat colors
~/.two-face/characters/Empath/colors.toml    # Healing colors

Sharing Themes

  1. Export your colors.toml
  2. Share via GitHub Gist, Discord, etc.
  3. Others copy to their ~/.two-face/ directory
  4. Run .reload colors

See Also

Highlight Patterns

Highlights let you colorize text based on patterns. This guide covers advanced pattern creation.

Pattern Basics

Highlights use regex (regular expressions):

[[highlights]]
name = "creatures"
pattern = "(?i)\\b(goblin|orc|troll)\\b"
fg = "#FF6600"
bold = true

Regex Syntax

Literal Text

# Exact match
pattern = "goblin"

# Multiple words
pattern = "goblin|orc|troll"

Character Classes

# Any digit
pattern = "\\d+"          # Matches: 123, 45, 6789

# Any word character
pattern = "\\w+"          # Matches: word, Word123

# Any whitespace
pattern = "\\s+"          # Matches spaces, tabs

# Custom class
pattern = "[A-Z][a-z]+"   # Matches: Hello, World

Quantifiers

# Zero or more
pattern = "a*"            # Matches: "", a, aa, aaa

# One or more
pattern = "a+"            # Matches: a, aa, aaa

# Zero or one
pattern = "colou?r"       # Matches: color, colour

# Exactly n
pattern = "\\d{3}"        # Matches: 123, 456

# n to m
pattern = "\\d{2,4}"      # Matches: 12, 123, 1234

Anchors

# Start of line
pattern = "^You attack"

# End of line
pattern = "falls dead!$"

# Word boundary
pattern = "\\bgoblin\\b"  # Won't match "goblins" or "hobgoblin"

Groups

# Non-capturing group
pattern = "(?:goblin|orc)"

# Capturing group (for future use)
pattern = "(\\w+) says,"

Modifiers

# Case insensitive
pattern = "(?i)goblin"    # Matches: goblin, Goblin, GOBLIN

Common Patterns

Creature Names

[[highlights]]
name = "creatures"
pattern = "(?i)\\b(goblin|orc|troll|kobold|giant|wolf|bear)s?\\b"
fg = "#FF6600"
bold = true

Player Speech

[[highlights]]
name = "speech"
pattern = "^(\\w+) (says|asks|exclaims|whispers),"
fg = "#00FF00"

Your Character Name

[[highlights]]
name = "my_name"
pattern = "\\bYourCharacterName\\b"
fg = "#FFFF00"
bold = true

Damage Numbers

[[highlights]]
name = "damage"
pattern = "\\b\\d+\\s+points?\\s+of\\s+damage"
fg = "#FF0000"
bold = true

Currency

[[highlights]]
name = "silver"
pattern = "\\d+\\s+silvers?"
fg = "#C0C0C0"

[[highlights]]
name = "gold"
pattern = "\\d+\\s+gold"
fg = "#FFD700"

Room Exits

[[highlights]]
name = "exits"
pattern = "Obvious (paths|exits):"
fg = "#00FFFF"

ESP/Thoughts

[[highlights]]
name = "esp"
pattern = "^\\[.*?\\]"
fg = "#FF00FF"
stream = "thoughts"

Critical Hits

[[highlights]]
name = "critical"
pattern = "(?i)critical|devastating|massive"
fg = "#FF0000"
bg = "#400000"
bold = true

Healing

[[highlights]]
name = "healing"
pattern = "heal|restore|recover"
fg = "#00FF00"

Stream Filtering

Limit patterns to specific streams:

[[highlights]]
name = "combat"
pattern = "You (hit|miss)"
fg = "#FF0000"
stream = "main"

[[highlights]]
name = "esp_highlight"
pattern = "\\[ESP\\]"
fg = "#00FFFF"
stream = "thoughts"

Priority System

Higher priority wins when multiple patterns match:

[[highlights]]
name = "important"
pattern = "critical"
fg = "#FF0000"
priority = 100          # Checked first

[[highlights]]
name = "general"
pattern = ".*"
fg = "#808080"
priority = 0            # Checked last

Fast Parse Mode

For simple literal patterns, use fast_parse:

[[highlights]]
name = "names"
pattern = "Bob|Alice|Charlie"
fg = "#FFFF00"
fast_parse = true       # Uses Aho-Corasick algorithm

When to use fast_parse:

  • Pattern is literal strings only
  • Pattern has many alternatives
  • High-frequency matching needed

When NOT to use:

  • Pattern uses regex features
  • Pattern needs word boundaries
  • Pattern uses anchors

Special Characters

Escape these with \\:

. * + ? ^ $ { } [ ] ( ) | \
# Match literal period
pattern = "Mr\\. Smith"

# Match literal asterisk
pattern = "\\*\\*emphasis\\*\\*"

# Match literal parentheses
pattern = "\\(optional\\)"

Sounds

Add sounds to highlights:

[[highlights]]
name = "whisper_alert"
pattern = "whispers to you"
fg = "#00FFFF"
sound = "~/.two-face/sounds/whisper.wav"

Squelch (Hide)

Hide matching text entirely:

[[highlights]]
name = "spam_filter"
pattern = "A gentle breeze|The sun|Birds chirp"
squelch = true

Redirect

Send matching text to different window:

[[highlights]]
name = "combat_redirect"
pattern = "You (attack|hit|miss|dodge)"
redirect = "combat"
redirect_mode = "copy"  # or "move"

Testing Patterns

Test in Browser

.highlights

Navigate to your highlight and check the preview.

Test with Game Output

  1. Add highlight
  2. Run .reload highlights
  3. Trigger the pattern in-game
  4. Verify appearance

Online Regex Testers

Use regex101.com or similar to test patterns before adding.

Performance Tips

  1. Use fast_parse for simple patterns
  2. Use word boundaries \b to limit matching
  3. Be specific - avoid .* when possible
  4. Limit stream - use stream = "main" when appropriate
  5. Keep count low - fewer than 100 patterns recommended

Debugging

Pattern Not Matching

  1. Test pattern in regex101.com
  2. Check escaping (use \\ not \)
  3. Check case sensitivity
  4. Verify stream setting

Pattern Too Broad

Add boundaries:

# Too broad
pattern = "hit"        # Matches "white", "smith"

# Better
pattern = "\\bhit\\b"  # Only matches "hit" as word

Escaping Issues

TOML strings need double backslashes:

# Wrong
pattern = "\d+"

# Correct
pattern = "\\d+"

Complete Example

# Combat Highlights
[[highlights]]
name = "damage_taken"
pattern = "(?i)(strikes|hits|bites|claws|slashes)\\s+you"
fg = "#FF4444"
bold = true
priority = 80

[[highlights]]
name = "damage_dealt"
pattern = "You\\s+(strike|hit|slash|stab)"
fg = "#44FF44"
priority = 80

[[highlights]]
name = "critical"
pattern = "(?i)critical|devastating"
fg = "#FF0000"
bg = "#400000"
bold = true
priority = 100

# Creatures
[[highlights]]
name = "creatures"
pattern = "(?i)\\b(goblin|orc|troll|kobold|wolf|bear|rat)s?\\b"
fg = "#FF8800"
bold = true
priority = 50

# Social
[[highlights]]
name = "my_name"
pattern = "\\bYourCharacter\\b"
fg = "#FFFF00"
bold = true
priority = 90

[[highlights]]
name = "friends"
pattern = "\\b(Alice|Bob|Charlie)\\b"
fg = "#00FF00"
fast_parse = true
priority = 60

# Currency
[[highlights]]
name = "silver"
pattern = "\\d+\\s+silvers?"
fg = "#C0C0C0"
bold = true

[[highlights]]
name = "treasure"
pattern = "(?i)(gold|gems?|jewel|treasure)"
fg = "#FFD700"

See Also

Keybind Actions

Two-Face supports extensive keyboard customization. This guide covers all available keybind actions.

Keybind Basics

Keybinds are defined in keybinds.toml:

[keybinds."ctrl+l"]
action = "layout_editor"

[keybinds."f1"]
action = "help"

[keybinds."ctrl+1"]
macro = "attack target"

Key Format

Modifiers

ModifierFormat
Controlctrl+
Altalt+
Shiftshift+
Meta/Supermeta+

Special Keys

KeyFormat
F1-F12f1 to f12
Enterenter
Escapeescape
Tabtab
Backspacebackspace
Deletedelete
Insertinsert
Homehome
Endend
Page Uppageup
Page Downpagedown
Arrowsup, down, left, right
Spacespace

Combination Examples

"ctrl+s"         # Ctrl + S
"ctrl+shift+s"   # Ctrl + Shift + S
"alt+f4"         # Alt + F4
"f1"             # F1 alone
"shift+tab"      # Shift + Tab

Action Categories

ActionDescription
scroll_upScroll window up
scroll_downScroll window down
page_upPage up
page_downPage down
scroll_topJump to top
scroll_bottomJump to bottom
focus_nextFocus next window
focus_prevFocus previous window
focus_mainFocus main window
focus_inputFocus command input

Window Actions

ActionDescription
layout_editorOpen layout editor
window_editorOpen window editor
toggle_borderToggle window border
toggle_titleToggle window title
maximize_windowMaximize current window
restore_windowRestore window size
close_windowClose current popup

Editor Actions

ActionDescription
highlight_browserOpen highlight browser
keybind_browserOpen keybind browser
color_browserOpen color browser
theme_browserOpen theme browser
settings_editorOpen settings

Input Actions

ActionDescription
history_prevPrevious command history
history_nextNext command history
clear_inputClear command input
submit_commandSubmit current command
cancelCancel current operation

Text Actions

ActionDescription
select_allSelect all text
copyCopy selection
cutCut selection
pastePaste clipboard
searchOpen search
search_nextFind next
search_prevFind previous

Tab Actions

ActionDescription
next_tabNext tab
prev_tabPrevious tab
tab_1 to tab_9Jump to tab by number
close_tabClose current tab

Application Actions

ActionDescription
quitExit application
helpShow help
reload_configReload all config
reload_colorsReload colors
reload_highlightsReload highlights
reload_keybindsReload keybinds
reload_layoutReload layout

Game Actions

ActionDescription
reconnectReconnect to server
disconnectDisconnect

Macros

Send game commands:

[keybinds."ctrl+1"]
macro = "attack target"

[keybinds."ctrl+2"]
macro = "stance defensive"

[keybinds."ctrl+h"]
macro = "hide"

Multi-Command Macros

Separate with semicolons:

[keybinds."f5"]
macro = "stance offensive;attack target"

Macro with Variables

Use $input for prompted input:

[keybinds."ctrl+g"]
macro = "go $input"
# Prompts for input, then sends "go <input>"

Default Keybinds

Global

KeyAction
EscapeCancel/close popup
Ctrl+CCopy
Ctrl+VPaste
Ctrl+XCut
Ctrl+ASelect all
KeyAction
Page UpScroll up
Page DownScroll down
HomeScroll to top
EndScroll to bottom
TabFocus next
Shift+TabFocus prev

Command Input

KeyAction
EnterSubmit command
UpHistory prev
DownHistory next
Ctrl+LClear input

Browsers

KeyAction
Up/kNavigate up
Down/jNavigate down
EnterSelect
DeleteDelete item
EscapeClose

Context-Specific Keybinds

Some keybinds only work in specific contexts:

# Only in Normal mode
[keybinds."ctrl+l"]
action = "layout_editor"
mode = "normal"

# Only in Navigation mode
[keybinds."j"]
action = "scroll_down"
mode = "navigation"

Priority Layers

Keybinds are processed in order:

  1. Global keybinds - Always active
  2. Menu keybinds - In popup/editor
  3. User keybinds - Game mode
  4. Default input - Character insertion

Examples

Combat Setup

# Quick attacks
[keybinds."f1"]
macro = "attack target"

[keybinds."f2"]
macro = "stance offensive;attack target"

[keybinds."f3"]
macro = "stance defensive"

[keybinds."f4"]
macro = "hide"

# Movement
[keybinds."numpad8"]
macro = "go north"

[keybinds."numpad2"]
macro = "go south"

[keybinds."numpad4"]
macro = "go west"

[keybinds."numpad6"]
macro = "go east"

Spellcasting

[keybinds."ctrl+1"]
macro = "prep 101;cast"

[keybinds."ctrl+2"]
macro = "prep 103;cast"

[keybinds."ctrl+3"]
macro = "prep 107;cast target"
[keybinds."ctrl+shift+up"]
action = "scroll_top"

[keybinds."ctrl+shift+down"]
action = "scroll_bottom"

[keybinds."alt+1"]
action = "focus_main"

[keybinds."alt+2"]
macro = ".focus combat"

Quick Commands

[keybinds."ctrl+l"]
macro = "look"

[keybinds."ctrl+i"]
macro = "inventory"

[keybinds."ctrl+e"]
macro = "experience"

[keybinds."ctrl+w"]
macro = "wealth"

Using the Keybind Browser

.keybinds

Features:

  • Browse all keybinds
  • Add new keybinds
  • Edit existing keybinds
  • Delete keybinds
  • Filter by type

Conflict Resolution

If two keybinds use the same key:

  1. Higher priority wins
  2. Later definition overwrites earlier
  3. Context-specific beats general

Check for conflicts:

.keybinds

Look for duplicate key combos.

Troubleshooting

Keybind Not Working

  1. Check for conflicts
  2. Verify key format is correct
  3. Check context/mode setting
  4. Reload: .reload keybinds

Key Not Detected

Some keys may not be captured:

  • System shortcuts (Alt+Tab, etc.)
  • Terminal shortcuts
  • Media keys

Modifier Issues

Some terminals don’t report all modifiers:

  • Try different modifier combos
  • Use function keys (F1-F12) as alternatives

See Also

Sound Alerts

Two-Face can play sounds when specific text patterns match, providing audio alerts for important events.

Sound Basics

Add sounds to highlight patterns:

[[highlights]]
name = "whisper_alert"
pattern = "whispers to you"
fg = "#00FFFF"
sound = "~/.two-face/sounds/whisper.wav"

Supported Formats

FormatExtensionNotes
WAV.wavBest compatibility
MP3.mp3Requires codec
OGG.oggGood compression

WAV is recommended for reliable playback.

Sound Configuration

Basic Sound

[[highlights]]
name = "my_alert"
pattern = "important event"
sound = "/path/to/sound.wav"

Volume Control

[[highlights]]
name = "quiet_alert"
pattern = "minor event"
sound = "/path/to/sound.wav"
volume = 0.5    # 0.0 to 1.0

Sound-Only (No Visual)

[[highlights]]
name = "audio_only"
pattern = "background event"
sound = "/path/to/sound.wav"
# No fg/bg colors - visual unchanged

Sound File Management

Directory Structure

~/.two-face/
└── sounds/
    ├── whisper.wav
    ├── attack.wav
    ├── death.wav
    ├── loot.wav
    └── custom/
        └── my_sounds.wav

Creating the Sounds Directory

mkdir -p ~/.two-face/sounds

Sound Sources

  • System sounds - Copy from OS sound themes
  • Online libraries - freesound.org, soundsnap.com
  • Create your own - Record with Audacity
  • Game packs - MUD sound packs

Common Alert Sounds

Combat Alerts

# Being attacked
[[highlights]]
name = "attack_received"
pattern = "(strikes|hits|bites|claws) you"
fg = "#FF4444"
sound = "~/.two-face/sounds/hit.wav"

# Near death
[[highlights]]
name = "low_health"
pattern = "You feel weak"
fg = "#FF0000"
bg = "#400000"
sound = "~/.two-face/sounds/warning.wav"
volume = 1.0

# Death
[[highlights]]
name = "death"
pattern = "You have died"
fg = "#FFFFFF"
bg = "#FF0000"
sound = "~/.two-face/sounds/death.wav"

Social Alerts

# Whispers
[[highlights]]
name = "whisper"
pattern = "whispers,"
fg = "#00FFFF"
sound = "~/.two-face/sounds/whisper.wav"
volume = 0.7

# Your name mentioned
[[highlights]]
name = "name_mention"
pattern = "\\bYourCharacter\\b"
fg = "#FFFF00"
sound = "~/.two-face/sounds/mention.wav"

# Group invite
[[highlights]]
name = "group_invite"
pattern = "invites you to join"
fg = "#00FF00"
sound = "~/.two-face/sounds/invite.wav"

Loot Alerts

# Treasure found
[[highlights]]
name = "treasure"
pattern = "(?i)(gold|gems|treasure|chest)"
fg = "#FFD700"
sound = "~/.two-face/sounds/loot.wav"
volume = 0.5

# Rare item
[[highlights]]
name = "rare_item"
pattern = "(?i)(legendary|artifact|ancient)"
fg = "#FF00FF"
sound = "~/.two-face/sounds/rare.wav"

Status Alerts

# Stunned
[[highlights]]
name = "stunned"
pattern = "You are stunned"
fg = "#FFFF00"
sound = "~/.two-face/sounds/stun.wav"

# Poisoned
[[highlights]]
name = "poisoned"
pattern = "(?i)poison|venom"
fg = "#00FF00"
sound = "~/.two-face/sounds/poison.wav"

Global Sound Settings

In config.toml

[sound]
enabled = true           # Master switch
volume = 0.8             # Master volume (0.0-1.0)
concurrent_limit = 3     # Max simultaneous sounds

Disable All Sounds

[sound]
enabled = false

Sound Priority

When multiple patterns match:

[[highlights]]
name = "critical_alert"
pattern = "critical"
sound = "~/.two-face/sounds/critical.wav"
priority = 100          # Plays first

[[highlights]]
name = "minor_alert"
pattern = "hit"
sound = "~/.two-face/sounds/hit.wav"
priority = 50           # May be skipped if concurrent_limit reached

Rate Limiting

Prevent sound spam:

[[highlights]]
name = "combat_sound"
pattern = "You attack"
sound = "~/.two-face/sounds/attack.wav"
sound_cooldown = 500    # Milliseconds between plays

Platform Notes

Windows

  • WAV files play natively
  • No additional dependencies needed

Linux

Requires audio system:

# PulseAudio (most distros)
pactl list short sinks

# ALSA
aplay -l

macOS

  • WAV files play natively
  • Uses Core Audio

Troubleshooting

No Sound

  1. Check [sound] enabled = true
  2. Verify file path is correct
  3. Test file with system player
  4. Check volume settings

Delayed Sound

  1. Use WAV instead of MP3
  2. Reduce file size
  3. Check system audio latency

Wrong Sound

  1. Check pattern matching correctly
  2. Verify highlight priority
  3. Check for pattern conflicts

Creating Custom Sounds

Using Audacity

  1. Open Audacity
  2. Record or import audio
  3. Trim to desired length (0.5-2 seconds recommended)
  4. Export as WAV (16-bit PCM)
  5. Save to ~/.two-face/sounds/

Sound Guidelines

  • Duration: 0.5-2 seconds
  • Format: WAV 16-bit PCM
  • Sample rate: 44100 Hz
  • Channels: Mono or Stereo
  • File size: Keep under 500KB

Sound Packs

Creating a Sound Pack

  1. Create themed sounds
  2. Package in zip/tar
  3. Include README with highlight configs
  4. Share with community

Installing a Sound Pack

  1. Extract to ~/.two-face/sounds/
  2. Add highlights from pack’s config
  3. Run .reload highlights

See Also

TTS Setup

Two-Face supports text-to-speech (TTS) for accessibility and hands-free notifications.

Overview

TTS features:

  • Read game text aloud
  • Announce specific events
  • Screen reader compatibility
  • Multiple voice options

Configuration

Basic Setup

In config.toml:

[tts]
enabled = true
voice = "default"
rate = 1.0           # Speaking rate (0.5-2.0)
volume = 1.0         # Volume (0.0-1.0)

Voice Selection

[tts]
voice = "default"    # System default
# Or specific voice name from your system
voice = "Microsoft David"    # Windows
voice = "Alex"               # macOS
voice = "espeak"             # Linux

Stream Configuration

Control which streams are spoken:

[tts]
enabled = true
streams = ["main", "speech", "thoughts"]  # Streams to read

# Or exclude specific streams
exclude_streams = ["combat", "logons"]

Stream Options

StreamDescription
mainMain game output
speechPlayer dialogue
thoughtsESP/telepathy
combatCombat messages
deathDeath notices
logonsLogin/logout
familiarFamiliar messages

Pattern-Based TTS

Speak only when patterns match:

[[tts_patterns]]
name = "whisper_announce"
pattern = "whispers to you"
message = "You received a whisper"

[[tts_patterns]]
name = "death_announce"
pattern = "You have died"
message = "Warning: You have died"

[[tts_patterns]]
name = "stun_announce"
pattern = "You are stunned"
speak_match = true    # Speak the matched text

Pattern Properties

[[tts_patterns]]
name = "my_pattern"
pattern = "regex pattern"

# What to speak:
message = "Custom message"    # Speak this text
speak_match = true            # Speak matched text
speak_line = true             # Speak entire line

# Control:
priority = 100                # Higher = more important
rate = 1.5                    # Override speaking rate
volume = 1.0                  # Override volume

Platform Setup

Windows

Windows has built-in TTS (SAPI):

  1. TTS works out of the box
  2. Configure voices in Windows Settings → Time & Language → Speech
  3. Install additional voices from Microsoft Store

Available voices:

  • Microsoft David (Male)
  • Microsoft Zira (Female)
  • Additional language packs
[tts]
enabled = true
voice = "Microsoft David"

macOS

macOS uses built-in speech synthesis:

  1. TTS works out of the box
  2. Configure in System Preferences → Accessibility → Spoken Content
  3. Download additional voices

Available voices:

  • Alex (Male)
  • Samantha (Female)
  • Many international voices
[tts]
enabled = true
voice = "Alex"

Linux

Linux requires a TTS engine:

eSpeak

# Debian/Ubuntu
sudo apt install espeak

# Fedora
sudo dnf install espeak

# Arch
sudo pacman -S espeak
[tts]
enabled = true
engine = "espeak"
voice = "en"

Festival

# Debian/Ubuntu
sudo apt install festival

# Configure
sudo apt install festvox-kallpc16k
[tts]
enabled = true
engine = "festival"

Speech Dispatcher

# Debian/Ubuntu
sudo apt install speech-dispatcher
spd-say "test"
[tts]
enabled = true
engine = "speechd"

Rate and Pitch

Speaking Rate

[tts]
rate = 1.0      # Normal speed
rate = 0.5      # Half speed (slower)
rate = 2.0      # Double speed (faster)

Pitch (if supported)

[tts]
pitch = 1.0     # Normal pitch
pitch = 0.8     # Lower pitch
pitch = 1.2     # Higher pitch

Accessibility Features

Screen Reader Compatibility

Two-Face works with screen readers:

  • NVDA (Windows)
  • JAWS (Windows)
  • VoiceOver (macOS)
  • Orca (Linux)
[accessibility]
screen_reader_mode = true
announce_focus_changes = true
announce_new_content = true

High Contrast Mode

Combine with TTS:

[ui]
high_contrast = true

[tts]
enabled = true
announce_colors = false    # Don't read color codes

TTS Commands

Toggle TTS

.tts on
.tts off
.tts toggle

Change Rate

.tts rate 1.5

Change Volume

.tts volume 0.8

Test TTS

.tts test "This is a test message"

List Voices

.tts voices

Common Patterns

Combat Awareness

[[tts_patterns]]
name = "attack"
pattern = "(strikes|hits|bites) you"
message = "Under attack"
priority = 90

[[tts_patterns]]
name = "stunned"
pattern = "You are stunned"
message = "Stunned"
priority = 100

[[tts_patterns]]
name = "roundtime"
pattern = "Roundtime: (\\d+)"
message = "Roundtime"

Social Notifications

[[tts_patterns]]
name = "whisper"
pattern = "(\\w+) whispers,"
speak_match = true

[[tts_patterns]]
name = "name_mention"
pattern = "\\bYourCharacter\\b"
message = "Someone mentioned you"
[[tts_patterns]]
name = "room_name"
pattern = "^\\[.*?\\]$"
speak_line = true

[[tts_patterns]]
name = "exits"
pattern = "Obvious (paths|exits):"
speak_line = true

Interruption

Control how new speech interrupts current:

[tts]
interrupt_mode = "queue"     # Wait for current to finish
interrupt_mode = "interrupt" # Stop current, start new
interrupt_mode = "priority"  # Interrupt only if higher priority

Troubleshooting

No Sound

  1. Check [tts] enabled = true
  2. Verify system TTS works:
    • Windows: PowerShell: Add-Type -AssemblyName System.Speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).Speak("test")
    • macOS: say "test"
    • Linux: espeak "test"
  3. Check volume settings

Wrong Voice

  1. List available voices: .tts voices
  2. Set correct voice name exactly
  3. Voice names are case-sensitive on some systems

Too Fast/Slow

Adjust rate:

[tts]
rate = 0.8    # Slower
rate = 1.2    # Faster

Cuts Off

Increase buffer or reduce interrupt:

[tts]
interrupt_mode = "queue"

See Also

Automation Overview

Two-Face provides automation features to streamline repetitive tasks and enhance gameplay efficiency.

Automation Features

FeatureDescriptionUse Case
CmdlistsContext menu commandsRight-click actions
MacrosMulti-command sequencesComplex actions
TriggersEvent-based responsesAutomatic reactions
ScriptingAdvanced automationCustom logic

Quick Start

Simple Macro

# In keybinds.toml
[keybinds."f1"]
macro = "attack target"

[keybinds."f2"]
macro = "stance defensive;hide"

Context Menu (Cmdlist)

# In cmdlist.toml
[[cmdlist]]
noun = "sword"
commands = ["look", "get", "drop", "put in backpack"]

Trigger

# In triggers.toml
[[triggers]]
pattern = "You are stunned"
command = ".say Stunned!"

Automation Philosophy

Enhancement, Not Replacement

Two-Face automation is designed to:

  • Reduce tedium - Automate repetitive tasks
  • Speed up common actions - Quick access to frequent commands
  • Provide alerts - Notify of important events

Not Designed For

  • Fully automated hunting
  • AFK gameplay
  • Botting

Automation Levels

Level 1: Keybind Macros

Simple command sequences bound to keys:

[keybinds."ctrl+1"]
macro = "prep 101;cast"

Best for: Frequent, simple commands

Level 2: Context Menus

Right-click commands for objects:

[[cmdlist]]
noun = "creature"
commands = ["attack", "look", "assess"]

Best for: Object-specific actions

Level 3: Triggers

Automatic responses to game events:

[[triggers]]
pattern = "roundtime"
command = ".notify"

Best for: Status monitoring, alerts

Level 4: Scripts (Future)

Complex conditional logic:

-- Future scripting API
on_event("stun", function()
    if health() < 50 then
        send("flee")
    end
end)

Best for: Complex decision-making

Safety Guidelines

Avoid Detection Issues

  • Don’t automate too quickly
  • Add reasonable delays
  • Keep human-like timing
  • Don’t automate core gameplay

Respect Game Rules

Check game policies on automation:

  • Some automation is allowed
  • Unattended automation may not be
  • When in doubt, don’t automate

Test Safely

Test automation in safe areas:

  • Town squares
  • Non-combat zones
  • With backup plans

Automation Guides

This section covers:

Common Automation Tasks

Combat Efficiency

  • Quick spell casting (macros)
  • Target selection (cmdlist)
  • Status alerts (triggers)

Inventory Management

  • Quick get/drop commands
  • Container management
  • Sorting actions
  • Direction macros
  • Room scanning
  • Exit shortcuts

Social

  • Quick emotes
  • Response macros
  • Greeting templates

See Also

Command Lists (Cmdlists)

Command lists provide context menu actions for clickable objects in the game.

Overview

When you right-click on a game object (creature, item, player), Two-Face shows a context menu with relevant commands.

┌─────────────────┐
│ a rusty sword   │
├─────────────────┤
│ Look            │
│ Get             │
│ Drop            │
│ Put in backpack │
│ Appraise        │
└─────────────────┘

Configuration

Basic Cmdlist

In cmdlist.toml:

[[cmdlist]]
noun = "sword"
commands = ["look", "get", "drop", "appraise"]

Cmdlist Properties

[[cmdlist]]
noun = "creature"       # Object noun to match
commands = [            # Available commands
    "attack",
    "look",
    "assess"
]
priority = 100          # Higher = appears first
category = "combat"     # Optional grouping

Noun Matching

Exact Match

[[cmdlist]]
noun = "sword"
commands = ["look", "get"]

Matches: “sword”, not “longsword”

Partial Match

[[cmdlist]]
noun = "sword"
match_mode = "contains"
commands = ["look", "get"]

Matches: “sword”, “longsword”, “shortsword”

Pattern Match

[[cmdlist]]
noun = "(?i).*sword.*"
match_mode = "regex"
commands = ["look", "get"]

Matches any noun containing “sword” (case-insensitive)

Command Syntax

Simple Command

commands = ["look", "get", "drop"]
# Results in: look {noun}, get {noun}, drop {noun}

Custom Command Format

commands = [
    "look",
    "get",
    "put in backpack:put {noun} in my backpack",
    "give to:give {noun} to {target}"
]

Format: label:command or just command

Variables

VariableDescription
{noun}Object noun
{exist}Object ID
{target}Prompted target
{input}Prompted input

Categories

Default Categories

# Creature commands
[[cmdlist]]
category = "creature"
noun = ".*"
match_mode = "regex"
commands = ["attack", "look", "assess"]
priority = 10

# Container commands
[[cmdlist]]
category = "container"
noun = "(?i)(backpack|bag|pouch|sack)"
match_mode = "regex"
commands = ["look in", "open", "close"]
priority = 20

Custom Categories

[[cmdlist]]
category = "alchemy"
noun = "(?i)(vial|potion|herb)"
match_mode = "regex"
commands = [
    "look",
    "get",
    "drink:drink my {noun}",
    "mix:mix my {noun}"
]

Common Cmdlists

Creatures

[[cmdlist]]
category = "combat"
noun = ".*"  # Default for all creatures
match_mode = "regex"
commands = [
    "attack",
    "attack left",
    "attack right",
    "look",
    "assess"
]
priority = 10

Weapons

[[cmdlist]]
category = "weapon"
noun = "(?i)(sword|dagger|axe|mace|staff)"
match_mode = "regex"
commands = [
    "look",
    "get",
    "drop",
    "wield",
    "sheath",
    "appraise"
]

Containers

[[cmdlist]]
category = "container"
noun = "(?i)(backpack|bag|pouch|cloak|sack)"
match_mode = "regex"
commands = [
    "look in",
    "open",
    "close",
    "get from:get {input} from my {noun}"
]

Players

[[cmdlist]]
category = "player"
noun = "^[A-Z][a-z]+$"  # Capitalized names
match_mode = "regex"
commands = [
    "look",
    "smile",
    "bow",
    "whisper:whisper {noun} {input}",
    "give:give {input} to {noun}"
]

Herbs/Alchemy

[[cmdlist]]
category = "herb"
noun = "(?i)(acantha|ambrominas|basal|cactacae)"
match_mode = "regex"
commands = [
    "look",
    "get",
    "eat",
    "put in:put my {noun} in my {input}"
]

Gems/Treasure

[[cmdlist]]
category = "treasure"
noun = "(?i)(gem|jewel|coin|gold|silver)"
match_mode = "regex"
commands = [
    "look",
    "get",
    "appraise",
    "sell"
]

Add visual separators:

[[cmdlist]]
noun = "sword"
commands = [
    "look",
    "get",
    "---",           # Separator
    "wield",
    "sheath",
    "---",
    "appraise",
    "sell"
]

Create nested menus:

[[cmdlist]]
noun = "sword"
commands = [
    "look",
    "get",
    "Combat>attack,attack left,attack right",
    "Container>put in backpack:put {noun} in my backpack,put in sack:put {noun} in my sack"
]

Priority System

Higher priority cmdlists appear first:

# Specific override
[[cmdlist]]
noun = "magic sword"
commands = ["invoke", "look", "get"]
priority = 100         # High priority

# General fallback
[[cmdlist]]
noun = "(?i).*sword.*"
match_mode = "regex"
commands = ["look", "get"]
priority = 10          # Lower priority

Two-Face extracts object data from game XML:

<a exist="12345" noun="sword">a rusty sword</a>
  • exist: Object ID for direct commands
  • noun: Used for cmdlist matching
  • Text: Display name

Direct Commands

Use object ID for precise targeting:

[[cmdlist]]
noun = "sword"
commands = [
    "look",
    "_inspect {exist}",      # Uses object ID
    "get #{exist}",          # Direct reference
]

Testing Cmdlists

  1. Save cmdlist.toml
  2. Run .reload cmdlist
  3. Right-click on matching object
  4. Verify menu appears correctly

Troubleshooting

  1. Check noun matches object exactly
  2. Try match_mode = "contains"
  3. Verify cmdlist.toml syntax
  4. Run .reload cmdlist

Wrong Commands

  1. Check priority order
  2. Verify pattern matching
  3. Test with exact noun match first

Variables Not Replaced

  1. Check variable syntax {noun}, {exist}
  2. Ensure object has required data
  3. Check for typos in variable names

Complete Example

# cmdlist.toml

# Combat creatures
[[cmdlist]]
category = "combat"
noun = ".*"
match_mode = "regex"
commands = [
    "attack",
    "attack left",
    "attack right",
    "---",
    "look",
    "assess"
]
priority = 5

# Weapons
[[cmdlist]]
category = "weapon"
noun = "(?i)(sword|dagger|axe|mace|staff|bow)"
match_mode = "regex"
commands = [
    "look",
    "get",
    "---",
    "wield",
    "sheath",
    "---",
    "appraise"
]
priority = 20

# Containers
[[cmdlist]]
category = "container"
noun = "(?i)(backpack|bag|pouch|cloak|sack|chest)"
match_mode = "regex"
commands = [
    "look in",
    "open",
    "close",
    "---",
    "get from:get {input} from my {noun}"
]
priority = 20

# Currency
[[cmdlist]]
category = "currency"
noun = "(?i)(silver|gold|coins?)"
match_mode = "regex"
commands = [
    "look",
    "get",
    "count"
]
priority = 15

# Players
[[cmdlist]]
category = "player"
noun = "^[A-Z][a-z]+$"
match_mode = "regex"
commands = [
    "look",
    "---",
    "smile",
    "wave",
    "bow",
    "---",
    "whisper:whisper {noun} {input}",
    "give:give {input} to {noun}"
]
priority = 25

See Also

Macros

Macros allow you to bind multiple commands to a single key press.

Overview

Macros send game commands when you press a key:

# In keybinds.toml
[keybinds."f1"]
macro = "attack target"

[keybinds."ctrl+1"]
macro = "prep 101;cast"

Basic Macros

Single Command

[keybinds."f1"]
macro = "attack target"

Press F1 → Sends “attack target” to game

Multiple Commands

Separate commands with semicolons:

[keybinds."f2"]
macro = "stance offensive;attack target"

Press F2 → Sends both commands in sequence

Command Sequences

Combat Sequence

[keybinds."f5"]
macro = "stance offensive;attack target;stance defensive"

Spell Casting

[keybinds."ctrl+1"]
macro = "prep 101;cast"

[keybinds."ctrl+2"]
macro = "prep 103;cast target"

[keybinds."ctrl+3"]
macro = "incant 107"

Movement

[keybinds."numpad8"]
macro = "go north"

[keybinds."numpad2"]
macro = "go south"

[keybinds."numpad4"]
macro = "go west"

[keybinds."numpad6"]
macro = "go east"

[keybinds."numpad5"]
macro = "out"

Delays

Add delays between commands (in milliseconds):

[keybinds."f5"]
macro = "prep 101;{500};cast"
# Wait 500ms between prep and cast

Delay Syntax

macro = "command1;{1000};command2"
# {1000} = 1 second delay

Example: Careful Spellcasting

[keybinds."f6"]
macro = "prep 901;{2000};cast target"
# Prep spell, wait 2 seconds for mana, cast

Variables

Input Prompt

[keybinds."ctrl+g"]
macro = "go $input"

Press Ctrl+G → Prompts for direction → Sends “go

Target Variable

[keybinds."f1"]
macro = "attack $target"

Uses current target if set, or prompts.

Last Target

[keybinds."f2"]
macro = "attack $lasttarget"

Attacks the last targeted creature.

Conditional Macros

With Input Check

[keybinds."f5"]
macro = "$input:prep $input;cast"
# Only executes if input provided

Multiple Inputs

[keybinds."f6"]
macro = "give $input1 to $input2"
# Prompts for two inputs

Common Macro Patterns

Quick Attacks

[keybinds."f1"]
macro = "attack target"

[keybinds."f2"]
macro = "attack left target"

[keybinds."f3"]
macro = "attack right target"

[keybinds."f4"]
macro = "feint target"

Defensive Moves

[keybinds."f5"]
macro = "stance defensive"

[keybinds."f6"]
macro = "hide"

[keybinds."f7"]
macro = "evade"

Stance Cycling

[keybinds."ctrl+up"]
macro = "stance offensive"

[keybinds."ctrl+down"]
macro = "stance defensive"

[keybinds."ctrl+left"]
macro = "stance neutral"

Looting

[keybinds."ctrl+l"]
macro = "search;loot"

[keybinds."ctrl+shift+l"]
macro = "search;loot;skin"

Information

[keybinds."i"]
macro = "inventory"

[keybinds."ctrl+i"]
macro = "inventory full"

[keybinds."ctrl+e"]
macro = "experience"

[keybinds."ctrl+w"]
macro = "wealth"

Healing

# Empath healing
[keybinds."ctrl+h"]
macro = "transfer $target"

# Herb usage
[keybinds."ctrl+1"]
macro = "get acantha from my herb pouch;eat my acantha"

[keybinds."ctrl+2"]
macro = "get basal from my herb pouch;eat my basal"

Social

[keybinds."ctrl+s"]
macro = "smile"

[keybinds."ctrl+b"]
macro = "bow"

[keybinds."ctrl+w"]
macro = "wave"

[keybinds."ctrl+g"]
macro = "greet $target"

Macro Organization

By Activity

# === COMBAT ===
[keybinds."f1"]
macro = "attack target"

[keybinds."f2"]
macro = "stance defensive"

# === SPELLS ===
[keybinds."ctrl+1"]
macro = "prep 101;cast"

# === MOVEMENT ===
[keybinds."numpad8"]
macro = "go north"

# === SOCIAL ===
[keybinds."ctrl+s"]
macro = "smile"

By Profession

Create character-specific keybinds:

~/.two-face/characters/Warrior/keybinds.toml
~/.two-face/characters/Wizard/keybinds.toml

Advanced Macros

Complex Combat Routine

[keybinds."f10"]
macro = "stance offensive;{100};attack target;{500};stance defensive"

Merchant Interaction

[keybinds."ctrl+m"]
macro = "order $input;buy"

Container Management

[keybinds."ctrl+p"]
macro = "put $input in my backpack"

[keybinds."ctrl+shift+p"]
macro = "get $input from my backpack"

Troubleshooting

Macro Not Executing

  1. Check key format is correct
  2. Verify keybinds.toml syntax
  3. Run .reload keybinds
  4. Check for key conflicts

Commands Out of Order

Add delays:

macro = "command1;{500};command2"

Input Prompt Not Appearing

  1. Check $input syntax
  2. Verify focus is on game
  3. Check for conflicting keybinds

Multiple Commands Failing

Some games have command limits:

  • Add delays between commands
  • Break into separate macros
  • Check game’s command queue limit

Best Practices

  1. Keep macros simple - Complex macros are harder to debug
  2. Add delays when needed - Prevents command flooding
  3. Test in safe areas - Verify before combat use
  4. Document your macros - Add comments to keybinds.toml
  5. Use consistent keys - Group related macros logically

See Also

Triggers

Triggers automatically execute commands when specific text patterns appear in the game.

Overview

Triggers watch for patterns and respond:

# In triggers.toml
[[triggers]]
pattern = "You are stunned"
command = ".say Stunned!"

[[triggers]]
pattern = "falls dead"
command = "search;loot"

Basic Triggers

Simple Trigger

[[triggers]]
name = "stun_alert"
pattern = "You are stunned"
command = ".notify Stunned!"

Multiple Commands

[[triggers]]
name = "death_loot"
pattern = "falls dead"
command = "search;loot"

Trigger Properties

[[triggers]]
name = "my_trigger"           # Unique name
pattern = "regex pattern"     # Pattern to match
command = "game command"      # Command to execute

# Optional properties
enabled = true                # Enable/disable
priority = 100                # Higher = checked first
stream = "main"               # Limit to specific stream
cooldown = 1000               # Minimum ms between triggers
category = "combat"           # Grouping

Pattern Syntax

Triggers use regex patterns:

Literal Text

pattern = "You are stunned"

Case Insensitive

pattern = "(?i)you are stunned"

Word Boundaries

pattern = "\\bstunned\\b"

Capture Groups

pattern = "(\\w+) falls dead"
command = "search #{noun}"

Multiple Patterns

pattern = "(stun|web|prone)"

Command Options

Game Commands

command = "attack target"

Client Commands

command = ".notify Alert!"      # Two-Face command
command = ".sound alert.wav"    # Play sound
command = ".tts Danger!"        # Text-to-speech

Multiple Commands

command = "search;loot;skin"

With Delay

command = "prep 101;{500};cast"

Captured Variables

Use captured groups in commands:

[[triggers]]
pattern = "(\\w+) whispers,"
command = ".notify Whisper from $1"
# $1 = captured name

Variable Reference

VariableDescription
$0Entire match
$1First capture group
$2Second capture group
$nNth capture group

Stream Filtering

Limit triggers to specific streams:

[[triggers]]
pattern = "ESP"
command = ".notify ESP received"
stream = "thoughts"

[[triggers]]
pattern = "attack"
command = ".beep"
stream = "combat"

Cooldowns

Prevent rapid-fire triggers:

[[triggers]]
pattern = "You are hit"
command = ".sound hit.wav"
cooldown = 500     # Only trigger once per 500ms

Common Triggers

Combat Alerts

# Stunned
[[triggers]]
name = "stun_alert"
pattern = "You are stunned"
command = ".notify Stunned!"
category = "combat"

# Webbed
[[triggers]]
name = "web_alert"
pattern = "webs stick to you"
command = ".notify Webbed!"
category = "combat"

# Low health
[[triggers]]
name = "low_health"
pattern = "You feel weak"
command = ".notify Low Health!;stance defensive"
category = "combat"
priority = 100

Social Alerts

# Whispers
[[triggers]]
name = "whisper"
pattern = "(\\w+) whispers,"
command = ".notify Whisper from $1"
category = "social"

# Name mentioned
[[triggers]]
name = "name_mention"
pattern = "\\bYourCharacter\\b"
command = ".sound mention.wav"
category = "social"

Loot Triggers

# Auto-search on kill
[[triggers]]
name = "auto_search"
pattern = "falls dead"
command = "search"
cooldown = 1000
category = "loot"

# Treasure alert
[[triggers]]
name = "treasure"
pattern = "(?i)treasure|gold|gems"
command = ".notify Treasure!"
category = "loot"

Status Triggers

# Roundtime tracker
[[triggers]]
name = "roundtime"
pattern = "Roundtime: (\\d+)"
command = ".countdown $1"
category = "status"

# Hidden status
[[triggers]]
name = "hidden"
pattern = "You melt into the shadows"
command = ".notify Hidden"
category = "status"

Priority System

Higher priority triggers are checked first:

[[triggers]]
name = "critical_danger"
pattern = "dragon"
command = "flee"
priority = 100      # Highest priority

[[triggers]]
name = "general_alert"
pattern = "attacks you"
command = ".beep"
priority = 10       # Lower priority

Conditional Triggers

With Capture Check

[[triggers]]
pattern = "(\\w+) says, \"(.+)\""
command = ".log $1: $2"
# Only triggers if both captures match

Negative Lookahead

pattern = "(?!You )\\w+ attacks"
# Matches "Goblin attacks" but not "You attack"

Enable/Disable

In Configuration

[[triggers]]
name = "my_trigger"
pattern = "something"
command = "respond"
enabled = false     # Disabled by default

Via Commands

.trigger enable my_trigger
.trigger disable my_trigger
.trigger toggle my_trigger

Trigger Groups

.trigger enable combat    # Enable all combat triggers
.trigger disable loot     # Disable all loot triggers

Testing Triggers

  1. Add trigger to triggers.toml
  2. Run .reload triggers
  3. Look for matching text in game
  4. Verify trigger fires

Debug Mode

.trigger debug on

Shows when triggers match.

Safety Considerations

Avoid Automation Abuse

  • Don’t automate core gameplay
  • Keep human-like delays
  • Don’t trigger in loops
  • Use cooldowns

Test Thoroughly

  • Test in safe areas
  • Verify pattern accuracy
  • Check for false positives
  • Monitor for issues

Complete Example

# triggers.toml

# === COMBAT ALERTS ===
[[triggers]]
name = "stun_alert"
pattern = "(?i)you are stunned"
command = ".notify Stunned!;.sound stun.wav"
category = "combat"
priority = 100
cooldown = 1000

[[triggers]]
name = "web_alert"
pattern = "(?i)webs? (stick|entangle)"
command = ".notify Webbed!"
category = "combat"
priority = 90

[[triggers]]
name = "prone_alert"
pattern = "(?i)knock.*down|fall.*prone"
command = ".notify Prone!"
category = "combat"
priority = 90

# === HEALTH ALERTS ===
[[triggers]]
name = "low_health"
pattern = "(?i)feel (weak|faint|dizzy)"
command = ".notify Low Health!;.sound warning.wav"
category = "health"
priority = 100

[[triggers]]
name = "death"
pattern = "You have died"
command = ".notify DEAD!;.sound death.wav"
category = "health"
priority = 100

# === SOCIAL ===
[[triggers]]
name = "whisper"
pattern = "(\\w+) whispers,"
command = ".notify Whisper from $1;.sound whisper.wav"
category = "social"
stream = "main"

[[triggers]]
name = "name_mention"
pattern = "\\bYourCharacter\\b"
command = ".sound mention.wav"
category = "social"
cooldown = 5000

# === LOOT ===
[[triggers]]
name = "auto_search"
pattern = "falls dead"
command = "search"
category = "loot"
cooldown = 2000
enabled = false    # Disabled by default

# === STATUS ===
[[triggers]]
name = "roundtime"
pattern = "Roundtime: (\\d+)"
command = ".rt $1"
category = "status"

See Also

Scripting (Future)

Note: Advanced scripting is a planned feature. This document describes the intended design.

Overview

Two-Face will support a scripting API for complex automation beyond simple triggers and macros.

Planned Features

Event-Based Scripts

-- React to game events
on_event("stun", function(duration)
    notify("Stunned for " .. duration .. " seconds")
    if health() < 50 then
        send("flee")
    end
end)

on_event("death", function()
    notify("You have died!")
    -- Auto-release logic
end)

State Access

-- Access game state
if health() < 30 then
    send("hide")
end

if mana() > 50 and not has_spell("Spirit Shield") then
    send("prep 107;cast")
end

Timers

-- Delayed actions
after(5000, function()
    send("look")
end)

-- Repeating actions
every(60000, function()
    if not hidden() then
        send("perception")
    end
end)

Variables

-- Persistent variables
set_var("kill_count", get_var("kill_count", 0) + 1)

if get_var("kill_count") >= 100 then
    notify("100 kills reached!")
end

Language Options

Lua

Lightweight, embeddable, well-suited for game scripting:

function on_combat_start(enemy)
    if enemy.name:match("dragon") then
        send("flee")
    else
        send("attack " .. enemy.noun)
    end
end

Rhai

Rust-native scripting language:

fn on_combat_start(enemy) {
    if enemy.name.contains("dragon") {
        send("flee");
    } else {
        send("attack " + enemy.noun);
    }
}

Planned API

Core Functions

FunctionDescription
send(cmd)Send command to game
notify(msg)Show notification
log(msg)Write to log
sleep(ms)Pause execution

State Functions

FunctionDescription
health()Current health
max_health()Maximum health
mana()Current mana
stamina()Current stamina
spirit()Current spirit

Status Functions

FunctionDescription
stunned()Is character stunned?
hidden()Is character hidden?
prone()Is character prone?
roundtime()Current roundtime

Room Functions

FunctionDescription
room_name()Current room name
room_id()Current room ID
exits()Available exits
creatures()Creatures in room

Spell Functions

FunctionDescription
has_spell(name)Check active spell
spell_time(name)Time remaining
prepared_spell()Currently prepared

Example Scripts

Auto-Hide When Wounded

-- Auto-hide when health drops
on_event("health_change", function(current, max)
    local percent = (current / max) * 100
    if percent < 30 and not hidden() and not stunned() then
        send("hide")
    end
end)

Spell Manager

-- Keep defensive spells up
local defensive_spells = {
    {spell = 107, name = "Spirit Shield"},
    {spell = 103, name = "Spirit Defense"},
}

every(10000, function()
    for _, s in ipairs(defensive_spells) do
        if not has_spell(s.name) and mana() > 20 then
            wait_for_rt()
            send("incant " .. s.spell)
            return  -- One spell at a time
        end
    end
end)

Combat Assistant

-- Simple combat script
local target = nil

on_event("creature_enter", function(creature)
    if target == nil then
        target = creature
        notify("Targeting: " .. creature.name)
    end
end)

on_event("creature_death", function(creature)
    if target and target.id == creature.id then
        send("search")
        target = nil
    end
end)

on_key("f1", function()
    if target then
        wait_for_rt()
        send("attack " .. target.noun)
    else
        notify("No target")
    end
end)

Herb Monitor

-- Track herb usage
local herbs_used = {
    acantha = 0,
    basal = 0,
    cactacae = 0,
}

on_pattern("eat.*acantha", function()
    herbs_used.acantha = herbs_used.acantha + 1
    if herbs_used.acantha % 10 == 0 then
        notify("Used " .. herbs_used.acantha .. " acantha")
    end
end)

Script Management

Loading Scripts

.script load my_script.lua
.script reload my_script
.script unload my_script

Listing Scripts

.script list

Script Directory

~/.two-face/scripts/
├── combat.lua
├── spells.lua
├── utils.lua
└── character/
    └── my_char.lua

Safety Features

Sandboxing

Scripts will be sandboxed:

  • No file system access
  • No network access
  • No shell commands
  • Limited API surface

Rate Limiting

-- Commands are rate-limited
send("attack")  -- Works
send("attack")  -- Queued
send("attack")  -- Queued
-- Actual sends spaced out

Kill Switch

.script stop     # Stop all scripts
.script pause    # Pause execution
.script resume   # Resume execution

Current Alternatives

Until scripting is implemented, use:

  1. Triggers - Pattern-based automation
  2. Macros - Key-bound command sequences
  3. Cmdlists - Context menu commands
  4. External scripts - Lich scripts (via proxy)

Contributing

Interested in scripting implementation? Check:

See Also

Tutorials

Step-by-step guides for common Two-Face setups and workflows.

Tutorial Philosophy

These tutorials guide you through complete workflows, explaining not just what to do but why each choice matters. Each tutorial builds a functional setup from scratch.

Available Tutorials

Your First Layout

Start here! Create a basic but functional layout:

  • Understanding the coordinate system
  • Placing essential widgets
  • Testing and iteration
  • Saving and loading

Time: 30 minutes | Difficulty: Beginner

Hunting Setup

Optimized layout for combat and hunting:

  • Combat-focused widget arrangement
  • Health/mana monitoring
  • Quick action macros
  • Creature targeting

Time: 45 minutes | Difficulty: Intermediate

Merchant Setup

Layout for trading and crafting:

  • Inventory management
  • Transaction logging
  • Crafting timers
  • Trade window optimization

Time: 30 minutes | Difficulty: Intermediate

Roleplay Setup

Immersive layout for roleplaying:

  • Expanded text areas
  • Chat organization
  • Minimal HUD elements
  • Atmosphere preservation

Time: 30 minutes | Difficulty: Beginner

Minimal Layout

Clean, distraction-free interface:

  • Essential widgets only
  • Maximum text space
  • Keyboard-centric design
  • Low resource usage

Time: 20 minutes | Difficulty: Beginner

Accessibility Setup

Screen reader and high contrast configurations:

  • TTS integration
  • High contrast themes
  • Keyboard navigation
  • Large text options

Time: 45 minutes | Difficulty: Intermediate

Tutorial Structure

Each tutorial follows the same pattern:

  1. Goal - What we’re building
  2. Prerequisites - What you need first
  3. Step-by-Step - Detailed instructions
  4. Configuration - Complete config files
  5. Testing - Verification steps
  6. Customization - Ways to adapt it
  7. Troubleshooting - Common issues

Before You Start

Prerequisites

  1. Two-Face installed and running
  2. Basic familiarity with TOML syntax
  3. A text editor
  4. Game account for testing

Backup First

Before modifying configurations:

# Backup your configs
cp -r ~/.two-face ~/.two-face.backup

Configuration Location

All config files live in ~/.two-face/:

~/.two-face/
├── config.toml      # Main settings
├── layout.toml      # Widget positions
├── colors.toml      # Theme colors
├── highlights.toml  # Text patterns
└── keybinds.toml    # Key mappings

Learning Path

Complete Beginner

  1. Your First Layout
  2. Minimal Layout
  3. Roleplay Setup

Combat Focus

  1. Your First Layout
  2. Hunting Setup

Accessibility Needs

  1. Accessibility Setup
  2. Minimal Layout

Trading/Crafting

  1. Your First Layout
  2. Merchant Setup

Tutorial Tips

Take Your Time

Don’t rush through tutorials. Understanding each step helps you customize later.

Experiment

Tutorials provide starting points. Try variations to find what works for you.

Ask Questions

If something’s unclear:

Share Your Setups

Created something cool? Consider sharing it with the community!

See Also

Your First Layout

Create a functional Two-Face layout from scratch in 30 minutes.

Goal

By the end of this tutorial, you’ll have:

  • A main text window for game output
  • Health, mana, and stamina bars
  • A compass for navigation
  • A command input area
  • Basic keybinds for common actions

Prerequisites

  • Two-Face installed
  • Connection to game (via Lich or direct)
  • Text editor for config files

Step 1: Understanding the Grid

Two-Face uses a percentage-based grid where:

  • x and y are positions (0-100)
  • width and height are sizes (0-100)
  • Origin (0, 0) is top-left corner
(0,0)─────────────────────────(100,0)
  │                                │
  │     Your Terminal Window       │
  │                                │
(0,100)───────────────────────(100,100)

Step 2: Plan Your Layout

Before coding, sketch your layout:

┌─────────────────────────────────────┐
│  Room Info (top bar)                │
├──────────────────────────┬──────────┤
│                          │ Compass  │
│                          ├──────────┤
│     Main Text            │ Health   │
│                          │ Mana     │
│                          │ Stamina  │
├──────────────────────────┴──────────┤
│  Command Input (bottom)             │
└─────────────────────────────────────┘

Step 3: Create the Main Text Window

Open ~/.two-face/layout.toml and start fresh:

# Main game text window
[[widgets]]
type = "text"
name = "main"
title = "Game"
x = 0
y = 5
width = 75
height = 85
streams = ["main", "combat", "room"]
scrollback = 5000

What this does:

  • Creates a text window named “main”
  • Positions it from 0% left, 5% from top
  • Takes 75% width, 85% height
  • Shows main game output, combat, and room descriptions
  • Keeps 5000 lines of history

Step 4: Add the Room Info Bar

Add a room info display at the top:

# Room information bar
[[widgets]]
type = "room"
name = "room_info"
x = 0
y = 0
width = 100
height = 5
show_exits = true
show_creatures = true

Step 5: Add the Compass

Position the compass in the right sidebar:

# Navigation compass
[[widgets]]
type = "compass"
name = "compass"
x = 76
y = 5
width = 24
height = 15
style = "unicode"
clickable = true

Style options:

  • "ascii" - Basic ASCII characters
  • "unicode" - Unicode arrows (recommended)
  • "minimal" - Just letters (N, S, E, W)

Step 6: Add Vital Bars

Add health, mana, and stamina bars below the compass:

# Health bar
[[widgets]]
type = "progress"
name = "health"
title = "Health"
x = 76
y = 21
width = 24
height = 3
data_source = "vitals.health"
color = "health"
show_text = true

# Mana bar
[[widgets]]
type = "progress"
name = "mana"
title = "Mana"
x = 76
y = 25
width = 24
height = 3
data_source = "vitals.mana"
color = "mana"
show_text = true

# Stamina bar
[[widgets]]
type = "progress"
name = "stamina"
title = "Stamina"
x = 76
y = 29
width = 24
height = 3
data_source = "vitals.stamina"
color = "stamina"
show_text = true

Step 7: Add Command Input

Add the command input at the bottom:

# Command input
[[widgets]]
type = "command_input"
name = "input"
x = 0
y = 91
width = 100
height = 9
history_size = 500
prompt = "> "

Step 8: Complete Layout File

Your complete layout.toml should look like:

# Two-Face Layout - Basic Setup
# Created following the "Your First Layout" tutorial

# Room information bar
[[widgets]]
type = "room"
name = "room_info"
x = 0
y = 0
width = 100
height = 5
show_exits = true
show_creatures = true

# Main game text window
[[widgets]]
type = "text"
name = "main"
title = "Game"
x = 0
y = 5
width = 75
height = 85
streams = ["main", "combat", "room"]
scrollback = 5000

# Navigation compass
[[widgets]]
type = "compass"
name = "compass"
x = 76
y = 5
width = 24
height = 15
style = "unicode"
clickable = true

# Health bar
[[widgets]]
type = "progress"
name = "health"
title = "Health"
x = 76
y = 21
width = 24
height = 3
data_source = "vitals.health"
color = "health"
show_text = true

# Mana bar
[[widgets]]
type = "progress"
name = "mana"
title = "Mana"
x = 76
y = 25
width = 24
height = 3
data_source = "vitals.mana"
color = "mana"
show_text = true

# Stamina bar
[[widgets]]
type = "progress"
name = "stamina"
title = "Stamina"
x = 76
y = 29
width = 24
height = 3
data_source = "vitals.stamina"
color = "stamina"
show_text = true

# Command input
[[widgets]]
type = "command_input"
name = "input"
x = 0
y = 91
width = 100
height = 9
history_size = 500
prompt = "> "

Step 9: Add Basic Keybinds

Create ~/.two-face/keybinds.toml:

# Basic navigation
[keybinds."numpad8"]
macro = "north"

[keybinds."numpad2"]
macro = "south"

[keybinds."numpad4"]
macro = "west"

[keybinds."numpad6"]
macro = "east"

[keybinds."numpad7"]
macro = "northwest"

[keybinds."numpad9"]
macro = "northeast"

[keybinds."numpad1"]
macro = "southwest"

[keybinds."numpad3"]
macro = "southeast"

[keybinds."numpad5"]
macro = "out"

# Quick actions
[keybinds."f1"]
macro = "look"

[keybinds."f2"]
macro = "inventory"

[keybinds."f3"]
macro = "experience"

# Widget navigation
[keybinds."tab"]
action = "next_widget"

[keybinds."shift+tab"]
action = "prev_widget"

[keybinds."page_up"]
action = "scroll_up"

[keybinds."page_down"]
action = "scroll_down"

# Escape to focus input
[keybinds."escape"]
action = "focus_input"

Step 10: Test Your Layout

  1. Save both files

  2. Reload configuration:

    .reload
    
  3. Verify widgets appear:

    • Main text window shows game output
    • Compass displays available exits
    • Vital bars show current values
    • Command input accepts typing
  4. Test keybinds:

    • Press numpad keys for movement
    • Press F1-F3 for quick commands
    • Use Tab to cycle widgets

Testing Checklist

  • Main window shows text
  • Room info updates when moving
  • Compass shows exits
  • Health bar reflects actual health
  • Mana bar works
  • Stamina bar works
  • Can type commands
  • Command history works (up/down arrows)
  • Keybinds execute commands

Customization Ideas

Adjust Sizes

If the sidebar feels too wide:

# Narrower sidebar (20% instead of 24%)
width = 80    # Main text
x = 81        # Sidebar widgets
width = 19    # Sidebar width

Add More Bars

Spirit bar for some professions:

[[widgets]]
type = "progress"
name = "spirit"
title = "Spirit"
x = 76
y = 33
width = 24
height = 3
data_source = "vitals.spirit"
color = "spirit"
show_text = true

Add Roundtime Display

[[widgets]]
type = "countdown"
name = "roundtime"
title = "RT"
x = 76
y = 37
width = 24
height = 3
data_source = "roundtime"

Add Status Indicators

[[widgets]]
type = "indicator"
name = "status"
x = 76
y = 41
width = 24
height = 8
indicators = ["hidden", "stunned", "webbed", "prone", "kneeling"]

Troubleshooting

Widgets Overlap

Check your coordinate math:

  • Widget end = x + width (or y + height)
  • Ensure widgets don’t exceed 100%
  • Leave small gaps between widgets

Bars Not Updating

Verify data source names:

  • vitals.health
  • vitals.mana
  • vitals.stamina
  • vitals.spirit

Compass Not Showing Exits

The game must send exit data. Check that:

  • You’re logged in
  • You’ve moved to a room
  • Brief mode isn’t hiding room info

Keybinds Not Working

  1. Check key syntax ("numpad8" not "num8")
  2. Verify no conflicts with system keys
  3. Reload config: .reload keybinds

Next Steps

Congratulations! You’ve created your first layout.

Consider exploring:

See Also

Hunting Setup

Create an optimized layout for combat and hunting with real-time status monitoring.

Goal

Build a hunting-focused layout with:

  • Combat text in a dedicated window
  • Real-time vital monitoring with alerts
  • Quick-access combat macros
  • Target tracking
  • Roundtime and casttime displays
  • Injury awareness

Prerequisites

  • Completed Your First Layout
  • Understanding of basic keybinds
  • Active hunting character

Layout Overview

┌────────────────────────────────────────────────────────────┐
│ Room: [name]                    Exits: N S E W    [Compass]│
├────────────────────────────────────┬───────────────────────┤
│                                    │ ████████████ Health   │
│                                    │ ████████░░░░ Mana     │
│       Main Game Text               │ ████████████ Stamina  │
│                                    ├───────────────────────┤
│                                    │ RT: 3s    Cast: --    │
│                                    ├───────────────────────┤
├────────────────────────────────────┤ [Hidden] [Prone]      │
│       Combat Log                   │ [Stunned] [Webbed]    │
│       (filtered combat text)       ├───────────────────────┤
│                                    │ Head:  ░░░░░          │
│                                    │ Chest: ██░░░          │
│                                    │ Arms:  ░░░░░          │
├────────────────────────────────────┴───────────────────────┤
│ > [command input]                                          │
└────────────────────────────────────────────────────────────┘

Step 1: Create the Layout

Create ~/.two-face/layout.toml:

# Hunting Layout - Combat Optimized
# Designed for active hunting with real-time monitoring

# ═══════════════════════════════════════════════════════════
# TOP BAR - Room and Navigation
# ═══════════════════════════════════════════════════════════

[[widgets]]
type = "room"
name = "room_info"
x = 0
y = 0
width = 85
height = 4
show_exits = true
show_creatures = true
creature_highlight = true

[[widgets]]
type = "compass"
name = "compass"
x = 86
y = 0
width = 14
height = 7
style = "unicode"
clickable = true

# ═══════════════════════════════════════════════════════════
# MAIN AREA - Game Text
# ═══════════════════════════════════════════════════════════

[[widgets]]
type = "text"
name = "main"
title = "Game"
x = 0
y = 4
width = 65
height = 50
streams = ["main", "room", "thoughts", "speech"]
scrollback = 5000
auto_scroll = true

# ═══════════════════════════════════════════════════════════
# COMBAT WINDOW - Filtered Combat Text
# ═══════════════════════════════════════════════════════════

[[widgets]]
type = "text"
name = "combat"
title = "Combat"
x = 0
y = 55
width = 65
height = 35
streams = ["combat"]
scrollback = 1000
auto_scroll = true
border_color = "red"

# ═══════════════════════════════════════════════════════════
# RIGHT SIDEBAR - Status Panel
# ═══════════════════════════════════════════════════════════

# Vital Bars
[[widgets]]
type = "progress"
name = "health"
title = "HP"
x = 66
y = 8
width = 34
height = 3
data_source = "vitals.health"
color = "health"
show_text = true
show_percentage = true

[[widgets]]
type = "progress"
name = "mana"
title = "MP"
x = 66
y = 12
width = 34
height = 3
data_source = "vitals.mana"
color = "mana"
show_text = true
show_percentage = true

[[widgets]]
type = "progress"
name = "stamina"
title = "ST"
x = 66
y = 16
width = 34
height = 3
data_source = "vitals.stamina"
color = "stamina"
show_text = true
show_percentage = true

[[widgets]]
type = "progress"
name = "spirit"
title = "SP"
x = 66
y = 20
width = 34
height = 3
data_source = "vitals.spirit"
color = "spirit"
show_text = true
show_percentage = true

# Timing Displays
[[widgets]]
type = "countdown"
name = "roundtime"
title = "RT"
x = 66
y = 24
width = 16
height = 3
data_source = "roundtime"
warning_threshold = 2
critical_threshold = 0

[[widgets]]
type = "countdown"
name = "casttime"
title = "Cast"
x = 83
y = 24
width = 17
height = 3
data_source = "casttime"

# Status Indicators
[[widgets]]
type = "indicator"
name = "status"
title = "Status"
x = 66
y = 28
width = 34
height = 10
indicators = [
    "hidden",
    "invisible",
    "stunned",
    "webbed",
    "prone",
    "kneeling",
    "sitting",
    "bleeding",
    "poisoned"
]
columns = 2

# Injury Display
[[widgets]]
type = "injury_doll"
name = "injuries"
title = "Injuries"
x = 66
y = 39
width = 34
height = 20
style = "bars"
show_scars = false

# Active Effects
[[widgets]]
type = "active_effects"
name = "effects"
title = "Active"
x = 66
y = 60
width = 34
height = 30
show_duration = true
group_by_type = true

# ═══════════════════════════════════════════════════════════
# BOTTOM - Command Input
# ═══════════════════════════════════════════════════════════

[[widgets]]
type = "command_input"
name = "input"
x = 0
y = 91
width = 100
height = 9
history_size = 500
prompt = "› "
show_roundtime = true

Step 2: Combat Keybinds

Create combat-focused ~/.two-face/keybinds.toml:

# ═══════════════════════════════════════════════════════════
# COMBAT MACROS - Function Keys
# ═══════════════════════════════════════════════════════════

# Basic attacks
[keybinds."f1"]
macro = "attack target"

[keybinds."f2"]
macro = "attack left target"

[keybinds."f3"]
macro = "attack right target"

[keybinds."f4"]
macro = "feint target"

# Defensive actions
[keybinds."f5"]
macro = "stance defensive"

[keybinds."f6"]
macro = "hide"

[keybinds."f7"]
macro = "stance offensive"

[keybinds."f8"]
macro = "stance neutral"

# Combat utilities
[keybinds."f9"]
macro = "search"

[keybinds."f10"]
macro = "search;loot"

[keybinds."f11"]
macro = "skin"

[keybinds."f12"]
macro = "aim target"

# ═══════════════════════════════════════════════════════════
# SPELL MACROS - Ctrl+Number
# ═══════════════════════════════════════════════════════════

[keybinds."ctrl+1"]
macro = "incant 101"

[keybinds."ctrl+2"]
macro = "incant 102"

[keybinds."ctrl+3"]
macro = "incant 103"

[keybinds."ctrl+4"]
macro = "incant 104"

[keybinds."ctrl+5"]
macro = "incant 105"

[keybinds."ctrl+6"]
macro = "incant 106"

[keybinds."ctrl+7"]
macro = "incant 107"

[keybinds."ctrl+8"]
macro = "incant 108"

[keybinds."ctrl+9"]
macro = "incant 109"

[keybinds."ctrl+0"]
macro = "incant 110"

# ═══════════════════════════════════════════════════════════
# MOVEMENT - Numpad
# ═══════════════════════════════════════════════════════════

[keybinds."numpad8"]
macro = "north"

[keybinds."numpad2"]
macro = "south"

[keybinds."numpad4"]
macro = "west"

[keybinds."numpad6"]
macro = "east"

[keybinds."numpad7"]
macro = "northwest"

[keybinds."numpad9"]
macro = "northeast"

[keybinds."numpad1"]
macro = "southwest"

[keybinds."numpad3"]
macro = "southeast"

[keybinds."numpad5"]
macro = "out"

[keybinds."numpad_plus"]
macro = "go gate"

[keybinds."numpad_minus"]
macro = "go door"

# ═══════════════════════════════════════════════════════════
# EMERGENCY ACTIONS - Shift+Function
# ═══════════════════════════════════════════════════════════

[keybinds."shift+f1"]
macro = "flee"

[keybinds."shift+f2"]
macro = "stance defensive;hide"

[keybinds."shift+f3"]
macro = "get acantha from my pouch;eat my acantha"

[keybinds."shift+f4"]
macro = "get basal from my pouch;eat my basal"

[keybinds."shift+f5"]
macro = "get cactacae from my pouch;eat my cactacae"

# ═══════════════════════════════════════════════════════════
# TARGETING - Alt+Keys
# ═══════════════════════════════════════════════════════════

[keybinds."alt+1"]
macro = "target first"

[keybinds."alt+2"]
macro = "target second"

[keybinds."alt+3"]
macro = "target third"

[keybinds."alt+t"]
macro = "target $input"

[keybinds."alt+c"]
macro = "target clear"

# ═══════════════════════════════════════════════════════════
# WIDGET NAVIGATION
# ═══════════════════════════════════════════════════════════

[keybinds."tab"]
action = "next_widget"

[keybinds."shift+tab"]
action = "prev_widget"

[keybinds."page_up"]
action = "scroll_up"

[keybinds."page_down"]
action = "scroll_down"

[keybinds."home"]
action = "scroll_top"

[keybinds."end"]
action = "scroll_bottom"

[keybinds."escape"]
action = "focus_input"

Step 3: Combat Highlights

Add to ~/.two-face/highlights.toml:

# ═══════════════════════════════════════════════════════════
# COMBAT HIGHLIGHTING
# ═══════════════════════════════════════════════════════════

# Critical hits
[[highlights]]
pattern = "\\*\\* .+ \\*\\*"
fg = "bright_red"
bold = true

# Your attacks
[[highlights]]
pattern = "You (swing|thrust|slash|punch|kick)"
fg = "green"

# Enemy attacks
[[highlights]]
pattern = "(swings|thrusts|slashes|punches|kicks) at you"
fg = "red"

# Misses
[[highlights]]
pattern = "(miss|barely miss|narrowly miss)"
fg = "dark_gray"
italic = true

# Death messages
[[highlights]]
pattern = "falls dead"
fg = "bright_yellow"
bold = true

# ═══════════════════════════════════════════════════════════
# STATUS ALERTS
# ═══════════════════════════════════════════════════════════

# Stunned
[[highlights]]
pattern = "(?i)you are stunned"
fg = "black"
bg = "yellow"
bold = true

# Webbed
[[highlights]]
pattern = "(?i)webs? (stick|entangle)"
fg = "black"
bg = "magenta"
bold = true

# Prone
[[highlights]]
pattern = "(?i)(knock|fall).*?(down|prone)"
fg = "black"
bg = "cyan"
bold = true

# ═══════════════════════════════════════════════════════════
# DAMAGE NUMBERS
# ═══════════════════════════════════════════════════════════

# High damage
[[highlights]]
pattern = "\\b([5-9]\\d|[1-9]\\d{2,}) points? of damage"
fg = "bright_green"
bold = true

# Medium damage
[[highlights]]
pattern = "\\b([2-4]\\d) points? of damage"
fg = "green"

# Low damage
[[highlights]]
pattern = "\\b([0-1]\\d) points? of damage"
fg = "dark_green"

# ═══════════════════════════════════════════════════════════
# LOOT
# ═══════════════════════════════════════════════════════════

# Coins
[[highlights]]
pattern = "\\d+ (silver|gold|copper)"
fg = "bright_yellow"

# Gems
[[highlights]]
pattern = "(?i)(gem|jewel|diamond|ruby|emerald|sapphire)"
fg = "bright_cyan"

# Treasure
[[highlights]]
pattern = "(?i)treasure"
fg = "bright_yellow"
bold = true

Step 4: Combat Triggers

Add to ~/.two-face/triggers.toml:

# ═══════════════════════════════════════════════════════════
# COMBAT ALERTS
# ═══════════════════════════════════════════════════════════

# Stunned alert
[[triggers]]
name = "stun_alert"
pattern = "(?i)you are stunned"
command = ".notify STUNNED!"
category = "combat"
priority = 100
cooldown = 1000

# Webbed alert
[[triggers]]
name = "web_alert"
pattern = "(?i)webs? (stick|entangle)"
command = ".notify WEBBED!"
category = "combat"
priority = 100
cooldown = 1000

# Prone alert
[[triggers]]
name = "prone_alert"
pattern = "(?i)(knock|fall).*?(down|prone)"
command = ".notify PRONE!"
category = "combat"
priority = 100
cooldown = 1000

# Low health warning
[[triggers]]
name = "low_health"
pattern = "You feel (weak|faint|dizzy)"
command = ".notify LOW HEALTH!;stance defensive"
category = "combat"
priority = 100

# ═══════════════════════════════════════════════════════════
# HUNTING AUTOMATION (Optional - Use Carefully)
# ═══════════════════════════════════════════════════════════

# Auto-search on kill (disabled by default)
[[triggers]]
name = "auto_search"
pattern = "falls dead"
command = "search"
category = "loot"
cooldown = 2000
enabled = false

# Roundtime notification
[[triggers]]
name = "roundtime"
pattern = "Roundtime: (\\d+)"
command = ".rt $1"
category = "status"

Step 5: Color Theme for Combat

Add combat-focused colors to ~/.two-face/colors.toml:

[theme]
name = "Combat Focus"

# Vital colors - easy visibility
health = "#00ff00"          # Bright green
health_low = "#ffff00"      # Yellow warning
health_critical = "#ff0000" # Red danger
mana = "#0080ff"            # Blue
stamina = "#ff8000"         # Orange
spirit = "#ff00ff"          # Magenta

# Combat window
combat_border = "#ff4444"
combat_bg = "#1a0000"

# Status indicators
hidden = "#00ff00"
stunned = "#ffff00"
webbed = "#ff00ff"
prone = "#00ffff"

# High contrast text
text = "#ffffff"
text_dim = "#808080"

Testing Your Setup

Combat Test Checklist

  1. Vital Monitoring

    • Health bar updates when damaged
    • Mana bar updates when casting
    • Low health triggers visual warning
  2. Combat Window

    • Combat text appears in combat window
    • Non-combat text stays in main window
    • Combat highlights are visible
  3. Roundtime Display

    • RT countdown shows during actions
    • Cast timer shows during spellcasting
  4. Status Indicators

    • Hidden indicator shows when hiding
    • Stun indicator triggers on stun
  5. Keybinds

    • F1 attacks target
    • Numpad moves correctly
    • Emergency flee works

Combat Workflow Test

  1. Find a creature
  2. Use F1 to attack
  3. Watch combat window for hit/miss
  4. Check RT countdown
  5. Use numpad to navigate away
  6. Verify flee (Shift+F1) works

Customization Tips

Profession-Specific Adjustments

Rogues: Prioritize hidden indicator, add ambush macros

[keybinds."f1"]
macro = "ambush target"

[keybinds."f2"]
macro = "hide;ambush target"

Wizards: Add spell tracking, larger mana display

[[widgets]]
type = "spells"
name = "known_spells"
x = 66
y = 60
width = 34
height = 30

Warriors: Add CM tracking, shield macros

[keybinds."f4"]
macro = "cman surge"

[keybinds."f5"]
macro = "cman feint"

Multi-Target Hunting

For areas with multiple creatures:

# Target cycling
[keybinds."alt+n"]
macro = "target next"

[keybinds."alt+p"]
macro = "target previous"

Troubleshooting

Combat Text Not Filtering

Verify stream configuration:

  • Main window: streams = ["main", "room", "thoughts", "speech"]
  • Combat window: streams = ["combat"]

Roundtime Not Showing

Check data source is correct:

data_source = "roundtime"

Keybinds Not Responding During Combat

Ensure focus is on input widget:

[keybinds."escape"]
action = "focus_input"

Triggers Not Firing

  1. Check trigger syntax
  2. Verify enabled = true (default)
  3. Test pattern matches actual game text

See Also

Merchant Setup

Create a layout optimized for trading, crafting, and inventory management.

Goal

Build a merchant-focused layout with:

  • Expanded inventory display
  • Transaction logging
  • Crafting timer tracking
  • Multiple chat channels
  • Appraisal quick-access

Prerequisites

  • Completed Your First Layout
  • Character with merchant activities
  • Understanding of inventory commands

Layout Overview

┌────────────────────────────────────────────────────────────┐
│ Room: [Town Square]                              [Compass] │
├────────────────────┬───────────────────┬───────────────────┤
│                    │                   │                   │
│   Main Game Text   │   Inventory       │   Transaction     │
│                    │   (tree view)     │   Log             │
│                    │                   │                   │
│                    │                   │                   │
├────────────────────┼───────────────────┴───────────────────┤
│   Trade Channel    │   Crafting Timer    │ Wealth Display  │
│                    │   [████████░░ 80%]  │ 12,345 silver   │
├────────────────────┴───────────────────────────────────────┤
│ > [command input]                                          │
└────────────────────────────────────────────────────────────┘

Step 1: Create the Layout

Create ~/.two-face/layout.toml:

# Merchant Layout - Trading and Crafting Optimized
# Designed for inventory management and transactions

# ═══════════════════════════════════════════════════════════
# TOP BAR - Room and Navigation
# ═══════════════════════════════════════════════════════════

[[widgets]]
type = "room"
name = "room_info"
x = 0
y = 0
width = 85
height = 4
show_exits = true
show_creatures = false
show_players = true

[[widgets]]
type = "compass"
name = "compass"
x = 86
y = 0
width = 14
height = 6
style = "minimal"
clickable = true

# ═══════════════════════════════════════════════════════════
# LEFT COLUMN - Main Game Text
# ═══════════════════════════════════════════════════════════

[[widgets]]
type = "text"
name = "main"
title = "Game"
x = 0
y = 4
width = 35
height = 55
streams = ["main", "room"]
scrollback = 3000
auto_scroll = true

# ═══════════════════════════════════════════════════════════
# CENTER COLUMN - Inventory
# ═══════════════════════════════════════════════════════════

[[widgets]]
type = "inventory"
name = "inventory"
title = "Inventory"
x = 36
y = 4
width = 32
height = 55
show_containers = true
show_weight = true
expand_containers = true
sort_by = "name"

# ═══════════════════════════════════════════════════════════
# RIGHT COLUMN - Transaction Log
# ═══════════════════════════════════════════════════════════

[[widgets]]
type = "text"
name = "transactions"
title = "Transactions"
x = 69
y = 4
width = 31
height = 55
streams = ["commerce", "merchant"]
scrollback = 1000
auto_scroll = true
border_color = "yellow"

# ═══════════════════════════════════════════════════════════
# BOTTOM ROW - Trade Chat and Utilities
# ═══════════════════════════════════════════════════════════

# Trade channel
[[widgets]]
type = "text"
name = "trade"
title = "Trade Chat"
x = 0
y = 60
width = 50
height = 30
streams = ["thoughts", "speech"]
scrollback = 500
auto_scroll = true

# Crafting/Activity Timer
[[widgets]]
type = "countdown"
name = "crafting"
title = "Activity"
x = 51
y = 60
width = 24
height = 5
data_source = "roundtime"
show_bar = true

# Wealth Display (Custom text widget)
[[widgets]]
type = "text"
name = "wealth"
title = "Wealth"
x = 76
y = 60
width = 24
height = 10
streams = ["wealth"]
scrollback = 50

# Status indicators (minimal)
[[widgets]]
type = "indicator"
name = "status"
title = "Status"
x = 51
y = 66
width = 49
height = 8
indicators = ["kneeling", "sitting", "encumbered"]
columns = 3

# Vital bars (compact)
[[widgets]]
type = "progress"
name = "health"
title = "HP"
x = 51
y = 75
width = 24
height = 3
data_source = "vitals.health"
color = "health"
show_text = true

[[widgets]]
type = "progress"
name = "stamina"
title = "ST"
x = 76
y = 75
width = 24
height = 3
data_source = "vitals.stamina"
color = "stamina"
show_text = true

# Active effects (spells, buffs)
[[widgets]]
type = "active_effects"
name = "effects"
title = "Effects"
x = 51
y = 79
width = 49
height = 11
show_duration = true
compact = true

# ═══════════════════════════════════════════════════════════
# COMMAND INPUT
# ═══════════════════════════════════════════════════════════

[[widgets]]
type = "command_input"
name = "input"
x = 0
y = 91
width = 100
height = 9
history_size = 500
prompt = "$ "

Step 2: Merchant Keybinds

Create ~/.two-face/keybinds.toml:

# ═══════════════════════════════════════════════════════════
# INVENTORY MANAGEMENT - Function Keys
# ═══════════════════════════════════════════════════════════

[keybinds."f1"]
macro = "inventory"

[keybinds."f2"]
macro = "inventory full"

[keybinds."f3"]
macro = "look in my backpack"

[keybinds."f4"]
macro = "look in my cloak"

[keybinds."f5"]
macro = "wealth"

[keybinds."f6"]
macro = "experience"

# ═══════════════════════════════════════════════════════════
# TRADING ACTIONS - Ctrl Keys
# ═══════════════════════════════════════════════════════════

[keybinds."ctrl+a"]
macro = "appraise $input"

[keybinds."ctrl+g"]
macro = "get $input"

[keybinds."ctrl+d"]
macro = "drop $input"

[keybinds."ctrl+p"]
macro = "put $input in my backpack"

[keybinds."ctrl+s"]
macro = "sell $input"

[keybinds."ctrl+b"]
macro = "buy $input"

[keybinds."ctrl+o"]
macro = "order $input"

# ═══════════════════════════════════════════════════════════
# CONTAINER SHORTCUTS - Alt Keys
# ═══════════════════════════════════════════════════════════

[keybinds."alt+1"]
macro = "put $input in my backpack"

[keybinds."alt+2"]
macro = "put $input in my cloak"

[keybinds."alt+3"]
macro = "put $input in my sack"

[keybinds."alt+4"]
macro = "put $input in my pouch"

[keybinds."alt+g"]
macro = "get $input from my $input"

# ═══════════════════════════════════════════════════════════
# QUICK COMMERCE
# ═══════════════════════════════════════════════════════════

[keybinds."ctrl+shift+a"]
macro = "appraise all"

[keybinds."ctrl+shift+s"]
macro = "sell all"

# Give to player
[keybinds."ctrl+shift+g"]
macro = "give $input to $input"

# ═══════════════════════════════════════════════════════════
# MOVEMENT - Numpad
# ═══════════════════════════════════════════════════════════

[keybinds."numpad8"]
macro = "north"

[keybinds."numpad2"]
macro = "south"

[keybinds."numpad4"]
macro = "west"

[keybinds."numpad6"]
macro = "east"

[keybinds."numpad7"]
macro = "northwest"

[keybinds."numpad9"]
macro = "northeast"

[keybinds."numpad1"]
macro = "southwest"

[keybinds."numpad3"]
macro = "southeast"

[keybinds."numpad5"]
macro = "out"

[keybinds."numpad_plus"]
macro = "go counter"

[keybinds."numpad_minus"]
macro = "go door"

# ═══════════════════════════════════════════════════════════
# SOCIAL/TRADE CHAT
# ═══════════════════════════════════════════════════════════

[keybinds."ctrl+t"]
macro = "think $input"

[keybinds."ctrl+w"]
macro = "whisper $input"

# ═══════════════════════════════════════════════════════════
# WIDGET NAVIGATION
# ═══════════════════════════════════════════════════════════

[keybinds."tab"]
action = "next_widget"

[keybinds."shift+tab"]
action = "prev_widget"

[keybinds."page_up"]
action = "scroll_up"

[keybinds."page_down"]
action = "scroll_down"

[keybinds."escape"]
action = "focus_input"

# Quick focus specific widgets
[keybinds."alt+i"]
action = "focus_widget"
widget = "inventory"

[keybinds."alt+t"]
action = "focus_widget"
widget = "transactions"

[keybinds."alt+m"]
action = "focus_widget"
widget = "main"

Step 3: Commerce Highlights

Add to ~/.two-face/highlights.toml:

# ═══════════════════════════════════════════════════════════
# CURRENCY HIGHLIGHTING
# ═══════════════════════════════════════════════════════════

# Silver amounts
[[highlights]]
pattern = "\\d+,?\\d* silver"
fg = "bright_white"
bold = true

# Gold (if applicable)
[[highlights]]
pattern = "\\d+,?\\d* gold"
fg = "bright_yellow"
bold = true

# ═══════════════════════════════════════════════════════════
# TRANSACTION HIGHLIGHTING
# ═══════════════════════════════════════════════════════════

# Purchase
[[highlights]]
pattern = "(?i)you (buy|purchase|acquire)"
fg = "green"

# Sale
[[highlights]]
pattern = "(?i)you (sell|sold)"
fg = "cyan"

# Trade accepted
[[highlights]]
pattern = "(?i)trade (complete|accepted|confirmed)"
fg = "bright_green"
bold = true

# ═══════════════════════════════════════════════════════════
# ITEM HIGHLIGHTING
# ═══════════════════════════════════════════════════════════

# Gems
[[highlights]]
pattern = "(?i)(diamond|ruby|emerald|sapphire|pearl|opal|topaz|garnet)"
fg = "bright_cyan"

# Rare items
[[highlights]]
pattern = "(?i)(rare|unique|enchanted|magical)"
fg = "bright_magenta"

# Containers
[[highlights]]
pattern = "(?i)(backpack|cloak|sack|pouch|bag|chest|box)"
fg = "yellow"

# ═══════════════════════════════════════════════════════════
# MERCHANT NPCS
# ═══════════════════════════════════════════════════════════

[[highlights]]
pattern = "(?i)(merchant|shopkeeper|vendor|trader|clerk)"
fg = "bright_yellow"

# ═══════════════════════════════════════════════════════════
# PLAYER NAMES (Trade Context)
# ═══════════════════════════════════════════════════════════

[[highlights]]
pattern = "\\b[A-Z][a-z]+\\b(?= (offers|gives|trades|whispers))"
fg = "bright_white"
bold = true

Step 4: Transaction Triggers

Add to ~/.two-face/triggers.toml:

# ═══════════════════════════════════════════════════════════
# TRANSACTION ALERTS
# ═══════════════════════════════════════════════════════════

# Whisper received
[[triggers]]
name = "whisper_alert"
pattern = "(\\w+) whispers,"
command = ".notify Whisper from $1"
category = "social"
priority = 100

# Trade offer
[[triggers]]
name = "trade_offer"
pattern = "(\\w+) offers to trade"
command = ".notify Trade offer from $1"
category = "commerce"
priority = 90

# Payment received
[[triggers]]
name = "payment"
pattern = "(\\d+) silver"
command = ".log Payment: $1 silver"
category = "commerce"
stream = "commerce"

# ═══════════════════════════════════════════════════════════
# INVENTORY ALERTS
# ═══════════════════════════════════════════════════════════

# Encumbered
[[triggers]]
name = "encumbered"
pattern = "(?i)encumbered"
command = ".notify Encumbered!"
category = "status"
cooldown = 5000

# Container full
[[triggers]]
name = "container_full"
pattern = "(?i)won't fit|too full"
command = ".notify Container full!"
category = "inventory"
cooldown = 2000

# ═══════════════════════════════════════════════════════════
# CRAFTING TIMERS
# ═══════════════════════════════════════════════════════════

# Roundtime for crafting
[[triggers]]
name = "craft_rt"
pattern = "Roundtime: (\\d+)"
command = ".rt $1"
category = "crafting"

Step 5: Command Lists for Items

Add to ~/.two-face/cmdlist.toml:

# ═══════════════════════════════════════════════════════════
# GENERAL ITEMS
# ═══════════════════════════════════════════════════════════

[[cmdlist]]
category = "item"
noun = ".*"
match_mode = "regex"
commands = [
    "look",
    "get",
    "drop",
    "---",
    "appraise",
    "sell",
    "---",
    "put in>put {noun} in my backpack,put {noun} in my cloak,put {noun} in my sack"
]
priority = 10

# ═══════════════════════════════════════════════════════════
# CONTAINERS
# ═══════════════════════════════════════════════════════════

[[cmdlist]]
category = "container"
noun = "(?i)(backpack|cloak|sack|pouch|bag|chest|box)"
match_mode = "regex"
commands = [
    "look in",
    "open",
    "close",
    "---",
    "get from:get {input} from my {noun}",
    "inventory check"
]
priority = 50

# ═══════════════════════════════════════════════════════════
# CURRENCY
# ═══════════════════════════════════════════════════════════

[[cmdlist]]
category = "currency"
noun = "(?i)(silver|gold|coins?|copper)"
match_mode = "regex"
commands = [
    "get",
    "count",
    "deposit"
]
priority = 40

# ═══════════════════════════════════════════════════════════
# GEMS AND VALUABLES
# ═══════════════════════════════════════════════════════════

[[cmdlist]]
category = "gems"
noun = "(?i)(gem|jewel|diamond|ruby|emerald|sapphire|pearl|stone)"
match_mode = "regex"
commands = [
    "look",
    "get",
    "appraise",
    "sell",
    "---",
    "put in pouch:put {noun} in my gem pouch"
]
priority = 45

# ═══════════════════════════════════════════════════════════
# PLAYERS (Trade Context)
# ═══════════════════════════════════════════════════════════

[[cmdlist]]
category = "player"
noun = "^[A-Z][a-z]+$"
match_mode = "regex"
commands = [
    "look",
    "---",
    "whisper:whisper {noun} {input}",
    "give:give {input} to {noun}",
    "trade:trade {noun}",
    "---",
    "smile",
    "bow"
]
priority = 60

Testing Your Setup

Merchant Workflow Test

  1. Inventory Display

    • Inventory widget shows items
    • Container contents expand
    • Weight displays correctly
  2. Transaction Logging

    • Commerce messages appear in transaction log
    • Trade whispers show in trade chat
  3. Quick Commands

    • F1 shows inventory
    • Ctrl+A prompts for appraise target
    • Ctrl+S prompts for sell target
  4. Context Menus

    • Right-click item shows merchant options
    • Put in container submenu works
    • Player right-click shows trade options

Typical Trade Session

  1. Go to town square
  2. Check inventory (F1)
  3. Appraise items (Ctrl+A)
  4. Watch transaction log
  5. Test whisper alerts
  6. Try context menu trading

Customization Tips

For Crafters

Add crafting-specific bindings:

# Crafting macros
[keybinds."ctrl+c"]
macro = "craft $input"

[keybinds."ctrl+r"]
macro = "repair $input"

[keybinds."ctrl+e"]
macro = "enhance $input"

For Auctioneers

Track auction activity:

[[triggers]]
name = "auction_bid"
pattern = "(?i)bid.*?(\\d+) silver"
command = ".notify Bid: $1 silver"
category = "auction"

For Gatherers

Quick deposit macros:

[keybinds."ctrl+shift+d"]
macro = "deposit all"

[keybinds."ctrl+shift+w"]
macro = "withdraw $input silver"

Troubleshooting

Transaction Log Empty

Check stream configuration:

streams = ["commerce", "merchant"]

If no dedicated streams exist, try:

streams = ["main"]

Then filter with highlights.

Inventory Not Updating

Inventory requires game state updates:

  1. Type inventory to refresh
  2. Check for parsing errors in logs
  3. Verify widget data source

Context Menu Missing

  1. Check cmdlist.toml syntax
  2. Verify pattern matches item nouns
  3. Reload: .reload cmdlist

Keybind Prompts Not Appearing

Ensure $input syntax is correct:

macro = "sell $input"     # Correct
macro = "sell {input}"    # Wrong

See Also

Roleplay Setup

Create an immersive layout focused on storytelling and social interaction.

Goal

Build a roleplay-focused layout with:

  • Maximum text visibility for story immersion
  • Organized chat channels
  • Minimal HUD elements
  • Easy social command access
  • Atmospheric preservation

Prerequisites

  • Completed Your First Layout
  • Interest in roleplay and social gameplay
  • Basic emote familiarity

Design Philosophy

Roleplay layouts prioritize:

  1. Immersion - Large text areas, minimal UI chrome
  2. Readability - Clear text, comfortable spacing
  3. Organization - Separate IC (In-Character) and OOC (Out-Of-Character)
  4. Quick Social - Fast access to emotes and speech

Layout Overview

┌────────────────────────────────────────────────────────────┐
│ [Room Name]                                                │
├────────────────────────────────────────────────────────────┤
│                                                            │
│                                                            │
│                    Main Story Text                         │
│              (room descriptions, actions)                  │
│                                                            │
│                                                            │
│                                                            │
├──────────────────────────────────┬─────────────────────────┤
│     Speech & Thoughts            │    ESP/OOC Chat         │
│     (IC communication)           │    (group chat)         │
├──────────────────────────────────┴─────────────────────────┤
│ > [command input]                                          │
└────────────────────────────────────────────────────────────┘

Step 1: Create the Layout

Create ~/.two-face/layout.toml:

# Roleplay Layout - Immersive Storytelling
# Maximizes text space, organizes communication channels

# ═══════════════════════════════════════════════════════════
# TOP - Room Title Bar (Minimal)
# ═══════════════════════════════════════════════════════════

[[widgets]]
type = "room"
name = "room_title"
x = 0
y = 0
width = 100
height = 3
show_exits = false
show_creatures = false
show_players = false
title_only = true

# ═══════════════════════════════════════════════════════════
# MAIN AREA - Story Text (Largest Widget)
# ═══════════════════════════════════════════════════════════

[[widgets]]
type = "text"
name = "story"
title = ""
x = 0
y = 3
width = 100
height = 55
streams = ["main", "room"]
scrollback = 10000
auto_scroll = true
border = false
padding = 1

# ═══════════════════════════════════════════════════════════
# BOTTOM LEFT - In-Character Communication
# ═══════════════════════════════════════════════════════════

[[widgets]]
type = "text"
name = "speech"
title = "Speech"
x = 0
y = 59
width = 55
height = 31
streams = ["speech", "whisper"]
scrollback = 2000
auto_scroll = true
border_color = "blue"

# ═══════════════════════════════════════════════════════════
# BOTTOM RIGHT - Out-Of-Character / ESP
# ═══════════════════════════════════════════════════════════

[[widgets]]
type = "text"
name = "thoughts"
title = "ESP/OOC"
x = 56
y = 59
width = 44
height = 31
streams = ["thoughts", "group"]
scrollback = 1000
auto_scroll = true
border_color = "cyan"

# ═══════════════════════════════════════════════════════════
# COMMAND INPUT
# ═══════════════════════════════════════════════════════════

[[widgets]]
type = "command_input"
name = "input"
x = 0
y = 91
width = 100
height = 9
history_size = 500
prompt = "› "

Step 2: Immersive Theme

Create ~/.two-face/colors.toml:

[theme]
name = "Parchment"

# Background - soft, easy on eyes
background = "#1a1814"     # Dark parchment

# Text colors - warm and readable
text = "#d4c5a9"           # Cream text
text_dim = "#8b7355"       # Muted brown

# UI elements - unobtrusive
border = "#3d3428"         # Dark border
border_focused = "#5c4a32" # Focused border

# Speech colors - distinguishable but not harsh
speech = "#87ceeb"         # Sky blue (say)
whisper = "#da70d6"        # Orchid (whisper)
thoughts = "#98fb98"       # Pale green (thoughts)
shout = "#ffa500"          # Orange (shout)

# Room descriptions - slightly brighter
room_name = "#ffd700"      # Gold
room_desc = "#d4c5a9"      # Cream

# Player names
player = "#ffffff"         # White
npc = "#daa520"            # Goldenrod

# Emotes and actions
emote = "#b8860b"          # Dark goldenrod

# Minimal status (if shown)
health = "#8fbc8f"         # Dark sea green
mana = "#6495ed"           # Cornflower blue

Step 3: Roleplay Keybinds

Create ~/.two-face/keybinds.toml:

# ═══════════════════════════════════════════════════════════
# SPEECH SHORTCUTS
# ═══════════════════════════════════════════════════════════

# Quick say (most common)
[keybinds."enter"]
action = "submit_input"

# Say with prompt
[keybinds."ctrl+s"]
macro = "say $input"

# Whisper
[keybinds."ctrl+w"]
macro = "whisper $input"

# Ask
[keybinds."ctrl+a"]
macro = "ask $input"

# Exclaim
[keybinds."ctrl+e"]
macro = "exclaim $input"

# ═══════════════════════════════════════════════════════════
# ESP/THOUGHTS
# ═══════════════════════════════════════════════════════════

[keybinds."ctrl+t"]
macro = "think $input"

[keybinds."ctrl+g"]
macro = "think [group] $input"

# ═══════════════════════════════════════════════════════════
# COMMON EMOTES - Function Keys
# ═══════════════════════════════════════════════════════════

[keybinds."f1"]
macro = "smile"

[keybinds."f2"]
macro = "nod"

[keybinds."f3"]
macro = "bow"

[keybinds."f4"]
macro = "wave"

[keybinds."f5"]
macro = "laugh"

[keybinds."f6"]
macro = "grin"

[keybinds."f7"]
macro = "sigh"

[keybinds."f8"]
macro = "shrug"

# Targeted emotes
[keybinds."shift+f1"]
macro = "smile $input"

[keybinds."shift+f2"]
macro = "nod $input"

[keybinds."shift+f3"]
macro = "bow $input"

[keybinds."shift+f4"]
macro = "wave $input"

# ═══════════════════════════════════════════════════════════
# ACTIONS AND POSES
# ═══════════════════════════════════════════════════════════

[keybinds."ctrl+1"]
macro = "act $input"

[keybinds."ctrl+2"]
macro = "pose $input"

[keybinds."ctrl+3"]
macro = "emote $input"

# ═══════════════════════════════════════════════════════════
# MOVEMENT (Unobtrusive)
# ═══════════════════════════════════════════════════════════

[keybinds."numpad8"]
macro = "go north"

[keybinds."numpad2"]
macro = "go south"

[keybinds."numpad4"]
macro = "go west"

[keybinds."numpad6"]
macro = "go east"

[keybinds."numpad7"]
macro = "go northwest"

[keybinds."numpad9"]
macro = "go northeast"

[keybinds."numpad1"]
macro = "go southwest"

[keybinds."numpad3"]
macro = "go southeast"

[keybinds."numpad5"]
macro = "go out"

# ═══════════════════════════════════════════════════════════
# LOOK AROUND
# ═══════════════════════════════════════════════════════════

[keybinds."ctrl+l"]
macro = "look"

[keybinds."ctrl+shift+l"]
macro = "look $input"

# ═══════════════════════════════════════════════════════════
# WINDOW NAVIGATION
# ═══════════════════════════════════════════════════════════

[keybinds."page_up"]
action = "scroll_up"

[keybinds."page_down"]
action = "scroll_down"

[keybinds."home"]
action = "scroll_top"

[keybinds."end"]
action = "scroll_bottom"

[keybinds."tab"]
action = "next_widget"

[keybinds."escape"]
action = "focus_input"

# Quick focus to specific windows
[keybinds."alt+1"]
action = "focus_widget"
widget = "story"

[keybinds."alt+2"]
action = "focus_widget"
widget = "speech"

[keybinds."alt+3"]
action = "focus_widget"
widget = "thoughts"

Step 4: Immersive Highlights

Add to ~/.two-face/highlights.toml:

# ═══════════════════════════════════════════════════════════
# SPEECH PATTERNS
# ═══════════════════════════════════════════════════════════

# Someone says
[[highlights]]
pattern = '(\\w+) says?, "'
fg = "#87ceeb"

# Someone whispers
[[highlights]]
pattern = "(\\w+) whispers,"
fg = "#da70d6"

# Someone asks
[[highlights]]
pattern = "(\\w+) asks?,"
fg = "#87ceeb"

# Someone exclaims
[[highlights]]
pattern = "(\\w+) exclaims?,"
fg = "#ffa500"

# Quoted speech
[[highlights]]
pattern = '"[^"]*"'
fg = "#ffffff"
bold = false

# ═══════════════════════════════════════════════════════════
# EMOTES AND ACTIONS
# ═══════════════════════════════════════════════════════════

# Emotes (verbs at start of line after name)
[[highlights]]
pattern = "^\\w+ (smiles|nods|bows|waves|laughs|grins|sighs|shrugs)"
fg = "#b8860b"

# ═══════════════════════════════════════════════════════════
# ROOM ELEMENTS
# ═══════════════════════════════════════════════════════════

# Room name (usually in brackets or emphasized)
[[highlights]]
pattern = "^\\[.*\\]$"
fg = "#ffd700"
bold = true

# Obvious exits
[[highlights]]
pattern = "Obvious (exits|paths):"
fg = "#8b7355"

# ═══════════════════════════════════════════════════════════
# PLAYER NAMES
# ═══════════════════════════════════════════════════════════

# Capital names (likely players)
[[highlights]]
pattern = "\\b[A-Z][a-z]{2,}\\b"
fg = "#ffffff"

# ═══════════════════════════════════════════════════════════
# OBJECTS AND ITEMS (Subtle)
# ═══════════════════════════════════════════════════════════

[[highlights]]
pattern = "(a|an|the|some) [a-z]+ [a-z]+"
fg = "#d4c5a9"

# ═══════════════════════════════════════════════════════════
# ESP/THOUGHTS
# ═══════════════════════════════════════════════════════════

[[highlights]]
pattern = "\\[.*?\\]:"
fg = "#98fb98"

# ═══════════════════════════════════════════════════════════
# TIME AND ATMOSPHERE
# ═══════════════════════════════════════════════════════════

[[highlights]]
pattern = "(?i)(dawn|morning|noon|afternoon|dusk|evening|night|midnight)"
fg = "#6495ed"
italic = true

[[highlights]]
pattern = "(?i)(rain|snow|fog|mist|wind|storm|clear|cloudy)"
fg = "#6495ed"
italic = true

Step 5: Social Command List

Add to ~/.two-face/cmdlist.toml:

# ═══════════════════════════════════════════════════════════
# PLAYERS - Social Context
# ═══════════════════════════════════════════════════════════

[[cmdlist]]
category = "player"
noun = "^[A-Z][a-z]+$"
match_mode = "regex"
commands = [
    "look",
    "---",
    "Greet>smile {noun},bow {noun},wave {noun},nod {noun}",
    "Say>say (to {noun}) {input},whisper {noun} {input},ask {noun} {input}",
    "---",
    "appraise"
]
priority = 50

# ═══════════════════════════════════════════════════════════
# ITEMS - RP Context
# ═══════════════════════════════════════════════════════════

[[cmdlist]]
category = "item"
noun = ".*"
match_mode = "regex"
commands = [
    "look",
    "get",
    "touch",
    "smell",
    "taste",
    "---",
    "show:show my {noun} to {input}",
    "give:give my {noun} to {input}"
]
priority = 10

# ═══════════════════════════════════════════════════════════
# FURNITURE AND SCENERY
# ═══════════════════════════════════════════════════════════

[[cmdlist]]
category = "furniture"
noun = "(?i)(chair|bench|stool|table|bed|couch|throne)"
match_mode = "regex"
commands = [
    "look",
    "sit on",
    "stand",
    "lean on"
]
priority = 30

Step 6: Optional - Minimal Status

If you want some status without breaking immersion:

# Add to layout.toml - minimalist status bar

[[widgets]]
type = "progress"
name = "health"
x = 0
y = 56
width = 50
height = 2
data_source = "vitals.health"
color = "health"
show_text = false
border = false

[[widgets]]
type = "progress"
name = "mana"
x = 50
y = 56
width = 50
height = 2
data_source = "vitals.mana"
color = "mana"
show_text = false
border = false

This adds thin, borderless bars between story and chat.

Testing Your Setup

Roleplay Flow Test

  1. Text Visibility

    • Room descriptions fill story window
    • Text is comfortable to read
    • Scrolling works smoothly
  2. Communication

    • Speech appears in speech window
    • ESP/thoughts in separate window
    • Colors distinguish speakers
  3. Quick Actions

    • F1-F8 emotes work
    • Ctrl+S opens say prompt
    • Movement numpad works
  4. Immersion

    • UI doesn’t distract
    • Colors are comfortable
    • Layout feels spacious

Social Interaction Test

  1. Go to a social area (town square)
  2. Use F1 to smile
  3. Use Ctrl+S to say something
  4. Check speech appears in correct window
  5. Verify ESP goes to thoughts window

Customization Ideas

Faction-Based Themes

Create themes matching your character’s background:

Elven Theme:

background = "#0a1510"
text = "#c0d890"
border = "#2a4530"

Dark Theme:

background = "#0d0d0d"
text = "#b0b0b0"
border = "#1a1a1a"

Event-Specific Layouts

For large events, expand thoughts window:

# Event layout - larger ESP
[[widgets]]
type = "text"
name = "thoughts"
x = 0
y = 60
width = 100
height = 30
streams = ["thoughts", "group"]

Character Macros

Pre-written character expressions:

[keybinds."ctrl+shift+1"]
macro = "emote adjusts their cloak with practiced ease"

[keybinds."ctrl+shift+2"]
macro = "emote glances around thoughtfully"

[keybinds."ctrl+shift+3"]
macro = "emote inclines their head in greeting"

Troubleshooting

Text Too Dense

Increase padding:

padding = 2

Or add line spacing if supported.

Can’t See Who’s Speaking

Adjust highlight patterns to catch speaker names before quotes.

ESP Flooding Main Window

Verify stream separation:

  • Story: ["main", "room"]
  • Speech: ["speech", "whisper"]
  • Thoughts: ["thoughts", "group"]

Emotes Going to Wrong Window

Emotes typically go to “main” stream. If you want them separate, check parser configuration.

See Also

Minimal Layout

Create a clean, distraction-free interface with maximum text visibility.

Goal

Build a minimalist layout with:

  • Maximum screen space for game text
  • Essential information only
  • Keyboard-centric design
  • Low resource usage
  • Quick load times

Prerequisites

  • Basic Two-Face familiarity
  • Preference for simple interfaces
  • Keyboard navigation skills

Design Philosophy

The minimal layout follows these principles:

  1. Less is More - Only show what you actively need
  2. Text First - Game output takes priority
  3. Keyboard Driven - Reduce mouse dependency
  4. Performance - Fewer widgets = faster rendering

Layout Overview

┌────────────────────────────────────────────────────────────┐
│                                                            │
│                                                            │
│                                                            │
│                     Game Text                              │
│                   (Full Screen)                            │
│                                                            │
│                                                            │
│                                                            │
├────────────────────────────────────────────────────────────┤
│ HP: ████████░░ 82%  MP: ████░░░░░░ 40%  RT: 3  > [input]   │
└────────────────────────────────────────────────────────────┘

Step 1: Create the Layout

Create ~/.two-face/layout.toml:

# Minimal Layout - Maximum Text, Minimum UI
# Clean, fast, keyboard-driven

# ═══════════════════════════════════════════════════════════
# MAIN TEXT - Full Width, Maximum Height
# ═══════════════════════════════════════════════════════════

[[widgets]]
type = "text"
name = "main"
title = ""
x = 0
y = 0
width = 100
height = 90
streams = ["main", "room", "combat", "thoughts", "speech"]
scrollback = 3000
auto_scroll = true
border = false
padding = 1

# ═══════════════════════════════════════════════════════════
# STATUS BAR - Compact Bottom Row
# ═══════════════════════════════════════════════════════════

# Health (compact)
[[widgets]]
type = "progress"
name = "health"
title = "HP"
x = 0
y = 91
width = 15
height = 3
data_source = "vitals.health"
color = "health"
show_percentage = true
border = false

# Mana (compact)
[[widgets]]
type = "progress"
name = "mana"
title = "MP"
x = 16
y = 91
width = 15
height = 3
data_source = "vitals.mana"
color = "mana"
show_percentage = true
border = false

# Roundtime (compact)
[[widgets]]
type = "countdown"
name = "roundtime"
title = "RT"
x = 32
y = 91
width = 8
height = 3
data_source = "roundtime"
border = false

# Command Input (rest of row)
[[widgets]]
type = "command_input"
name = "input"
x = 41
y = 91
width = 59
height = 9
history_size = 500
prompt = "> "
border = false

That’s it. Four widgets total.

Step 2: Minimal Theme

Create ~/.two-face/colors.toml:

[theme]
name = "Minimal"

# Clean, high-contrast colors
background = "#000000"
text = "#cccccc"
text_dim = "#666666"

# No visible borders
border = "#000000"
border_focused = "#333333"

# Vitals - visible but not distracting
health = "#00aa00"
health_low = "#aaaa00"
health_critical = "#aa0000"
mana = "#0066aa"
stamina = "#aa6600"

# Status bar background
status_bg = "#111111"

Step 3: Keyboard-Centric Keybinds

Create ~/.two-face/keybinds.toml:

# ═══════════════════════════════════════════════════════════
# CORE NAVIGATION - Always Available
# ═══════════════════════════════════════════════════════════

# Cardinal directions
[keybinds."numpad8"]
macro = "north"

[keybinds."numpad2"]
macro = "south"

[keybinds."numpad4"]
macro = "west"

[keybinds."numpad6"]
macro = "east"

# Diagonal
[keybinds."numpad7"]
macro = "northwest"

[keybinds."numpad9"]
macro = "northeast"

[keybinds."numpad1"]
macro = "southwest"

[keybinds."numpad3"]
macro = "southeast"

# Special movement
[keybinds."numpad5"]
macro = "out"

[keybinds."numpad_plus"]
macro = "go"

[keybinds."numpad_minus"]
macro = "climb"

# ═══════════════════════════════════════════════════════════
# ESSENTIAL COMMANDS - Function Keys
# ═══════════════════════════════════════════════════════════

[keybinds."f1"]
macro = "look"

[keybinds."f2"]
macro = "inventory"

[keybinds."f3"]
macro = "experience"

[keybinds."f4"]
macro = "health"

[keybinds."f5"]
macro = "stance defensive"

[keybinds."f6"]
macro = "stance offensive"

[keybinds."f7"]
macro = "hide"

[keybinds."f8"]
macro = "search"

# ═══════════════════════════════════════════════════════════
# QUICK ACTIONS - Ctrl Keys
# ═══════════════════════════════════════════════════════════

[keybinds."ctrl+a"]
macro = "attack target"

[keybinds."ctrl+s"]
macro = "search;loot"

[keybinds."ctrl+g"]
macro = "get $input"

[keybinds."ctrl+d"]
macro = "drop $input"

# ═══════════════════════════════════════════════════════════
# SCROLLING - Page Keys
# ═══════════════════════════════════════════════════════════

[keybinds."page_up"]
action = "scroll_up"

[keybinds."page_down"]
action = "scroll_down"

[keybinds."home"]
action = "scroll_top"

[keybinds."end"]
action = "scroll_bottom"

# Half-page scroll
[keybinds."ctrl+page_up"]
action = "scroll_half_up"

[keybinds."ctrl+page_down"]
action = "scroll_half_down"

# ═══════════════════════════════════════════════════════════
# FOCUS
# ═══════════════════════════════════════════════════════════

[keybinds."escape"]
action = "focus_input"

# Toggle between main and input (since only 2 widgets)
[keybinds."tab"]
action = "next_widget"

Step 4: Minimal Highlights

Create ~/.two-face/highlights.toml:

# Minimal highlighting - only essential patterns

# ═══════════════════════════════════════════════════════════
# CRITICAL ALERTS
# ═══════════════════════════════════════════════════════════

[[highlights]]
pattern = "(?i)you are stunned"
fg = "black"
bg = "yellow"
bold = true

[[highlights]]
pattern = "(?i)you have died"
fg = "black"
bg = "red"
bold = true

# ═══════════════════════════════════════════════════════════
# PLAYER INTERACTION
# ═══════════════════════════════════════════════════════════

# Whispers (important)
[[highlights]]
pattern = "(\\w+) whispers,"
fg = "magenta"

# Your name mentioned
[[highlights]]
pattern = "\\bYOURNAME\\b"
fg = "cyan"
bold = true

# ═══════════════════════════════════════════════════════════
# COMBAT (Subtle)
# ═══════════════════════════════════════════════════════════

# Hits
[[highlights]]
pattern = "\\*\\*.+\\*\\*"
fg = "red"

# Death
[[highlights]]
pattern = "falls dead"
fg = "yellow"

# ═══════════════════════════════════════════════════════════
# NAVIGATION AIDS
# ═══════════════════════════════════════════════════════════

# Room name
[[highlights]]
pattern = "^\\[.+\\]$"
fg = "white"
bold = true

# Exits
[[highlights]]
pattern = "Obvious (exits|paths):"
fg = "gray"

Step 5: No Triggers (Optional)

For truly minimal setup, skip triggers entirely. Or add only critical ones:

Create ~/.two-face/triggers.toml:

# Only life-saving triggers

[[triggers]]
name = "death_alert"
pattern = "You have died"
command = ".notify DEAD!"
priority = 100

[[triggers]]
name = "stun_alert"
pattern = "(?i)you are stunned"
command = ".notify Stunned"
priority = 100
cooldown = 2000

Alternative: Ultra-Minimal

For absolute minimalism, use just two widgets:

# Ultra-minimal - text and input only

[[widgets]]
type = "text"
name = "main"
x = 0
y = 0
width = 100
height = 92
streams = ["main", "room", "combat", "thoughts", "speech"]
scrollback = 2000
border = false

[[widgets]]
type = "command_input"
name = "input"
x = 0
y = 93
width = 100
height = 7
prompt = "> "
border = false

No status bars, no indicators. Pure text adventure.

Alternative: Vertical Split

If you want some separation without complexity:

# Two-column minimal

[[widgets]]
type = "text"
name = "main"
x = 0
y = 0
width = 70
height = 92
streams = ["main", "room", "combat"]
border = false

[[widgets]]
type = "text"
name = "chat"
x = 71
y = 0
width = 29
height = 92
streams = ["thoughts", "speech", "whisper"]
border_color = "gray"

[[widgets]]
type = "command_input"
name = "input"
x = 0
y = 93
width = 100
height = 7
prompt = "> "
border = false

Testing Your Setup

Performance Test

  1. Launch Two-Face
  2. Check startup time (should be fast)
  3. Scroll rapidly (should be smooth)
  4. Send many commands (no lag)

Functionality Test

  • Text displays clearly
  • Scrolling works (Page Up/Down)
  • Commands execute
  • Vital bars update
  • RT countdown works

Keyboard-Only Test

Try a session without using the mouse:

  1. Navigate rooms with numpad
  2. Use F-keys for common commands
  3. Type commands directly
  4. Scroll with Page keys

Performance Tips

Reduce Scrollback

Less history = less memory:

scrollback = 1000  # Instead of 5000+

Disable Animations

If supported:

[performance]
animations = false
smooth_scroll = false

Simple Highlights

Complex regex patterns slow parsing:

# Fast (literal string)
pattern = "You are stunned"

# Slower (complex regex)
pattern = "(?i)you\\s+are\\s+(?:completely\\s+)?stunned"

Fewer Streams

Combining streams reduces processing:

# All in one (fastest)
streams = ["main"]

# Separated (more processing)
streams = ["main", "room", "combat", "thoughts"]

Customization

Adding Status on Demand

Create a keybind to show/hide status:

[keybinds."ctrl+h"]
action = "toggle_widget"
widget = "health"

Temporary Expansion

For complex situations, temporarily switch layouts:

.layout hunting    # Switch to full layout
.layout minimal    # Back to minimal

Color Adjustments

For different lighting conditions:

Day Mode (brighter):

background = "#1a1a1a"
text = "#e0e0e0"

Night Mode (darker):

background = "#000000"
text = "#909090"

Troubleshooting

Missing Information

If you need data not shown:

  1. Add minimal widget for that data
  2. Use keybind macros (F3 = experience)
  3. Create triggers for notifications

Accidental Scrollback

Enable auto-scroll:

auto_scroll = true

Or use End key to jump to bottom.

Can’t See Status

Check border settings - borders might be same color as background:

border = false  # Hide borders entirely

Input Too Small

Increase input height:

height = 9  # More space for long commands

Philosophy

The minimal layout works best when you:

  • Know the game well
  • Prefer typing over clicking
  • Want maximum text visibility
  • Value performance over features
  • Play on smaller screens or terminals

It doesn’t work well when you:

  • Need constant visual status monitoring
  • Rely heavily on context menus
  • Are new to the game
  • Want rich visual feedback

Choose the approach that matches your playstyle.

See Also

Accessibility Setup

Configure Two-Face for screen readers, high contrast needs, and other accessibility requirements.

Goal

Create an accessible setup with:

  • Screen reader integration (TTS)
  • High contrast color themes
  • Keyboard-only navigation
  • Large text options
  • Simplified layouts

Prerequisites

  • Two-Face installed
  • Screen reader software (optional)
  • Understanding of your accessibility needs

Accessibility Features Overview

FeaturePurposeConfiguration
TTSAudio outputconfig.toml
High ContrastVisual claritycolors.toml
Large TextReadabilityTerminal settings
Keyboard NavMouse-free usekeybinds.toml
Audio AlertsStatus awarenesstriggers.toml

Step 1: Enable Text-to-Speech

Configure TTS

Edit ~/.two-face/config.toml:

[tts]
enabled = true
voice = "default"           # System default voice
rate = 1.0                  # 0.5 (slow) to 2.0 (fast)
volume = 1.0                # 0.0 to 1.0

# What to speak
speak_room_descriptions = true
speak_combat = true
speak_speech = true
speak_whispers = true
speak_thoughts = false      # Often too much
speak_system = true

# Filtering
skip_patterns = [
    "^\\s*$",               # Empty lines
    "^Obvious exits:",      # Repetitive
]

# Priority speaking (interrupts queue)
priority_patterns = [
    "stunned",
    "webbed",
    "died",
    "whispers,"
]

Platform-Specific TTS

Windows (SAPI):

[tts]
engine = "sapi"
voice = "Microsoft David"   # or "Microsoft Zira"

macOS (say):

[tts]
engine = "say"
voice = "Alex"              # or "Samantha"

Linux (espeak):

[tts]
engine = "espeak"
voice = "en-us"
# Install: sudo apt install espeak

Linux (speech-dispatcher):

[tts]
engine = "speechd"
voice = "default"
# Install: sudo apt install speech-dispatcher

Step 2: High Contrast Theme

Create ~/.two-face/colors.toml:

[theme]
name = "High Contrast"

# Maximum contrast backgrounds
background = "#000000"      # Pure black
text = "#ffffff"            # Pure white
text_dim = "#c0c0c0"        # Light gray (still readable)

# Bold, distinct borders
border = "#ffffff"
border_focused = "#ffff00"  # Yellow focus indicator

# Vitals - WCAG AAA contrast
health = "#00ff00"          # Bright green
health_low = "#ffff00"      # Yellow warning
health_critical = "#ff0000" # Red danger
mana = "#00ffff"            # Cyan
stamina = "#ff8800"         # Orange
spirit = "#ff00ff"          # Magenta

# Status indicators - distinct colors
hidden = "#00ff00"
stunned = "#ffff00"
webbed = "#ff00ff"
prone = "#00ffff"
kneeling = "#ff8800"

# Speech - distinguishable
speech = "#00ffff"          # Cyan
whisper = "#ff00ff"         # Magenta
thoughts = "#00ff00"        # Green
shout = "#ffff00"           # Yellow

# Combat
attack_hit = "#00ff00"
attack_miss = "#ff0000"
damage_high = "#ff0000"

# Links and interactive elements
link = "#00ffff"
link_hover = "#ffff00"

Alternative: Yellow on Black

Some users prefer yellow text:

[theme]
name = "Yellow High Contrast"
background = "#000000"
text = "#ffff00"
text_dim = "#cccc00"
border = "#ffff00"

Alternative: White on Blue

Classic high-contrast scheme:

[theme]
name = "White on Blue"
background = "#000080"
text = "#ffffff"
border = "#ffffff"

Step 3: Accessible Layout

Create ~/.two-face/layout.toml:

# Accessible Layout - Clear, Logical Structure
# Optimized for screen readers and keyboard navigation

# ═══════════════════════════════════════════════════════════
# MAIN TEXT - Primary Focus
# ═══════════════════════════════════════════════════════════

[[widgets]]
type = "text"
name = "main"
title = "Game Output"
x = 0
y = 0
width = 100
height = 70
streams = ["main", "room", "combat", "speech"]
scrollback = 2000
auto_scroll = true
focus_order = 1             # First in tab order

# ═══════════════════════════════════════════════════════════
# STATUS - Spoken Summary
# ═══════════════════════════════════════════════════════════

[[widgets]]
type = "progress"
name = "health"
title = "Health"
x = 0
y = 71
width = 25
height = 5
data_source = "vitals.health"
color = "health"
show_text = true
show_percentage = true
announce_changes = true     # TTS announces changes
focus_order = 2

[[widgets]]
type = "progress"
name = "mana"
title = "Mana"
x = 26
y = 71
width = 25
height = 5
data_source = "vitals.mana"
color = "mana"
show_text = true
show_percentage = true
announce_changes = true
focus_order = 3

[[widgets]]
type = "progress"
name = "stamina"
title = "Stamina"
x = 52
y = 71
width = 24
height = 5
data_source = "vitals.stamina"
color = "stamina"
show_text = true
show_percentage = true
focus_order = 4

[[widgets]]
type = "countdown"
name = "roundtime"
title = "Roundtime"
x = 77
y = 71
width = 23
height = 5
data_source = "roundtime"
announce_start = true       # "Roundtime 5 seconds"
announce_end = true         # "Ready"
focus_order = 5

# ═══════════════════════════════════════════════════════════
# STATUS INDICATORS - Audio Alerts
# ═══════════════════════════════════════════════════════════

[[widgets]]
type = "indicator"
name = "status"
title = "Status"
x = 0
y = 77
width = 100
height = 5
indicators = [
    "hidden",
    "stunned",
    "webbed",
    "prone",
    "kneeling"
]
columns = 5
announce_changes = true     # TTS announces status changes
focus_order = 6

# ═══════════════════════════════════════════════════════════
# COMMAND INPUT - Primary Interaction
# ═══════════════════════════════════════════════════════════

[[widgets]]
type = "command_input"
name = "input"
title = "Command"
x = 0
y = 83
width = 100
height = 17
history_size = 500
prompt = "Enter command: "
announce_echo = false       # Don't repeat typed text
focus_order = 0             # Default focus

Step 4: Keyboard Navigation

Create ~/.two-face/keybinds.toml:

# ═══════════════════════════════════════════════════════════
# WIDGET NAVIGATION - Tab Order
# ═══════════════════════════════════════════════════════════

[keybinds."tab"]
action = "next_widget"

[keybinds."shift+tab"]
action = "prev_widget"

[keybinds."escape"]
action = "focus_input"

# Quick focus to specific widgets
[keybinds."alt+m"]
action = "focus_widget"
widget = "main"

[keybinds."alt+h"]
action = "focus_widget"
widget = "health"

[keybinds."alt+i"]
action = "focus_widget"
widget = "input"

# ═══════════════════════════════════════════════════════════
# SCROLLING - Standard Keys
# ═══════════════════════════════════════════════════════════

[keybinds."page_up"]
action = "scroll_up"

[keybinds."page_down"]
action = "scroll_down"

[keybinds."home"]
action = "scroll_top"

[keybinds."end"]
action = "scroll_bottom"

[keybinds."ctrl+home"]
action = "scroll_top"

[keybinds."ctrl+end"]
action = "scroll_bottom"

# Line-by-line scrolling
[keybinds."up"]
action = "scroll_line_up"

[keybinds."down"]
action = "scroll_line_down"

# ═══════════════════════════════════════════════════════════
# STATUS QUERIES - Audio Feedback
# ═══════════════════════════════════════════════════════════

[keybinds."f1"]
action = "speak_status"     # TTS reads all vitals

[keybinds."f2"]
action = "speak_room"       # TTS reads room description

[keybinds."f3"]
action = "speak_last"       # Repeat last spoken text

[keybinds."f4"]
action = "stop_speaking"    # Interrupt TTS

# ═══════════════════════════════════════════════════════════
# MOVEMENT - Consistent Keys
# ═══════════════════════════════════════════════════════════

[keybinds."numpad8"]
macro = "north"

[keybinds."numpad2"]
macro = "south"

[keybinds."numpad4"]
macro = "west"

[keybinds."numpad6"]
macro = "east"

[keybinds."numpad7"]
macro = "northwest"

[keybinds."numpad9"]
macro = "northeast"

[keybinds."numpad1"]
macro = "southwest"

[keybinds."numpad3"]
macro = "southeast"

[keybinds."numpad5"]
macro = "out"

# ═══════════════════════════════════════════════════════════
# COMMON ACTIONS
# ═══════════════════════════════════════════════════════════

[keybinds."f5"]
macro = "look"

[keybinds."f6"]
macro = "inventory"

[keybinds."f7"]
macro = "experience"

[keybinds."f8"]
macro = "health"

[keybinds."f9"]
macro = "attack target"

[keybinds."f10"]
macro = "hide"

[keybinds."f11"]
macro = "search"

[keybinds."f12"]
macro = "flee"

# ═══════════════════════════════════════════════════════════
# TTS CONTROL
# ═══════════════════════════════════════════════════════════

[keybinds."ctrl+space"]
action = "toggle_tts"

[keybinds."ctrl+up"]
action = "tts_rate_up"

[keybinds."ctrl+down"]
action = "tts_rate_down"

[keybinds."ctrl+shift+up"]
action = "tts_volume_up"

[keybinds."ctrl+shift+down"]
action = "tts_volume_down"

Step 5: Audio Alerts

Create ~/.two-face/triggers.toml:

# ═══════════════════════════════════════════════════════════
# CRITICAL STATUS - Spoken Alerts
# ═══════════════════════════════════════════════════════════

[[triggers]]
name = "stun_announce"
pattern = "(?i)you are stunned"
command = ".tts You are stunned!"
priority = 100
cooldown = 1000

[[triggers]]
name = "web_announce"
pattern = "(?i)webs? (stick|entangle)"
command = ".tts You are webbed!"
priority = 100
cooldown = 1000

[[triggers]]
name = "prone_announce"
pattern = "(?i)(knock|fall).*?(down|prone)"
command = ".tts You fell down!"
priority = 100
cooldown = 1000

[[triggers]]
name = "death_announce"
pattern = "You have died"
command = ".tts You have died!"
priority = 100

# ═══════════════════════════════════════════════════════════
# HEALTH WARNINGS
# ═══════════════════════════════════════════════════════════

[[triggers]]
name = "low_health"
pattern = "(?i)feel (weak|faint)"
command = ".tts Warning! Low health!"
priority = 100
cooldown = 5000

# ═══════════════════════════════════════════════════════════
# SOCIAL - Important Communications
# ═══════════════════════════════════════════════════════════

[[triggers]]
name = "whisper_announce"
pattern = "(\\w+) whispers,"
command = ".tts $1 whispers to you"
priority = 90
cooldown = 500

[[triggers]]
name = "name_mention"
pattern = "\\bYOURNAME\\b"
command = ".tts Your name was mentioned"
priority = 80
cooldown = 3000

# ═══════════════════════════════════════════════════════════
# ROUNDTIME
# ═══════════════════════════════════════════════════════════

[[triggers]]
name = "rt_long"
pattern = "Roundtime: ([5-9]|\\d{2,})"
command = ".tts Roundtime $1 seconds"
priority = 50

[[triggers]]
name = "rt_done"
pattern = "Roundtime: 0"
command = ".tts Ready"
priority = 50
enabled = false  # Enable if you want ready alerts

Step 6: Large Text Configuration

Two-Face inherits font size from your terminal. Configure your terminal:

Windows Terminal

Settings → Profiles → Default → Appearance:

  • Font size: 14-18 for comfortable reading
  • Font face: Consolas, Cascadia Code, or other monospace

macOS Terminal

Terminal → Preferences → Profiles → Text:

  • Font: Monaco or SF Mono
  • Size: 14-18 pt

Linux Terminal (GNOME)

Preferences → Profiles → Default → Text:

  • Custom font: DejaVu Sans Mono
  • Size: 14-18

High-readability monospace fonts:

FontPlatformNotes
JetBrains MonoAllDesigned for readability
Fira CodeAllExcellent legibility
Cascadia CodeWindowsMicrosoft’s modern font
SF MonomacOSApple’s system font
DejaVu Sans MonoLinuxComprehensive Unicode
OpenDyslexic MonoAllDyslexia-friendly

Testing Your Setup

TTS Test

  1. Enable TTS in config
  2. Launch Two-Face
  3. Move to a new room
  4. Verify room description is spoken
  5. Test F1 (speak status) keybind
  1. Use Tab to cycle through widgets
  2. Verify focus indicator is visible (yellow border)
  3. Use Page Up/Down in main window
  4. Use Escape to return to input

Contrast Test

  1. Check all text is readable
  2. Verify borders are visible
  3. Test in both light and dark room lighting
  4. Check vital bar colors are distinguishable

Checklist

  • TTS speaks room descriptions
  • TTS announces status changes
  • Tab cycles through widgets
  • Focus indicator clearly visible
  • All text high contrast
  • Vital bars distinguishable by color
  • Keybinds work as expected
  • F1 speaks current status

Customization

Adjust TTS Speed

For faster reading:

[tts]
rate = 1.5

For clearer pronunciation:

[tts]
rate = 0.8

Selective TTS

Only speak important things:

[tts]
speak_room_descriptions = true
speak_combat = false
speak_speech = true
speak_whispers = true
speak_thoughts = false

Different Voices

Some systems support multiple voices:

[tts]
combat_voice = "Microsoft David"
speech_voice = "Microsoft Zira"

Custom Alert Sounds

Combine TTS with audio:

[[triggers]]
name = "stun_alert"
pattern = "(?i)you are stunned"
command = ".sound stun.wav;.tts Stunned!"

Screen Reader Integration

Two-Face can work alongside dedicated screen readers:

NVDA (Windows)

  • Two-Face TTS can coexist with NVDA
  • Consider disabling Two-Face TTS if using NVDA
  • NVDA will read terminal output automatically

VoiceOver (macOS)

  • VoiceOver reads terminal content
  • Use VoiceOver commands for navigation
  • Two-Face keybinds may conflict - adjust as needed

Orca (Linux)

  • Orca works with accessible terminals
  • Ensure terminal has accessibility enabled
  • May need to disable Two-Face TTS to avoid overlap

Troubleshooting

TTS Not Working

  1. Check TTS is enabled in config
  2. Verify system TTS works: say "test" (macOS) or test in Windows settings
  3. Check TTS engine setting matches installed software
  4. Review logs for TTS errors

Focus Not Visible

Increase border contrast:

border_focused = "#ffff00"  # Bright yellow

Or add a focus background:

focused_bg = "#333300"

Colors Not Distinct

Test with colorblind simulation tools, then adjust:

# For red-green colorblindness
health = "#00ffff"    # Cyan instead of green
danger = "#ffff00"    # Yellow instead of red

Keybinds Conflicting

Screen readers use many keybinds. Check for conflicts and remap:

# If F1 conflicts with screen reader
[keybinds."ctrl+f1"]
action = "speak_status"

Resources

Color Contrast Tools

Screen Reader Documentation

Accessibility Standards

See Also

Cookbook

Quick recipes for common Two-Face customizations and configurations.

Philosophy

The cookbook provides focused, copy-paste-ready solutions for specific tasks. Each recipe is self-contained and can be used independently.

Recipe Format

Each recipe includes:

  1. Goal - What you’ll achieve
  2. Configuration - Complete TOML to copy
  3. Explanation - How it works
  4. Variations - Alternative approaches

Available Recipes

Layout Recipes

Feedback Recipes

Organization Recipes

Quick Reference

Common Tasks

Want to…Recipe
Split main windowSplit Main Window
Overlay compassFloating Compass
Combat soundsCombat Alerts
Organize chatTabbed Channels
Custom HUDCustom Status Bar

By Difficulty

Beginner:

  • Combat Alerts
  • Custom Status Bar

Intermediate:

  • Split Main Window
  • Tabbed Channels
  • Floating Compass

Advanced:

  • Transparent Overlays

Using Recipes

Copy and Modify

  1. Find the recipe for your goal
  2. Copy the configuration
  3. Paste into your config file
  4. Adjust values for your preferences

Combining Recipes

Recipes are designed to work together. To combine:

  1. Copy configurations from each recipe
  2. Adjust positions to prevent overlap
  3. Ensure no naming conflicts

Testing

After applying a recipe:

  1. Reload configuration (.reload or restart)
  2. Verify the feature works
  3. Adjust as needed

Contributing Recipes

Have a useful configuration? Consider contributing:

  1. Document it following the recipe format
  2. Test thoroughly
  3. Submit via GitHub

See Also

Split Main Window

Divide the main text area into multiple panels for better organization.

Goal

Create separate text windows for different content types while maintaining a clean, organized layout.

Basic Split: Combat + Main

Two-column layout with combat on the left, main text on the right.

# Main game text (right side)
[[widgets]]
type = "text"
name = "main"
title = "Game"
x = 30
y = 0
width = 70
height = 85
streams = ["main", "room"]
scrollback = 5000

# Combat text (left side)
[[widgets]]
type = "text"
name = "combat"
title = "Combat"
x = 0
y = 0
width = 29
height = 85
streams = ["combat"]
scrollback = 2000

Horizontal Split: Main + Chat

Main text on top, communications below.

# Main game text (top)
[[widgets]]
type = "text"
name = "main"
title = "Game"
x = 0
y = 0
width = 100
height = 60
streams = ["main", "room", "combat"]
scrollback = 5000

# Chat window (bottom)
[[widgets]]
type = "text"
name = "chat"
title = "Chat"
x = 0
y = 61
width = 100
height = 24
streams = ["speech", "whisper", "thoughts"]
scrollback = 3000

Three-Panel Layout

Combat left, main center, chat right.

# Combat (left)
[[widgets]]
type = "text"
name = "combat"
title = "Combat"
x = 0
y = 0
width = 25
height = 85
streams = ["combat"]

# Main (center)
[[widgets]]
type = "text"
name = "main"
title = "Game"
x = 26
y = 0
width = 48
height = 85
streams = ["main", "room"]

# Chat (right)
[[widgets]]
type = "text"
name = "chat"
title = "Chat"
x = 75
y = 0
width = 25
height = 85
streams = ["speech", "whisper", "thoughts"]

Percentage-Based Split

Adapts to terminal size.

# Left panel (30% width)
[[widgets]]
type = "text"
name = "side"
x = "0%"
y = "0%"
width = "30%"
height = "80%"
streams = ["combat"]

# Right panel (70% width)
[[widgets]]
type = "text"
name = "main"
x = "30%"
y = "0%"
width = "70%"
height = "80%"
streams = ["main", "room"]

How It Works

Stream Assignment

Each widget shows only its assigned streams:

  • main - Primary game output
  • room - Room descriptions
  • combat - Combat messages
  • speech - Character speech
  • whisper - Private messages
  • thoughts - Mental communications

Position Calculation

Widgets are positioned using x, y coordinates:

  • Fixed numbers = absolute cells
  • Percentages = relative to terminal size

Gap Prevention

Leave 1 cell between widgets for borders:

# Widget 1 ends at x=29
width = 29

# Widget 2 starts at x=30
x = 30

Variations

Minimal Split

Just main and combat:

[[widgets]]
type = "text"
name = "main"
x = 0
width = "65%"
streams = ["main", "room", "speech", "whisper"]

[[widgets]]
type = "text"
name = "combat"
x = "66%"
width = "34%"
streams = ["combat"]

Stacked Chat

Multiple chat streams in separate panels:

[[widgets]]
type = "text"
name = "speech"
title = "Speech"
y = 0
height = "33%"
streams = ["speech"]

[[widgets]]
type = "text"
name = "whisper"
title = "Whispers"
y = "34%"
height = "33%"
streams = ["whisper"]

[[widgets]]
type = "text"
name = "thoughts"
title = "Thoughts"
y = "67%"
height = "33%"
streams = ["thoughts"]

Tips

  1. Consider Focus: Which panel needs most attention?
  2. Size by Importance: Larger panels for important content
  3. Test Scrollback: Reduce scrollback for secondary panels
  4. Add Borders: Visual separation helps readability

See Also

Floating Compass

Overlay a compass on top of the main text window for space efficiency.

Goal

Display the compass as a semi-transparent overlay, saving vertical space while keeping navigation visible.

Basic Floating Compass

# Main window (full width)
[[widgets]]
type = "text"
name = "main"
title = "Game"
x = 0
y = 0
width = 100
height = 85
streams = ["main", "room", "combat"]
z_index = 1

# Floating compass (top right corner)
[[widgets]]
type = "compass"
name = "compass"
x = 85
y = 1
width = 14
height = 7
z_index = 10
style = "compact"
border = false
clickable = true

Transparent Compass

With transparency support:

[[widgets]]
type = "compass"
name = "compass"
x = 85
y = 1
width = 14
height = 7
z_index = 10
style = "unicode"
transparent = true
opacity = 0.8

Corner Positions

Top Right (Default)

[[widgets]]
type = "compass"
name = "compass"
x = "86%"
y = 1
anchor = "top_right"

Top Left

[[widgets]]
type = "compass"
name = "compass"
x = 1
y = 1
anchor = "top_left"

Bottom Right

[[widgets]]
type = "compass"
name = "compass"
x = "86%"
y = "78%"
anchor = "bottom_right"

Compact Styles

Minimal Compass

[[widgets]]
type = "compass"
name = "compass"
style = "minimal"
width = 7
height = 3
# Shows: N E S W in a row

Icons Only

[[widgets]]
type = "compass"
name = "compass"
style = "icons"
width = 9
height = 5
# Shows directional arrows only

Full Rose

[[widgets]]
type = "compass"
name = "compass"
style = "rose"
width = 15
height = 9
# Shows full compass rose with all directions

Compass with Room Name

Combine compass with room title:

[[widgets]]
type = "compass"
name = "compass_with_room"
x = 75
y = 0
width = 25
height = 10
style = "unicode"
show_room_name = true
room_name_position = "top"

Toggle Visibility

Keybind to show/hide compass:

[keybinds."ctrl+c"]
action = "toggle_widget"
widget = "compass"

How It Works

Z-Index Layering

Higher z_index draws on top:

z_index = 1   # Main window (bottom)
z_index = 10  # Compass (top)

Transparency

When transparent = true:

  • Background is not drawn
  • Text underneath may show through
  • opacity controls visibility (0.0-1.0)

Click-Through

click_through = true  # Clicks go to window below
click_through = false # Compass captures clicks

Variations

Mini Compass

Smallest possible:

[[widgets]]
type = "compass"
name = "mini_compass"
style = "dots"
width = 5
height = 3
# Shows • for available directions

Direction Indicators Only

Show only when exits available:

[[widgets]]
type = "compass"
name = "compass"
style = "indicators"
hide_when_empty = true

Centered Floating

[[widgets]]
type = "compass"
name = "compass"
x = "center"
y = 2
width = 15
height = 7
z_index = 10

Tips

  1. Test Overlap: Ensure compass doesn’t cover important text
  2. Use Borders Wisely: Borderless saves space
  3. Consider Clickability: Click navigation is convenient
  4. Adjust Opacity: Find balance between visible and unobtrusive

Troubleshooting

Compass Covers Text

  • Reduce compass size
  • Move to corner with less activity
  • Add toggle keybind

Clicks Not Working

  • Check clickable = true
  • Verify z_index is highest
  • Ensure not blocked by click_through

See Also

Transparent Overlays

Create semi-transparent widgets that layer over other content.

Goal

Display status information as overlays without completely blocking the view of the main game window.

Basic Transparent Widget

[[widgets]]
type = "progress"
name = "health_overlay"
title = ""
x = 1
y = 1
width = 20
height = 1
data_source = "vitals.health"
z_index = 100
transparent = true
opacity = 0.7
border = false

Overlay Status Bar

Minimal status overlay at top of screen:

# Transparent status strip
[[widgets]]
type = "dashboard"
name = "status_overlay"
x = 0
y = 0
width = 100
height = 1
z_index = 100
transparent = true
opacity = 0.8
border = false
components = ["health_mini", "mana_mini", "rt_mini"]

# Component definitions
[widgets.status_overlay.health_mini]
type = "progress"
width = 15
data_source = "vitals.health"
format = "HP:{value}"

[widgets.status_overlay.mana_mini]
type = "progress"
width = 15
data_source = "vitals.mana"
format = "MP:{value}"

[widgets.status_overlay.rt_mini]
type = "countdown"
width = 10
data_source = "roundtime"
format = "RT:{value}"

Corner Vitals Overlay

Health/mana in corner:

[[widgets]]
type = "text"
name = "main"
x = 0
y = 0
width = 100
height = 100
z_index = 1

# Health overlay (top left)
[[widgets]]
type = "progress"
name = "health"
x = 1
y = 1
width = 15
height = 1
data_source = "vitals.health"
z_index = 50
transparent = true
opacity = 0.75
border = false
show_text = true
format = "❤ {percent}%"

# Mana overlay (below health)
[[widgets]]
type = "progress"
name = "mana"
x = 1
y = 2
width = 15
height = 1
data_source = "vitals.mana"
z_index = 50
transparent = true
opacity = 0.75
border = false
show_text = true
format = "✦ {percent}%"

Overlay HUD Panel

Grouped status overlays:

[[widgets]]
type = "dashboard"
name = "hud"
x = "75%"
y = 1
width = "24%"
height = 15
z_index = 100
transparent = true
opacity = 0.85
border_style = "rounded"
border_opacity = 0.5
components = ["vitals", "timers", "status"]

[widgets.hud.vitals]
layout = "vertical"
items = ["health", "mana", "stamina", "spirit"]

[widgets.hud.timers]
layout = "horizontal"
items = ["roundtime", "casttime"]

[widgets.hud.status]
type = "indicator"
indicators = ["hidden", "stunned", "prone"]

Notification Overlay

Temporary overlay for alerts:

[[widgets]]
type = "text"
name = "notification"
x = "center"
y = 5
width = 40
height = 3
z_index = 200
transparent = true
opacity = 0.9
visible = false
auto_hide = 3000
border_style = "double"
align = "center"

Trigger configuration:

[[triggers]]
pattern = "You are stunned"
command = ".notify_show Stunned!"

Opacity Levels

Recommended opacity settings:

Use CaseOpacityNotes
Critical alerts0.95Nearly opaque
Status bars0.75-0.85Readable but see-through
Background info0.5-0.6Subtle overlay
Watermark0.2-0.3Barely visible

How It Works

Transparency Rendering

When transparent = true:

  1. Background is not filled
  2. Only text/graphics are drawn
  3. opacity affects all rendering (0.0-1.0)

Z-Index Stacking

z_index = 1    # Base layer (main window)
z_index = 50   # Mid layer (status)
z_index = 100  # Top layer (HUD)
z_index = 200  # Alert layer (notifications)

Color Blending

With transparency, colors blend:

# Original: #ff0000 (red) at 0.5 opacity
# Over:     #000000 (black) background
# Result:   ~#800000 (dark red)

Advanced: Conditional Transparency

Change opacity based on state:

[[widgets]]
type = "progress"
name = "health"
transparent = true
opacity = 0.5

# Increase opacity when low
[widgets.health.dynamic_opacity]
condition = "value < 30"
opacity = 0.95

Tips

  1. Test Readability: Ensure text is readable over content
  2. Use High Contrast: Bright colors work better transparent
  3. Consider Activity: Busy areas need lower opacity
  4. Add Borders: Light borders improve definition

Troubleshooting

Text Hard to Read

  • Increase opacity
  • Add background blur (if supported)
  • Use high contrast colors
  • Add shadow/outline to text

Overlay Blocks Interaction

click_through = true  # Pass clicks to window below

Flickering

  • Reduce render rate for overlay
  • Enable double buffering
  • Use solid backgrounds for fast-updating widgets

Terminal Support

Transparency requires terminal support:

  • Full support: iTerm2, Kitty, WezTerm
  • Partial: Windows Terminal (with settings)
  • None: Basic terminals, SSH

Check support:

echo $COLORTERM  # Should include "truecolor"

See Also

Combat Alerts

Set up visual and audio notifications for combat events.

Goal

Never miss critical combat information with customized alerts for stuns, low health, deaths, and other important events.

Visual Alerts

Critical Highlight Pattern

# highlights.toml

# Stunned - high visibility
[[highlights]]
pattern = "(?i)you are stunned"
fg = "black"
bg = "yellow"
bold = true
flash = true

# Webbed
[[highlights]]
pattern = "(?i)webs? (stick|entangle|ensnare)"
fg = "black"
bg = "magenta"
bold = true

# Low health warning
[[highlights]]
pattern = "feel your life fading"
fg = "white"
bg = "red"
bold = true
flash = true

# Enemy death
[[highlights]]
pattern = "falls dead"
fg = "bright_yellow"
bold = true

# Critical hit received
[[highlights]]
pattern = "\\*\\* .+ \\*\\*"
fg = "bright_red"
bold = true

Audio Alerts

Sound Triggers

# triggers.toml

# Stun alert
[[triggers]]
name = "stun_sound"
pattern = "(?i)you are stunned"
sound = "alert_high.wav"
cooldown = 500

# Low health alert
[[triggers]]
name = "health_critical"
pattern = "feel your life fading|growing weak|death is near"
sound = "alarm.wav"
cooldown = 2000

# Enemy dies
[[triggers]]
name = "kill_sound"
pattern = "falls dead|crumples to the ground"
sound = "success.wav"
cooldown = 100

# Webbed
[[triggers]]
name = "web_sound"
pattern = "(?i)webs? (stick|entangle)"
sound = "warning.wav"

TTS Alerts

# triggers.toml

[[triggers]]
name = "stun_tts"
pattern = "(?i)you are stunned"
tts = "Stunned!"
tts_priority = "high"

[[triggers]]
name = "health_tts"
pattern = "feel your life fading"
tts = "Health critical!"
tts_priority = "urgent"

Status Widget Alerts

Flashing Indicators

# layout.toml

[[widgets]]
type = "indicator"
name = "combat_status"
x = 0
y = 85
width = 30
height = 3
indicators = ["stunned", "webbed", "prone", "bleeding"]

# Indicator styles
[widgets.combat_status.styles]
stunned = { fg = "black", bg = "yellow", flash = true }
webbed = { fg = "black", bg = "magenta", flash = true }
prone = { fg = "white", bg = "red" }
bleeding = { fg = "red", bg = "black", flash = true }

Health Bar Alerts

[[widgets]]
type = "progress"
name = "health"
data_source = "vitals.health"
# Color changes at thresholds
color_thresholds = [
    { value = 100, color = "health" },
    { value = 50, color = "health_low" },
    { value = 25, color = "health_critical", flash = true }
]

Combat Dashboard

Combined alert display:

[[widgets]]
type = "dashboard"
name = "combat_hud"
x = 75
y = 0
width = 25
height = 20
title = "Combat"

[widgets.combat_hud.components]
health = { type = "progress", data = "vitals.health" }
mana = { type = "progress", data = "vitals.mana" }
rt = { type = "countdown", data = "roundtime" }
status = { type = "indicator", items = ["stunned", "webbed", "prone"] }
target = { type = "text", data = "combat.target" }

Notification System

[[triggers]]
name = "death_notify"
pattern = "seems to have died|falls dead"
command = ".notify Enemy Killed!"

[[triggers]]
name = "stun_notify"
pattern = "(?i)you are stunned"
command = ".notify STUNNED!"
notification_style = "urgent"

Screen Flash

[[triggers]]
name = "critical_flash"
pattern = "feel your life fading"
action = "flash_screen"
flash_color = "red"
flash_duration = 200

Complete Combat Setup

highlights.toml

# Combat highlights

# Status effects
[[highlights]]
pattern = "(?i)you are stunned"
fg = "black"
bg = "yellow"
bold = true

[[highlights]]
pattern = "(?i)webs? (stick|entangle|ensnare)"
fg = "black"
bg = "magenta"
bold = true

[[highlights]]
pattern = "(?i)(knocked|fall) (down|prone|to the ground)"
fg = "black"
bg = "cyan"
bold = true

# Damage
[[highlights]]
pattern = "\\*\\* .+ \\*\\*"
fg = "bright_red"
bold = true

[[highlights]]
pattern = "\\d+ points? of damage"
fg = "red"

# Success
[[highlights]]
pattern = "falls dead|crumples"
fg = "bright_yellow"
bold = true

[[highlights]]
pattern = "(strike|hit|slash|stab).*(head|neck|chest)"
fg = "bright_green"

triggers.toml

# Combat triggers

[[triggers]]
name = "stun_alert"
pattern = "(?i)you are stunned"
sound = "stun.wav"
tts = "Stunned"
cooldown = 500
enabled = true

[[triggers]]
name = "web_alert"
pattern = "(?i)webs? (stick|entangle)"
sound = "web.wav"
cooldown = 500

[[triggers]]
name = "health_warning"
pattern = "feel your life fading|death is near"
sound = "alarm.wav"
tts = "Health critical"
tts_priority = "urgent"
cooldown = 3000

[[triggers]]
name = "kill_confirm"
pattern = "falls dead|crumples"
sound = "kill.wav"
cooldown = 100

Customizing Sounds

Sound File Locations

~/.two-face/sounds/
├── alert_high.wav
├── alarm.wav
├── success.wav
├── warning.wav
├── stun.wav
├── web.wav
└── kill.wav

Sound Settings

# config.toml
[sound]
enabled = true
volume = 0.7
alert_volume = 1.0  # Louder for alerts

Tips

  1. Prioritize Alerts: Not everything needs sound
  2. Use Cooldowns: Prevent alert spam
  3. Test Patterns: Verify regex matches correctly
  4. Balance Volume: Alerts should be noticeable but not jarring

See Also

Custom Status Bar

Create a personalized status display with exactly the information you need.

Goal

Build a compact status bar showing vitals, timers, and status in a format you design.

Basic Status Bar

Single-line status at bottom of screen:

[[widgets]]
type = "dashboard"
name = "statusbar"
x = 0
y = 98
width = 100
height = 2
border = false
layout = "horizontal"
components = ["health", "mana", "stamina", "spirit", "rt", "stance"]
spacing = 2

[widgets.statusbar.health]
type = "text"
format = "HP:{vitals.health}%"
color_condition = [
    { if = "value >= 70", color = "green" },
    { if = "value >= 30", color = "yellow" },
    { if = "value < 30", color = "red" }
]

[widgets.statusbar.mana]
type = "text"
format = "MP:{vitals.mana}%"
color = "blue"

[widgets.statusbar.stamina]
type = "text"
format = "ST:{vitals.stamina}%"
color = "orange"

[widgets.statusbar.spirit]
type = "text"
format = "SP:{vitals.spirit}%"
color = "magenta"

[widgets.statusbar.rt]
type = "text"
format = "RT:{roundtime}"
visible_when = "roundtime > 0"
color = "cyan"

[widgets.statusbar.stance]
type = "text"
format = "[{stance}]"
color = "white"

Mini Vitals Bar

Compact progress bars:

[[widgets]]
type = "dashboard"
name = "mini_vitals"
x = 0
y = 0
width = 60
height = 1
border = false
layout = "horizontal"

[widgets.mini_vitals.components]
hp = { type = "progress", width = 12, data = "vitals.health", format = "♥{value}" }
mp = { type = "progress", width = 12, data = "vitals.mana", format = "♦{value}" }
st = { type = "progress", width = 12, data = "vitals.stamina", format = "⚡{value}" }
sp = { type = "progress", width = 12, data = "vitals.spirit", format = "✧{value}" }

Icon Status Bar

Using Unicode symbols:

[[widgets]]
type = "dashboard"
name = "icon_status"
x = 0
y = 99
width = 100
height = 1
border = false

# Format: ❤95 ♦100 ⚡87 ✧100 ⏱3 🛡Off 👁Hid
components = [
    { icon = "❤", data = "vitals.health", color = "health" },
    { icon = "♦", data = "vitals.mana", color = "mana" },
    { icon = "⚡", data = "vitals.stamina", color = "stamina" },
    { icon = "✧", data = "vitals.spirit", color = "spirit" },
    { icon = "⏱", data = "roundtime", visible_when = "> 0" },
    { icon = "🛡", data = "stance", format = "{short}" },
    { icon = "👁", data = "hidden", format = "{status}" }
]

Two-Line Status

More detailed status bar:

[[widgets]]
type = "dashboard"
name = "status_two_line"
x = 0
y = 97
width = 100
height = 3

[widgets.status_two_line.line1]
layout = "horizontal"
items = [
    "HP: {vitals.health}%",
    "MP: {vitals.mana}%",
    "ST: {vitals.stamina}%",
    "SP: {vitals.spirit}%"
]

[widgets.status_two_line.line2]
layout = "horizontal"
items = [
    "RT: {roundtime}s",
    "CT: {casttime}s",
    "Stance: {stance}",
    "Room: {room.name}"
]

Status with Indicators

Include status flags:

[[widgets]]
type = "dashboard"
name = "full_status"
x = 0
y = 95
width = 100
height = 5

[widgets.full_status.sections]

vitals = { row = 1, content = "HP:{health}% MP:{mana}% ST:{stamina}% SP:{spirit}%" }

timers = { row = 2, content = "RT:{rt} CT:{ct}" }

flags = {
    row = 3,
    type = "indicators",
    items = ["hidden", "invisible", "stunned", "webbed", "prone"],
    style = "compact"
}

location = { row = 4, content = "[{room.name}]", align = "center" }

Minimal Status

Just the essentials:

[[widgets]]
type = "text"
name = "minimal_status"
x = 0
y = 99
width = 100
height = 1
border = false
content = "{health}❤ {mana}♦ {rt}⏱ {room.short_name}"

Contextual Status

Shows different info based on situation:

[[widgets]]
type = "dashboard"
name = "context_status"

[widgets.context_status.modes]

# Default mode
default = "HP:{health} MP:{mana} [{room.name}]"

# Combat mode (when in combat)
combat = "HP:{health} MP:{mana} RT:{rt} Target:{target}"

# Town mode (when in town)
town = "Silver:{silver} [{room.name}]"

[widgets.context_status.mode_triggers]
combat = "roundtime > 0 OR target != ''"
town = "room.type == 'town'"

Color Themes

Light Theme Status

[widgets.statusbar.theme]
background = "#e0e0e0"
text = "#333333"
health = "#00aa00"
health_low = "#aaaa00"
health_critical = "#aa0000"
mana = "#0000aa"

Dark Theme Status

[widgets.statusbar.theme]
background = "#1a1a1a"
text = "#c0c0c0"
health = "#00ff00"
health_low = "#ffff00"
health_critical = "#ff0000"
mana = "#0080ff"

Dynamic Updates

Flashing on Change

[widgets.statusbar.health]
flash_on_decrease = true
flash_duration = 500
flash_color = "red"

Smooth Transitions

[widgets.statusbar.health]
animate_changes = true
animation_duration = 200

Tips

  1. Keep It Compact: Status bar should be quick to read
  2. Prioritize Information: Most important data first/largest
  3. Use Color Wisely: Colors should convey meaning
  4. Test Readability: Ensure text is readable at a glance

Complete Example

# A complete status bar setup

[[widgets]]
type = "dashboard"
name = "main_status"
x = 0
y = 98
width = 100
height = 2
border = false
background = "#1a1a1a"

[widgets.main_status.row1]
items = [
    { text = "❤ ", color = "red" },
    { data = "vitals.health", width = 3, align = "right" },
    { text = "% " },
    { text = "♦ ", color = "blue" },
    { data = "vitals.mana", width = 3, align = "right" },
    { text = "% " },
    { text = "⚡ ", color = "orange" },
    { data = "vitals.stamina", width = 3, align = "right" },
    { text = "% " },
    { text = "│ ", color = "gray" },
    { text = "RT:", visible_when = "roundtime > 0" },
    { data = "roundtime", visible_when = "roundtime > 0" },
    { text = " │ ", color = "gray" },
    { indicators = ["hidden", "stunned", "prone"], style = "icons" }
]

See Also

Tabbed Channels

Organize different communication streams into tabs for cleaner chat management.

Goal

Separate game text, combat, and various chat channels into organized tabs you can quickly switch between.

Basic Tabbed Chat

[[widgets]]
type = "tabbed_text"
name = "channels"
x = 0
y = 60
width = 100
height = 25

[[widgets.channels.tabs]]
name = "All"
streams = ["speech", "whisper", "thoughts", "group"]
scrollback = 2000

[[widgets.channels.tabs]]
name = "Speech"
streams = ["speech"]
scrollback = 1000

[[widgets.channels.tabs]]
name = "Whispers"
streams = ["whisper"]
scrollback = 500
highlight_new = true

[[widgets.channels.tabs]]
name = "Thoughts"
streams = ["thoughts"]
scrollback = 500

Combat + Chat Tabs

Separate combat from communication:

[[widgets]]
type = "tabbed_text"
name = "main_tabs"
x = 0
y = 0
width = 75
height = 85

[[widgets.main_tabs.tabs]]
name = "Game"
streams = ["main", "room"]
scrollback = 5000
default = true

[[widgets.main_tabs.tabs]]
name = "Combat"
streams = ["combat"]
scrollback = 2000
notify_on_activity = true

[[widgets.main_tabs.tabs]]
name = "Chat"
streams = ["speech", "whisper", "thoughts"]
scrollback = 1000
notify_on_activity = true

[[widgets.main_tabs.tabs]]
name = "System"
streams = ["logons", "deaths", "experience"]
scrollback = 500

Notification Indicators

Show unread indicators on tabs:

[[widgets]]
type = "tabbed_text"
name = "chat_tabs"

[widgets.chat_tabs.settings]
show_unread_count = true
unread_indicator = "●"
unread_color = "yellow"
flash_on_highlight = true

[[widgets.chat_tabs.tabs]]
name = "Whisper"
streams = ["whisper"]
# Tab shows: "Whisper ●3" when 3 unread messages
notify_pattern = ".*"  # All messages count

Keybind Navigation

Quick tab switching:

# keybinds.toml

[keybinds."alt+1"]
action = "switch_tab"
widget = "channels"
tab = 0

[keybinds."alt+2"]
action = "switch_tab"
widget = "channels"
tab = 1

[keybinds."alt+3"]
action = "switch_tab"
widget = "channels"
tab = 2

[keybinds."alt+4"]
action = "switch_tab"
widget = "channels"
tab = 3

# Cycle tabs
[keybinds."ctrl+tab"]
action = "next_tab"
widget = "channels"

[keybinds."ctrl+shift+tab"]
action = "prev_tab"
widget = "channels"

Filtered Tabs

Create tabs with filtered content:

[[widgets.channels.tabs]]
name = "Trade"
streams = ["speech"]
filter_pattern = "(?i)(sell|buy|trade|silver|coin)"
scrollback = 500

[[widgets.channels.tabs]]
name = "Group"
streams = ["speech", "thoughts"]
filter_pattern = "\\[Group\\]|\\[Party\\]"
scrollback = 500

Tab Styles

Minimal Tabs

[widgets.channels.style]
tab_position = "top"
tab_style = "minimal"
# Shows: [Game|Combat|Chat]
separator = "|"
active_indicator = "underline"

Full Tabs

[widgets.channels.style]
tab_position = "top"
tab_style = "full"
# Shows: ╔════════╗╔═══════╗
#        ║  Game  ║║Combat ║
active_bg = "blue"
inactive_bg = "gray"

Bottom Tabs

[widgets.channels.style]
tab_position = "bottom"

Per-Tab Settings

Different settings per tab:

[[widgets.channels.tabs]]
name = "Game"
streams = ["main", "room"]
scrollback = 5000
wrap = true
timestamps = false

[[widgets.channels.tabs]]
name = "Combat"
streams = ["combat"]
scrollback = 1000
wrap = false
timestamps = true
timestamp_format = "[%H:%M:%S]"

[[widgets.channels.tabs]]
name = "Log"
streams = ["experience", "logons", "deaths"]
scrollback = 500
timestamps = true
log_to_file = true
log_file = "~/.two-face/logs/events.log"

Searchable Tabs

Enable search within tabs:

[widgets.channels.settings]
searchable = true
search_keybind = "ctrl+f"
search_highlight = "yellow"

Auto-Focus Tab

Switch to tab on activity:

[[widgets.channels.tabs]]
name = "Whispers"
streams = ["whisper"]
auto_focus_on_activity = true
# Automatically switches to this tab when whisper received

Or only for specific patterns:

[[widgets.channels.tabs]]
name = "Important"
streams = ["whisper", "thoughts"]
auto_focus_pattern = "(?i)(urgent|emergency|help)"

Complete Setup

layout.toml

# Main game area with tabs
[[widgets]]
type = "tabbed_text"
name = "main_tabs"
x = 0
y = 0
width = 75
height = 70

[widgets.main_tabs.settings]
tab_position = "top"
show_unread_count = true
default_tab = "Game"

[[widgets.main_tabs.tabs]]
name = "Game"
streams = ["main", "room"]
scrollback = 5000

[[widgets.main_tabs.tabs]]
name = "Combat"
streams = ["combat"]
scrollback = 2000

# Chat area with tabs
[[widgets]]
type = "tabbed_text"
name = "chat_tabs"
x = 0
y = 71
width = 75
height = 14

[widgets.chat_tabs.settings]
tab_position = "top"
show_unread_count = true

[[widgets.chat_tabs.tabs]]
name = "All"
streams = ["speech", "whisper", "thoughts"]
scrollback = 1000

[[widgets.chat_tabs.tabs]]
name = "Local"
streams = ["speech"]

[[widgets.chat_tabs.tabs]]
name = "Private"
streams = ["whisper"]
notify_on_activity = true

[[widgets.chat_tabs.tabs]]
name = "ESP"
streams = ["thoughts"]

keybinds.toml

# Tab navigation
[keybinds."f1"]
action = "focus_widget"
widget = "main_tabs"

[keybinds."f2"]
action = "focus_widget"
widget = "chat_tabs"

[keybinds."ctrl+1"]
action = "switch_tab"
widget = "main_tabs"
tab = 0

[keybinds."ctrl+2"]
action = "switch_tab"
widget = "main_tabs"
tab = 1

Tips

  1. Don’t Over-Tab: Too many tabs defeats organization
  2. Use Notifications: Visual cues for important tabs
  3. Set Keybinds: Quick switching is essential
  4. Filter Wisely: Regex filters can miss messages

See Also

Hunting HUD

Create an optimized heads-up display for hunting with all critical information visible at a glance.

Goal

A compact layout showing health, mana, roundtime, target, buffs, and room info - everything a hunter needs without cluttering the main text area.

Layout

┌─────────────────────────────────────────────┬──────────────────┐
│                                             │ ▓▓▓▓▓▓▓▓░░ 326   │ Health
│                                             │ ▓▓▓▓▓▓▓▓▓▓ 481   │ Mana
│                                             │ ▓▓▓▓▓▓▓▓▓░ 223   │ Stamina
│              Main Game Text                 ├──────────────────┤
│                                             │ RT: ██░░░ 3s     │
│                                             │ CT: ░░░░░ 0s     │
│                                             ├──────────────────┤
│                                             │    N             │
│                                             │  W + E           │
│                                             │    S             │
├─────────────────────────────────────────────┼──────────────────┤
│ Room: Angargreft, Pits of the Dead         │ Target: valravn  │
│ Exits: north, southeast                     │ [HIDDEN] [STANCE]│
├─────────────────────────────────────────────┴──────────────────┤
│ > _                                                            │
└────────────────────────────────────────────────────────────────┘

Configuration

layout.toml

[layout]
name = "Hunting HUD"
columns = 120
rows = 40

# Main text window - takes most of the screen
[[widgets]]
type = "text"
name = "main"
stream = "main"
x = 0
y = 0
width = 85
height = 32
buffer_size = 3000
show_border = true
title = "Game"

# Vitals panel (right side, top)
[[widgets]]
type = "progress"
name = "health"
data_source = "vitals.health"
x = 85
y = 0
width = 35
height = 1
color = "health"
show_text = true
show_border = false

[[widgets]]
type = "progress"
name = "mana"
data_source = "vitals.mana"
x = 85
y = 1
width = 35
height = 1
color = "mana"
show_text = true
show_border = false

[[widgets]]
type = "progress"
name = "stamina"
data_source = "vitals.stamina"
x = 85
y = 2
width = 35
height = 1
color = "stamina"
show_text = true
show_border = false

[[widgets]]
type = "progress"
name = "spirit"
data_source = "vitals.spirit"
x = 85
y = 3
width = 35
height = 1
color = "spirit"
show_text = true
show_border = false

# Timers
[[widgets]]
type = "countdown"
name = "roundtime"
countdown_id = "roundtime"
x = 85
y = 5
width = 35
height = 1
color = "yellow"
label = "RT"

[[widgets]]
type = "countdown"
name = "casttime"
countdown_id = "casttime"
x = 85
y = 6
width = 35
height = 1
color = "cyan"
label = "CT"

# Compass
[[widgets]]
type = "compass"
name = "compass"
x = 85
y = 8
width = 35
height = 5
style = "graphical"
show_border = true

# Status indicators
[[widgets]]
type = "indicator"
name = "status"
x = 85
y = 14
width = 35
height = 2
indicators = ["hidden", "stunned", "webbed", "prone", "kneeling", "sitting"]
compact = true

# Active buffs (scrollable)
[[widgets]]
type = "effects"
name = "buffs"
category = "Buffs"
x = 85
y = 17
width = 35
height = 15
show_timers = true
show_border = true
title = "Buffs"

# Room info bar
[[widgets]]
type = "room"
name = "room_bar"
x = 0
y = 32
width = 85
height = 3
show_exits = true
compact = true
show_border = true

# Target display
[[widgets]]
type = "text"
name = "target"
stream = "target"
x = 85
y = 32
width = 35
height = 3
show_border = true
title = "Target"

# Command input
[[widgets]]
type = "input"
name = "command"
x = 0
y = 35
width = 120
height = 3
history_size = 200
prompt = "> "
show_border = true

highlights.toml (Hunting)

# Monster names (bold red)
[[highlights]]
pattern = "\\b(orc|troll|goblin|valravn|panther|drake|golem)\\b"
fg = "bright_red"
bold = true

# Your attacks hitting
[[highlights]]
pattern = "(You|Your).*(strike|hit|slash|stab|fire|cast)"
fg = "bright_green"

# Critical hits
[[highlights]]
pattern = "\\*\\* .+ \\*\\*"
fg = "bright_yellow"
bold = true

# Damage numbers
[[highlights]]
pattern = "\\d+ points? of damage"
fg = "red"

# Enemy death
[[highlights]]
pattern = "(falls dead|crumples|expires|dies)"
fg = "bright_yellow"
bold = true

# Loot
[[highlights]]
pattern = "(silver|coins|silvers|drops? a)"
fg = "bright_yellow"

# Stun warning
[[highlights]]
pattern = "(?i)you are stunned"
fg = "black"
bg = "yellow"
bold = true
flash = true

# Webbed
[[highlights]]
pattern = "(?i)webs? (stick|entangle)"
fg = "black"
bg = "magenta"
bold = true

keybinds.toml (Hunting)

# Quick stance changes
[[keybinds]]
key = "F1"
action = "send"
command = "stance offensive"

[[keybinds]]
key = "F2"
action = "send"
command = "stance defensive"

# Quick hide
[[keybinds]]
key = "F3"
action = "send"
command = "hide"

# Target next
[[keybinds]]
key = "F4"
action = "send"
command = "target next"

# Attack
[[keybinds]]
key = "F5"
action = "send"
command = "attack"

# Loot all
[[keybinds]]
key = "F6"
action = "send"
command = "loot"

# Quick look
[[keybinds]]
key = "F7"
action = "send"
command = "look"

# Health check
[[keybinds]]
key = "F8"
action = "send"
command = "health"

Triggers for Hunting

# triggers.toml

# Stun alert
[[triggers]]
name = "stun_alert"
pattern = "(?i)you are stunned"
sound = "stun.wav"
tts = "Stunned"
cooldown = 500

# Low health warning
[[triggers]]
name = "health_low"
pattern = "feel your life fading|death is near"
sound = "alarm.wav"
tts = "Health critical"
cooldown = 3000

# Enemy death confirmation
[[triggers]]
name = "kill_confirm"
pattern = "falls dead|crumples|expires"
sound = "kill.wav"
cooldown = 100

Tips

  1. Adjust widths based on your terminal size
  2. Add more indicators for your profession-specific statuses
  3. Customize highlights for creatures in your hunting area
  4. Use keybinds for your most common combat actions

See Also

Buff Tracker

Display active buffs, debuffs, and spell effects with countdown timers.

Goal

Track all active effects on your character with visual progress bars showing time remaining.

The Problem

GemStone IV sends buff data via the dialogData id='Buffs' XML elements. These include spell effects, sigils, and other timed buffs with progress bars and countdown timers.

Layout

┌─────────────────────────────────────────────┬────────────────────────┐
│                                             │ Active Effects         │
│                                             ├────────────────────────┤
│                                             │ ▓▓▓▓▓▓▓░░░ 3:44        │
│                                             │ Fasthr's Reward        │
│                                             │                        │
│              Main Game Text                 │ ▓▓▓▓▓▓▓▓▓▓ 0:76        │
│                                             │ Celerity               │
│                                             │                        │
│                                             │ ▓▓▓▓░░░░░░ 1:57        │
│                                             │ Sigil of Defense       │
│                                             │                        │
│                                             │ ▓▓░░░░░░░░ 0:08        │
│                                             │ Sigil of Major Bane    │
└─────────────────────────────────────────────┴────────────────────────┘

Configuration

layout.toml

# Active buffs panel
[[widgets]]
type = "effects"
name = "buffs"
category = "Buffs"
x = 85
y = 0
width = 35
height = 20
show_timers = true
show_border = true
title = "Active Effects"
sort_by = "time_remaining"  # or "name", "value"

# Debuffs panel (if you want separate tracking)
[[widgets]]
type = "effects"
name = "debuffs"
category = "Debuffs"
x = 85
y = 20
width = 35
height = 10
show_timers = true
show_border = true
title = "Debuffs"
color_scheme = "danger"

# Cooldowns panel
[[widgets]]
type = "effects"
name = "cooldowns"
category = "Cooldowns"
x = 85
y = 30
width = 35
height = 8
show_timers = true
show_border = true
title = "Cooldowns"

Color Configuration

# colors.toml

# Buff bar colors by time remaining
buff_full = "#00FF00"      # Green - plenty of time
buff_medium = "#FFFF00"    # Yellow - getting low
buff_low = "#FF6600"       # Orange - almost expired
buff_critical = "#FF0000"  # Red - about to expire

# Category colors
buffs_color = "#00AAFF"    # Blue for buffs
debuffs_color = "#FF0000"  # Red for debuffs
cooldowns_color = "#FFAA00" # Orange for cooldowns

Alerts for Expiring Buffs

# triggers.toml

# Celerity about to expire
[[triggers]]
name = "celerity_warning"
pattern = "The heightened speed of Celerity fades"
tts = "Celerity expired"
sound = "buff_expire.wav"

# Spell shield warning
[[triggers]]
name = "shield_warning"
pattern = "Your (spell|elemental) shield dissipates"
tts = "Shield down"
sound = "warning.wav"

# Generic buff fade
[[triggers]]
name = "buff_fade"
pattern = "The (glow|aura|effect) of .+ fades"
sound = "buff_expire.wav"
cooldown = 1000

Advanced: Profession-Specific Buffs

Ranger Buffs

[[widgets]]
type = "effects"
name = "ranger_buffs"
category = "Buffs"
filter = ["Camouflage", "Nature's Touch", "Resist Nature", "Tangle Weed"]
x = 85
y = 0
width = 35
height = 10

Wizard Buffs

[[widgets]]
type = "effects"
name = "wizard_buffs"
category = "Buffs"
filter = ["Elemental Defense", "Elemental Targeting", "Haste", "Familiar"]
x = 85
y = 0
width = 35
height = 10

Cleric Buffs

[[widgets]]
type = "effects"
name = "cleric_buffs"
category = "Buffs"
filter = ["Spirit Shield", "Spirit Warding", "Benediction", "Prayer"]
x = 85
y = 0
width = 35
height = 10

Compact Mode

For smaller displays:

[[widgets]]
type = "effects"
name = "buffs_compact"
category = "Buffs"
x = 85
y = 0
width = 25
height = 15
show_timers = true
compact = true           # Single line per buff
show_progress = false    # Hide progress bars, just show time
abbreviate_names = true  # "Sigil of Def" instead of full name

Tips

  1. Sort by time to see what’s expiring soon
  2. Use filters to show only profession-relevant buffs
  3. Add sound alerts for critical buff expirations
  4. Compact mode works well for limited screen space

XML Reference

The game sends buff data like this:

<dialogData id='Buffs' clear='t'></dialogData>
<dialogData id='Buffs'>
  <progressBar id='115' value='89' text="Fasthr's Reward"
    left='22%' top='0' width='76%' height='15' time='03:44:32'/>
  <label id='l115' value='3:44 ' top='0' left='0' justify='2'/>
  <!-- more buffs... -->
</dialogData>

Two-Face parses this into ActiveEffect elements with:

  • id - Spell/effect identifier
  • text - Display name
  • value - Progress percentage (0-100)
  • time - Time remaining string

See Also

Encumbrance Monitor

Track your carrying capacity with visual feedback and alerts.

Goal

Display your current encumbrance level with color-coded warnings as you pick up loot.

The Data

GemStone IV sends encumbrance via dialogData id='encum':

<dialogData id='encum'>
  <progressBar id='encumlevel' value='10' text='Light' .../>
  <label id='encumblurb' value='You feel confident that your load
    is not affecting your actions very much.' .../>
</dialogData>

Layout Options

Option 1: Progress Bar

[[widgets]]
type = "progress"
name = "encumbrance"
data_source = "encumbrance"
x = 0
y = 35
width = 30
height = 1
show_text = true
color_thresholds = [
    { value = 100, color = "#00FF00" },  # Light - green
    { value = 75, color = "#66FF00" },   # Moderate - yellow-green
    { value = 50, color = "#FFFF00" },   # Significant - yellow
    { value = 25, color = "#FF6600" },   # Heavy - orange
    { value = 0, color = "#FF0000" }     # Overloaded - red
]

Option 2: Text Display

[[widgets]]
type = "text"
name = "encum_display"
stream = "encumbrance"
x = 0
y = 35
width = 50
height = 2
show_border = true
title = "Load"

Option 3: In Status Bar

[[widgets]]
type = "dashboard"
name = "status_bar"
x = 0
y = 37
width = 120
height = 1
horizontal = true

[widgets.status_bar.components]
health = { type = "progress", data = "vitals.health", width = 20 }
mana = { type = "progress", data = "vitals.mana", width = 20 }
encum = { type = "progress", data = "encumbrance", width = 15 }
stance = { type = "text", data = "stance", width = 15 }

Highlights for Encumbrance Messages

# highlights.toml

# Light load - green
[[highlights]]
pattern = "Your current load is Light"
fg = "bright_green"

# Moderate - yellow
[[highlights]]
pattern = "Your current load is (Moderate|Somewhat)"
fg = "yellow"

# Heavy - orange
[[highlights]]
pattern = "Your current load is (Significant|Heavy)"
fg = "bright_yellow"
bold = true

# Overloaded - red
[[highlights]]
pattern = "Your current load is (Very Heavy|Overloaded|Extreme)"
fg = "bright_red"
bold = true

Alerts

# triggers.toml

# Getting heavy
[[triggers]]
name = "encum_heavy"
pattern = "Your load (is now|has become) (Heavy|Very Heavy)"
tts = "Load getting heavy"
sound = "warning.wav"

# Overloaded
[[triggers]]
name = "encum_overloaded"
pattern = "overloaded|encumbered"
tts = "Overloaded!"
sound = "alarm.wav"
cooldown = 5000

# Back to light
[[triggers]]
name = "encum_light"
pattern = "Your load (is now|has become) Light"
sound = "success.wav"

Encumbrance Levels

LevelValueEffect
Light0-20%No penalty
Moderate20-40%Minor RT increase
Significant40-60%Noticeable RT increase
Heavy60-80%Major penalties
Very Heavy80-100%Severe penalties
Overloaded100%+Can’t move

Merchant/Looting Setup

For heavy looting sessions:

# Dedicated encumbrance window
[[widgets]]
type = "text"
name = "loot_log"
stream = "loot"
x = 85
y = 20
width = 35
height = 10
show_border = true
title = "Loot"

# Encumbrance bar always visible
[[widgets]]
type = "progress"
name = "encum"
data_source = "encumbrance"
x = 85
y = 31
width = 35
height = 2
show_text = true
title = "Load"

Loot Highlights

# Valuable loot
[[highlights]]
pattern = "(drops? a|You also see).*(gem|jewel|gold|platinum|diamond)"
fg = "bright_yellow"
bold = true

# Silver/coins
[[highlights]]
pattern = "\\d+ (silver|silvers|coins)"
fg = "yellow"

# Boxes/containers
[[highlights]]
pattern = "(strongbox|chest|coffer|box|lockbox)"
fg = "cyan"

Tips

  1. Position strategically - Put encumbrance where you’ll notice it
  2. Use color coding - Visual feedback is faster than reading text
  3. Set up alerts - Don’t get stuck overloaded in a hunting ground
  4. Track loot separately - Dedicated loot window helps manage inventory

See Also

Stance Display

Show your current combat stance with quick-switch controls.

Goal

Display current stance (offensive/defensive) with visual indicator and easy keybind switching.

The Data

GemStone IV sends stance data via multiple XML elements:

<dialogData id='stance'>
  <progressBar id='pbarStance' value='100' text='defensive (100%)' .../>
</dialogData>

<dialogData id='combat'>
  <dropDownBox id='dDBStance' value="defensive"
    cmd='_stance %dDBStance%'
    content_text='offensive,advance,forward,neutral,guarded,defensive' .../>
</dialogData>

Layout Options

Option 1: Progress Bar

[[widgets]]
type = "progress"
name = "stance"
data_source = "stance"
x = 85
y = 5
width = 25
height = 1
show_text = true
color_dynamic = true  # Color based on stance type

Option 2: Text Indicator

[[widgets]]
type = "indicator"
name = "stance_indicator"
x = 85
y = 5
width = 15
height = 1
indicators = ["offensive", "defensive", "neutral"]
style = "text"

Option 3: Combined with Vitals

[[widgets]]
type = "dashboard"
name = "combat_status"
x = 85
y = 0
width = 35
height = 8
title = "Combat"

[widgets.combat_status.components]
health = { type = "progress", data = "vitals.health", row = 0 }
mana = { type = "progress", data = "vitals.mana", row = 1 }
stance = { type = "progress", data = "stance", row = 2 }
rt = { type = "countdown", data = "roundtime", row = 3 }

Stance Colors

# colors.toml

# Stance-specific colors
stance_offensive = "#FF0000"    # Red - aggressive
stance_advance = "#FF6600"      # Orange
stance_forward = "#FFAA00"      # Yellow-orange
stance_neutral = "#FFFF00"      # Yellow
stance_guarded = "#00AAFF"      # Light blue
stance_defensive = "#00FF00"    # Green - safe

Quick Stance Keybinds

# keybinds.toml

# F1-F6 for stance ladder
[[keybinds]]
key = "F1"
action = "send"
command = "stance offensive"

[[keybinds]]
key = "F2"
action = "send"
command = "stance advance"

[[keybinds]]
key = "F3"
action = "send"
command = "stance forward"

[[keybinds]]
key = "F4"
action = "send"
command = "stance neutral"

[[keybinds]]
key = "F5"
action = "send"
command = "stance guarded"

[[keybinds]]
key = "F6"
action = "send"
command = "stance defensive"

# Quick toggles
[[keybinds]]
key = "Ctrl+O"
action = "send"
command = "stance offensive"

[[keybinds]]
key = "Ctrl+D"
action = "send"
command = "stance defensive"

Stance Highlights

# highlights.toml

# Stance change confirmations
[[highlights]]
pattern = "You are now in an? (offensive|advance) stance"
fg = "red"
bold = true

[[highlights]]
pattern = "You are now in a (forward|neutral) stance"
fg = "yellow"

[[highlights]]
pattern = "You are now in a (guarded|defensive) stance"
fg = "green"
bold = true

Stance Ladder Reference

StanceDS%AS PenaltyBest For
Offensive0%NoneMax damage
Advance20%-5Aggressive
Forward40%-10Balanced attack
Neutral60%-15Balanced
Guarded80%-20Cautious
Defensive100%-25Max defense

Combat Triggers

# triggers.toml

# Auto-defensive on stun
[[triggers]]
name = "stun_defensive"
pattern = "(?i)you are stunned"
command = "stance defensive"
enabled = false  # Enable if you want auto-stance

# Stance reminder when attacking
[[triggers]]
name = "offensive_reminder"
pattern = "Roundtime: \\d+ sec"
condition = "stance != offensive"
tts = "Not in offensive stance"
enabled = false

Tips

  1. Color code your stance for instant recognition
  2. Keybind frequently used stances - F1/F6 for offensive/defensive is common
  3. Consider auto-stance triggers for safety (controversial - some consider it automation)
  4. Position near RT - Stance and roundtime are often checked together

See Also

Target Window

Display your current combat target with quick targeting controls.

Goal

Show the currently targeted creature with health estimation and quick-switch capabilities.

The Data

GemStone IV sends target data via:

<dialogData id='combat'>
  <dropDownBox id='dDBTarget' value="black valravn"
    cmd="target %dDBTarget%"
    content_text="none,black valravn,troll"
    content_value="target help,#534103532,#534103533" .../>
</dialogData>

And targeting messages:

You are now targeting an eyeless black valravn.

Layout

[[widgets]]
type = "text"
name = "target"
stream = "target"
x = 85
y = 10
width = 35
height = 5
show_border = true
title = "Target"

Target Highlights

# highlights.toml

# Currently targeting
[[highlights]]
pattern = "You are now targeting"
fg = "bright_cyan"
bold = true

# Target cleared
[[highlights]]
pattern = "You are no longer targeting"
fg = "gray"

# Target status
[[highlights]]
pattern = "appears (dead|stunned|webbed|prone)"
fg = "bright_yellow"
bold = true

Targeting Keybinds

# keybinds.toml

# Target cycling
[[keybinds]]
key = "Tab"
action = "send"
command = "target next"

[[keybinds]]
key = "Shift+Tab"
action = "send"
command = "target previous"

# Clear target
[[keybinds]]
key = "Escape"
action = "send"
command = "target clear"

# Target random
[[keybinds]]
key = "Ctrl+T"
action = "send"
command = "target random"

# Attack current target
[[keybinds]]
key = "F5"
action = "send"
command = "attack"

# Target and attack
[[keybinds]]
key = "Ctrl+A"
action = "send"
command = "target random;attack"

Room Objects Integration

Combine with room objects display:

[[widgets]]
type = "room"
name = "room_with_targets"
x = 0
y = 30
width = 85
height = 5
show_objects = true
show_players = true
highlight_targets = true  # Highlight targetable creatures

Target Status Parsing

Track target condition from combat messages:

# highlights.toml

# Target health estimation
[[highlights]]
pattern = "(appears to be |seems |is )(uninjured|in good shape)"
fg = "green"

[[highlights]]
pattern = "(has minor |slight |a few )(wounds|injuries|scratches)"
fg = "yellow"

[[highlights]]
pattern = "(has moderate |significant |serious )(wounds|injuries)"
fg = "orange"

[[highlights]]
pattern = "(has severe |critical |grievous |terrible )(wounds|injuries)"
fg = "red"
bold = true

[[highlights]]
pattern = "(appears dead|lies dead|crumples|falls dead)"
fg = "bright_green"
bold = true

Multiple Target Tracking

For hunting multiple creatures:

[[widgets]]
type = "text"
name = "creatures"
stream = "room_objects"
x = 85
y = 10
width = 35
height = 10
show_border = true
title = "Creatures"
filter = "monster"  # Only show creatures, not items

Quick Target Macros

# Target specific creature types
[[keybinds]]
key = "Ctrl+1"
action = "send"
command = "target orc"

[[keybinds]]
key = "Ctrl+2"
action = "send"
command = "target troll"

[[keybinds]]
key = "Ctrl+3"
action = "send"
command = "target valravn"

# Target by condition
[[keybinds]]
key = "Ctrl+S"
action = "send"
command = "target stunned"

[[keybinds]]
key = "Ctrl+W"
action = "send"
command = "target webbed"

Combat Dashboard with Target

[[widgets]]
type = "dashboard"
name = "combat_hud"
x = 85
y = 0
width = 35
height = 20
title = "Combat"

[widgets.combat_hud.components]
target = { type = "text", data = "target", height = 3 }
health = { type = "progress", data = "vitals.health" }
rt = { type = "countdown", data = "roundtime" }
stance = { type = "progress", data = "stance" }
status = { type = "indicator", items = ["stunned", "hidden"] }

Tips

  1. Use Tab for cycling - Natural feel for target switching
  2. Color-code target status - Know at a glance if target is hurt
  3. Combine with room display - See all potential targets
  4. Quick macros - Target specific creature types you hunt often

See Also

Loot Window

Dedicated window for tracking loot drops and treasure.

Goal

Separate loot messages from main game text for easy tracking during hunting sessions.

Configuration

Basic Loot Window

[[widgets]]
type = "text"
name = "loot"
stream = "loot"
x = 85
y = 20
width = 35
height = 15
buffer_size = 500
show_border = true
title = "Loot"

Stream Filter Setup

Configure what goes to the loot stream:

# In config.toml
[streams.loot]
patterns = [
    "drops? a",
    "falls to the ground",
    "\\d+ (silver|silvers|coins)",
    "You also see",
    "(gem|jewel|gold|platinum|diamond)",
    "(strongbox|chest|coffer|lockbox)"
]

Loot Highlights

# highlights.toml

# Currency
[[highlights]]
pattern = "\\d+ (silver|silvers|coins)"
fg = "bright_yellow"
bold = true

# Valuable gems
[[highlights]]
pattern = "(diamond|emerald|ruby|sapphire|pearl)"
fg = "bright_cyan"
bold = true

# Boxes (lockpicking)
[[highlights]]
pattern = "(strongbox|chest|coffer|lockbox|box)"
fg = "cyan"

# Rare drops
[[highlights]]
pattern = "(rare|unusual|exceptional|perfect)"
fg = "bright_magenta"
bold = true

# Skins/trophies
[[highlights]]
pattern = "(skin|pelt|hide|claw|fang|horn)"
fg = "yellow"

Loot Alerts

# triggers.toml

# Big silver drop
[[triggers]]
name = "big_silver"
pattern = "(\\d{4,}) silvers?"
tts = "Big silver drop"
sound = "coins.wav"

# Box drop
[[triggers]]
name = "box_drop"
pattern = "drops? a.*(strongbox|chest|coffer)"
sound = "box.wav"

# Rare item
[[triggers]]
name = "rare_drop"
pattern = "drops? a.*(rare|unusual|exceptional)"
tts = "Rare item"
sound = "rare.wav"

Loot Summary Widget

Track session totals:

[[widgets]]
type = "dashboard"
name = "loot_summary"
x = 85
y = 35
width = 35
height = 3
title = "Session"

[widgets.loot_summary.components]
silver = { type = "counter", data = "session.silver", label = "Silver" }
boxes = { type = "counter", data = "session.boxes", label = "Boxes" }
kills = { type = "counter", data = "session.kills", label = "Kills" }

Multi-Tab Loot Tracking

[[widgets]]
type = "tabbed_text"
name = "treasure"
x = 85
y = 15
width = 35
height = 20
show_border = true
tabs = [
    { name = "Loot", stream = "loot" },
    { name = "Boxes", stream = "boxes" },
    { name = "Gems", stream = "gems" }
]

Keybinds for Looting

# keybinds.toml

# Quick loot
[[keybinds]]
key = "L"
action = "send"
command = "loot"

# Look for loot
[[keybinds]]
key = "Ctrl+L"
action = "send"
command = "look"

# Get all
[[keybinds]]
key = "G"
action = "send"
command = "get coins;get box"

# Appraise
[[keybinds]]
key = "Ctrl+A"
action = "send"
command = "appraise"

Tips

  1. Keep loot window small - Just needs to show recent drops
  2. Use sound alerts for valuable drops so you don’t miss them
  3. Color-code by value - Instantly spot the good stuff
  4. Position near encumbrance - Watch your load while looting

See Also

Inventory Browser

Navigate and display your inventory with container support.

Goal

Display inventory contents, open containers, and track worn items.

The Data

GemStone IV sends inventory via the inv stream:

<streamWindow id='inv' title='My Inventory' target='wear' ifClosed='' resident='true'/>
<clearStream id='inv' ifClosed=''/>
<pushStream id='inv'/>Your worn items are:
  a patchwork dwarf skin backpack
  an enruned urglaes band
<popStream/>

Container contents use <container> and <inv> tags:

<container id="535703780">
  <inv id="123">a silver wand</inv>
  <inv id="124">a gold ring</inv>
</container>

Basic Inventory Window

[[widgets]]
type = "text"
name = "inventory"
stream = "inv"
x = 85
y = 0
width = 35
height = 20
buffer_size = 200
show_border = true
title = "Inventory"
clickable = true  # Enable clicking items

Tabbed Inventory View

[[widgets]]
type = "tabbed_text"
name = "inventory_tabs"
x = 85
y = 0
width = 35
height = 25
show_border = true
tabs = [
    { name = "Worn", stream = "inv" },
    { name = "Pack", stream = "container_pack" },
    { name = "Disk", stream = "container_disk" },
    { name = "Locker", stream = "container_locker" }
]

Inventory Highlights

# highlights.toml

# Containers
[[highlights]]
pattern = "(backpack|satchel|cloak|pouch|sack|bag)"
fg = "cyan"

# Weapons
[[highlights]]
pattern = "(sword|dagger|bow|staff|axe|mace|falchion)"
fg = "bright_red"

# Armor
[[highlights]]
pattern = "(armor|shield|helm|gauntlet|greave|vambrace)"
fg = "bright_blue"

# Magic items
[[highlights]]
pattern = "(wand|rod|amulet|ring|crystal|orb)"
fg = "bright_magenta"

# Valuable
[[highlights]]
pattern = "(gold|silver|platinum|mithril|vultite|ora)"
fg = "bright_yellow"

Inventory Commands

# keybinds.toml

# Check inventory
[[keybinds]]
key = "I"
action = "send"
command = "inventory"

# Check specific containers
[[keybinds]]
key = "Ctrl+I"
action = "send"
command = "look in my pack"

# Quick wear/remove
[[keybinds]]
key = "Ctrl+W"
action = "send"
command = "wear my"

[[keybinds]]
key = "Ctrl+R"
action = "send"
command = "remove my"

Container Browser Widget

[[widgets]]
type = "container"
name = "container_view"
x = 85
y = 0
width = 35
height = 25
show_border = true
title = "Container"
show_count = true      # Show item count
show_capacity = true   # Show if nearly full
nested = true          # Support nested containers

Inventory Alerts

# triggers.toml

# Container full
[[triggers]]
name = "container_full"
pattern = "(can't fit|won't fit|is full|no room)"
tts = "Container full"
sound = "warning.wav"

# Item picked up
[[triggers]]
name = "item_got"
pattern = "You (pick up|get|grab)"
sound = "pickup.wav"
cooldown = 100

Hands Display Integration

[[widgets]]
type = "hands"
name = "hands"
x = 85
y = 26
width = 35
height = 4
show_spell = true
show_border = true
title = "Hands"

Full Inventory Layout

# Complete inventory management layout

[[widgets]]
type = "hands"
name = "hands"
x = 85
y = 0
width = 35
height = 3
show_spell = true
title = "Hands"

[[widgets]]
type = "text"
name = "worn"
stream = "inv"
x = 85
y = 3
width = 35
height = 15
title = "Worn Items"

[[widgets]]
type = "text"
name = "container"
stream = "container"
x = 85
y = 18
width = 35
height = 15
title = "Container"

[[widgets]]
type = "progress"
name = "encum"
data_source = "encumbrance"
x = 85
y = 33
width = 35
height = 2
title = "Load"

Tips

  1. Use tabs for different containers
  2. Enable clicking to interact with items
  3. Color-code item types for quick scanning
  4. Track encumbrance alongside inventory
  5. Set alerts for full containers

See Also

Architecture Overview

Two-Face is built on a rigorous three-layer architecture designed for maintainability, testability, and future GUI support.

Design Philosophy

Two-Face separates concerns into distinct layers:

  1. Data Layer - Pure data structures, no rendering logic
  2. Core Layer - Business logic, message processing
  3. Frontend Layer - Rendering, user interaction

This separation enables:

  • Multiple frontends - TUI now, GUI later, same logic
  • Testability - Core logic without rendering dependencies
  • Maintainability - Clear boundaries and responsibilities
  • Performance - Efficient data flow and change detection

High-Level Architecture

┌─────────────────────────────────────────────────────────────────┐
│                      FRONTEND LAYER                             │
│  ┌─────────────────┐              ┌─────────────────┐           │
│  │   TUI Frontend  │              │   GUI Frontend  │           │
│  │   (ratatui)     │              │   (egui)        │           │
│  │                 │              │   [planned]     │           │
│  └────────┬────────┘              └────────┬────────┘           │
│           │                                │                    │
│           │    Frontend Trait Interface    │                    │
│           └────────────────┬───────────────┘                    │
├────────────────────────────┼────────────────────────────────────┤
│                      CORE LAYER                                 │
│  ┌─────────────────────────┴─────────────────────────┐          │
│  │                    AppCore                        │          │
│  │  ┌─────────────┐ ┌─────────────┐ ┌─────────────┐  │          │
│  │  │MessageProc. │ │  Commands   │ │  Keybinds   │  │          │
│  │  └─────────────┘ └─────────────┘ └─────────────┘  │          │
│  └───────────────────────┬───────────────────────────┘          │
├──────────────────────────┼──────────────────────────────────────┤
│                      DATA LAYER                                 │
│  ┌───────────────────────┴───────────────────────────┐          │
│  │  ┌─────────────┐ ┌─────────────┐ ┌─────────────┐  │          │
│  │  │  GameState  │ │   UiState   │ │ WindowState │  │          │
│  │  └─────────────┘ └─────────────┘ └─────────────┘  │          │
│  └───────────────────────────────────────────────────┘          │
└─────────────────────────────────────────────────────────────────┘
                              │
                    ┌─────────┴─────────┐
                    │   Network Layer   │
                    │  (Tokio Async)    │
                    └───────────────────┘

Module Organization

src/
├── main.rs              # Entry point, CLI parsing
├── lib.rs               # Library exports
│
├── core/                # Business logic
│   ├── mod.rs
│   ├── state.rs         # GameState
│   ├── messages.rs      # MessageProcessor
│   ├── input_router.rs  # Input dispatch
│   ├── menu_actions.rs  # Menu action handlers
│   └── app_core/
│       ├── mod.rs
│       ├── state.rs     # AppCore
│       ├── commands.rs  # Built-in commands
│       ├── keybinds.rs  # Keybind dispatch
│       └── layout.rs    # Layout management
│
├── data/                # Data structures
│   ├── mod.rs
│   ├── ui_state.rs      # UiState, InputMode
│   ├── widget.rs        # TextContent, ProgressData
│   └── window.rs        # WindowState, WidgetType
│
├── frontend/            # Rendering
│   ├── mod.rs           # Frontend trait
│   ├── events.rs        # FrontendEvent
│   ├── common/          # Shared types
│   │   ├── color.rs
│   │   ├── input.rs
│   │   ├── rect.rs
│   │   └── text_input.rs
│   ├── tui/             # Terminal UI
│   │   ├── mod.rs
│   │   ├── runtime.rs   # Event loop
│   │   ├── widget_manager.rs
│   │   ├── text_window.rs
│   │   ├── progress_bar.rs
│   │   └── ... (30+ widget files)
│   └── gui/             # Native GUI (planned)
│       └── mod.rs
│
├── config.rs            # Configuration
├── config/
│   ├── highlights.rs
│   └── keybinds.rs
│
├── parser.rs            # XML parser
├── cmdlist.rs           # Context menu data
├── network.rs           # Network connections
├── theme.rs             # Theme system
├── sound.rs             # Audio playback
├── tts/                 # Text-to-speech
├── clipboard.rs         # Clipboard access
├── selection.rs         # Text selection
└── performance.rs       # Performance tracking

Layer Rules

Import Restrictions

LayerCan Import From
DataNothing else
CoreData only
FrontendData and Core
#![allow(unused)]
fn main() {
// ✅ ALLOWED
// data/ imports nothing from core/ or frontend/
// core/ imports from data/
// frontend/ imports from data/ and core/

// ❌ FORBIDDEN
// data/ importing from core/ or frontend/
// core/ importing from frontend/
}

Responsibilities

LayerResponsibilities
DataDefine state structures, no behavior
CoreProcess messages, handle commands, manage state
FrontendRender widgets, capture input, convert events

Key Components

AppCore

The central orchestrator containing all state and logic:

#![allow(unused)]
fn main() {
pub struct AppCore {
    pub config: Config,
    pub layout: Layout,
    pub game_state: GameState,
    pub ui_state: UiState,
    pub message_processor: MessageProcessor,
    pub parser: XmlParser,
}
}

Frontend Trait

Interface for UI implementations:

#![allow(unused)]
fn main() {
pub trait Frontend {
    fn poll_events(&mut self) -> Result<Vec<FrontendEvent>>;
    fn render(&mut self, app: &mut dyn Any) -> Result<()>;
    fn cleanup(&mut self) -> Result<()>;
    fn size(&self) -> (u16, u16);
}
}

Event Loop

The main loop coordinates all components:

  1. Poll user input (non-blocking)
  2. Process server messages
  3. Update state
  4. Render frame
  5. Sleep to maintain frame rate

Architecture Sections

This section covers:

Design Principles

  1. Separation of Concerns - Each layer has clear responsibilities
  2. Frontend Independence - Core logic works with any UI
  3. Event-Driven - Asynchronous network, synchronous rendering
  4. State Centralization - All state in Data layer
  5. Extensibility - New widgets/frontends plug in cleanly

Core-Data-Frontend Architecture

Two-Face strictly separates code into three layers. This design enables frontend independence and clean testing.

Layer Overview

┌─────────────────────────────────────┐
│           FRONTEND                  │
│   • Renders widgets                 │
│   • Captures user input             │
│   • Converts events                 │
│   • NO business logic               │
├─────────────────────────────────────┤
│             CORE                    │
│   • Processes messages              │
│   • Handles commands                │
│   • Dispatches keybinds             │
│   • Routes data to state            │
├─────────────────────────────────────┤
│             DATA                    │
│   • Pure data structures            │
│   • No behavior                     │
│   • Owned by Core                   │
└─────────────────────────────────────┘

Data Layer

Location: src/data/

The data layer contains pure data structures with no logic or rendering.

UiState

#![allow(unused)]
fn main() {
pub struct UiState {
    pub windows: HashMap<String, WindowState>,
    pub focused_window: Option<String>,
    pub input_mode: InputMode,
    pub popup_menu: Option<PopupMenu>,
    pub selection_state: Option<SelectionState>,
    pub search_input: String,
}
}

GameState

#![allow(unused)]
fn main() {
pub struct GameState {
    pub connected: bool,
    pub character_name: Option<String>,
    pub room_id: Option<String>,
    pub room_name: Option<String>,
    pub exits: Vec<String>,
    pub vitals: Vitals,
    pub left_hand: Option<String>,
    pub right_hand: Option<String>,
    pub spell: Option<String>,
    pub status: StatusInfo,
}
}

WindowState

#![allow(unused)]
fn main() {
pub struct WindowState {
    pub name: String,
    pub widget_type: WidgetType,
    pub streams: Vec<String>,
    pub visible: bool,
    pub rect: Rect,

    // Content (varies by widget type)
    pub text_content: Option<TextContent>,
    pub progress_data: Option<ProgressData>,
    pub countdown_data: Option<CountdownData>,
    pub compass_data: Option<CompassData>,
}
}

TextContent

#![allow(unused)]
fn main() {
pub struct TextContent {
    pub lines: VecDeque<StyledLine>,
    pub scroll_offset: usize,
    pub max_lines: usize,
    pub title: String,
    pub generation: u64,  // Change counter
}
}

Input Modes

#![allow(unused)]
fn main() {
pub enum InputMode {
    // Standard modes
    Normal,              // Command input active
    Navigation,          // Vi-style navigation
    History,             // Scrolling command history
    Search,              // Text search mode
    Menu,                // Context menu open

    // Editor modes (priority windows)
    HighlightBrowser,
    HighlightForm,
    KeybindBrowser,
    KeybindForm,
    ColorPaletteBrowser,
    WindowEditor,
    // ...
}
}

Core Layer

Location: src/core/

The core layer contains all business logic but no rendering code.

AppCore

The central orchestrator:

#![allow(unused)]
fn main() {
pub struct AppCore {
    pub config: Config,
    pub layout: Layout,
    pub game_state: GameState,
    pub ui_state: UiState,
    pub message_processor: MessageProcessor,
    pub parser: XmlParser,
    pub performance_stats: PerformanceStats,
}
}

AppCore Methods

#![allow(unused)]
fn main() {
impl AppCore {
    // Message processing
    pub fn process_server_message(&mut self, msg: &str) { ... }

    // User input
    pub fn handle_command(&mut self, cmd: &str) { ... }
    pub fn dispatch_keybind(&mut self, key: &KeyEvent) { ... }

    // State management
    pub fn get_window_state(&self, name: &str) -> Option<&WindowState> { ... }
    pub fn get_window_state_mut(&mut self, name: &str) -> Option<&mut WindowState> { ... }

    // Layout
    pub fn apply_layout(&mut self, layout: Layout) { ... }
}
}

MessageProcessor

Routes parsed XML to appropriate state:

#![allow(unused)]
fn main() {
impl MessageProcessor {
    pub fn process(
        &mut self,
        element: ParsedElement,
        game_state: &mut GameState,
        ui_state: &mut UiState,
    ) {
        match element {
            ParsedElement::Text { content, stream, .. } => {
                // Route to appropriate window's text_content
            }
            ParsedElement::ProgressBar { id, value, max, .. } => {
                // Update vitals or progress widget
            }
            ParsedElement::Compass { directions } => {
                // Update game_state.exits
            }
            // ... handle all element types
        }
    }
}
}

Frontend Layer

Location: src/frontend/

The frontend layer renders UI and captures input. It has no business logic.

Frontend Trait

#![allow(unused)]
fn main() {
pub trait Frontend {
    fn poll_events(&mut self) -> Result<Vec<FrontendEvent>>;
    fn render(&mut self, app: &mut dyn Any) -> Result<()>;
    fn cleanup(&mut self) -> Result<()>;
    fn size(&self) -> (u16, u16);
}
}

FrontendEvent

#![allow(unused)]
fn main() {
pub enum FrontendEvent {
    Key(KeyEvent),
    Mouse(MouseEvent),
    Resize(u16, u16),
    Paste(String),
    FocusGained,
    FocusLost,
}
}

TUI Frontend

The terminal UI implementation:

#![allow(unused)]
fn main() {
pub struct TuiFrontend {
    terminal: Terminal<CrosstermBackend<Stdout>>,
    widget_manager: WidgetManager,
}

impl Frontend for TuiFrontend {
    fn poll_events(&mut self) -> Result<Vec<FrontendEvent>> {
        // Convert crossterm events to FrontendEvent
    }

    fn render(&mut self, app: &mut dyn Any) -> Result<()> {
        // Sync data to widgets
        // Render all visible widgets
    }
}
}

Widget Manager

Caches and manages frontend widgets:

#![allow(unused)]
fn main() {
pub struct WidgetManager {
    pub text_windows: HashMap<String, TextWindow>,
    pub tabbed_text_windows: HashMap<String, TabbedTextWindow>,
    pub command_inputs: HashMap<String, CommandInput>,
    pub progress_bars: HashMap<String, ProgressBar>,
    pub countdowns: HashMap<String, Countdown>,
    pub compass_widgets: HashMap<String, Compass>,
    // ... more widget types

    // Generation tracking
    pub last_synced_generation: HashMap<String, u64>,
}
}

Layer Communication

Data Flow Pattern

Server Message
      ↓
[Network Layer] ──────→ Raw XML string
      ↓
[Parser]        ──────→ Vec<ParsedElement>
      ↓
[MessageProcessor] ───→ State updates
      ↓
[Data Layer]    ──────→ GameState, UiState, WindowState
      ↓
[Sync Functions] ─────→ Widget updates
      ↓
[Frontend]      ──────→ Rendered frame

User Input Flow

User Input
      ↓
[Frontend] ───────────→ FrontendEvent
      ↓
[Input Router] ───────→ Keybind match?
      ↓
[AppCore] ────────────→ Execute action
      ↓
Either:
  • UI Action (state change)
  • Game Command (send to server)

Strict Separation Benefits

1. Frontend Independence

Core and Data know nothing about rendering:

#![allow(unused)]
fn main() {
// In core/
// ❌ FORBIDDEN:
use ratatui::*;
use crossterm::*;

// ✅ ALLOWED:
use crate::data::*;
}

This enables adding a GUI frontend without modifying core logic.

2. Testability

Core logic can be tested without a terminal:

#![allow(unused)]
fn main() {
#[test]
fn test_message_processing() {
    let mut app = AppCore::new(test_config());

    // Simulate server message
    app.process_server_message("<pushBold/>A goblin<popBold/>");

    // Verify state without rendering
    let main_window = app.ui_state.windows.get("main").unwrap();
    assert!(main_window.text_content.as_ref().unwrap()
        .lines.back().unwrap().segments[0].bold);
}
}

3. Clear Boundaries

Each layer has explicit responsibilities:

QuestionAnswer
Where do I add a new data field?Data layer
Where do I process server XML?Core layer (parser/message processor)
Where do I render a new widget?Frontend layer
Where do I handle a keybind?Core layer (commands/keybinds)

4. Maintainability

Changes are localized:

  • Change rendering style → Frontend only
  • Change data format → Data + consumers
  • Change business logic → Core only
  • Add new widget type → All layers, but well-defined boundaries

State Hierarchy

AppCore
├── config: Config                    # Configuration (immutable)
│   ├── connection: ConnectionConfig
│   ├── ui: UiConfig
│   ├── highlights: HashMap<String, HighlightPattern>
│   ├── keybinds: HashMap<String, KeyBindAction>
│   └── colors: ColorConfig
│
├── layout: Layout                    # Current window layout
│   └── windows: Vec<WindowDef>
│
├── game_state: GameState             # Game session state
│   ├── connected: bool
│   ├── character_name: Option<String>
│   ├── room_id, room_name: Option<String>
│   ├── exits: Vec<String>
│   ├── vitals: Vitals
│   └── status: StatusInfo
│
├── ui_state: UiState                 # UI interaction state
│   ├── windows: HashMap<String, WindowState>
│   ├── focused_window: Option<String>
│   ├── input_mode: InputMode
│   └── popup_menu: Option<PopupMenu>
│
└── message_processor: MessageProcessor
    ├── current_stream: String
    └── squelch_matcher: Option<AhoCorasick>

Adding a New Feature

Example: Add “Stance” Display

1. Data Layer - Add data structure:

#![allow(unused)]
fn main() {
// src/data/game_state.rs
pub struct GameState {
    // ... existing fields
    pub stance: Option<Stance>,
}

pub struct Stance {
    pub name: String,
    pub offensive: u8,
    pub defensive: u8,
}
}

2. Core Layer - Handle the XML:

#![allow(unused)]
fn main() {
// src/core/messages.rs
ParsedElement::Stance { name, offensive, defensive } => {
    game_state.stance = Some(Stance { name, offensive, defensive });
}
}

3. Frontend Layer - Render the widget:

#![allow(unused)]
fn main() {
// src/frontend/tui/stance_widget.rs
pub struct StanceWidget {
    stance: Option<Stance>,
}

impl Widget for &StanceWidget {
    fn render(self, area: Rect, buf: &mut Buffer) {
        // Render stance display
    }
}
}

4. Sync Function - Connect data to widget:

#![allow(unused)]
fn main() {
// src/frontend/tui/sync.rs
pub fn sync_stance_widgets(
    game_state: &GameState,
    widget_manager: &mut WidgetManager,
) {
    if let Some(stance) = &game_state.stance {
        let widget = widget_manager.stance_widgets
            .entry("stance".to_string())
            .or_insert_with(StanceWidget::new);
        widget.update(stance);
    }
}
}

See Also

Message Flow

This document traces data flow through Two-Face from network to screen and from keyboard to game server.

Server → UI Flow

Complete Flow Diagram

┌──────────────┐    ┌──────────────┐    ┌──────────────┐
│   Network    │───▶│    Parser    │───▶│   Message    │
│   (TCP/TLS)  │    │   (XML)      │    │  Processor   │
└──────────────┘    └──────────────┘    └──────────────┘
                                               │
                    ┌──────────────────────────┼──────────────────────────┐
                    │                          ▼                          │
              ┌─────┴─────┐            ┌──────────────┐            ┌──────┴─────┐
              │ GameState │            │   UiState    │            │  Windows   │
              │  (vitals, │            │  (streams,   │            │  (text     │
              │   room)   │            │   focus)     │            │   content) │
              └───────────┘            └──────────────┘            └────────────┘
                                               │
                                               ▼
                                       ┌──────────────┐
                                       │   Frontend   │
                                       │   Render     │
                                       └──────────────┘

Step-by-Step Processing

1. Network Receives Data

#![allow(unused)]
fn main() {
ServerMessage::Text(raw_xml)
// Example: "<pushBold/>A goblin<popBold/> attacks you!"
}

The network task runs asynchronously, sending messages via channel.

2. Parser Extracts Elements

#![allow(unused)]
fn main() {
let elements: Vec<ParsedElement> = parser.parse(&raw_xml);

// Result:
// [
//   Text { content: "A goblin", bold: true, span_type: Monsterbold, ... },
//   Text { content: " attacks you!", bold: false, span_type: Normal, ... },
// ]
}

The parser maintains state (color stacks, current stream) across calls.

3. MessageProcessor Routes to State

#![allow(unused)]
fn main() {
for element in elements {
    message_processor.process(element, &mut game_state, &mut ui_state);
}

// Processing logic:
match element {
    ParsedElement::Text { content, stream, fg_color, bg_color, bold, span_type, link_data } => {
        // Find window for this stream
        if let Some(window) = ui_state.windows.get_mut(&stream) {
            if let Some(text_content) = &mut window.text_content {
                // Create styled line
                let line = StyledLine::new(segments);
                text_content.add_line(line);  // Increments generation
            }
        }
    }

    ParsedElement::ProgressBar { id, value, max, text } => {
        // Update vitals
        match id.as_str() {
            "health" => game_state.vitals.health = (value, max),
            "mana" => game_state.vitals.mana = (value, max),
            // ...
        }
    }

    ParsedElement::Compass { directions } => {
        game_state.exits = directions;
    }

    // ... handle 30+ element types
}
}

4. State Changes Detected

The sync system detects changes via generation counters:

#![allow(unused)]
fn main() {
// Each text_content tracks changes
pub struct TextContent {
    pub lines: VecDeque<StyledLine>,
    pub generation: u64,  // Increments on every add_line()
}
}

5. Frontend Syncs and Renders

#![allow(unused)]
fn main() {
// In render loop:
sync_text_windows(&app.ui_state, &mut widget_manager);
sync_progress_bars(&app.game_state, &mut widget_manager);
sync_compass_widgets(&app.game_state, &mut widget_manager);
// ... more sync functions

// Render all widgets
terminal.draw(|frame| {
    for (name, widget) in &widget_manager.text_windows {
        frame.render_widget(&*widget, widget.rect());
    }
    // ... render other widget types
})?;
}

User Input → Game Flow

Complete Flow Diagram

┌──────────────┐    ┌──────────────┐    ┌──────────────┐
│   Frontend   │───▶│   Keybind    │───▶│    Core      │
│   (events)   │    │   Dispatch   │    │   Commands   │
└──────────────┘    └──────────────┘    └──────────────┘
                                               │
                          ┌────────────────────┴────────────────────┐
                          ▼                                         ▼
                   ┌──────────────┐                          ┌──────────────┐
                   │  UI Action   │                          │ Game Command │
                   │ (open menu,  │                          │  (send to    │
                   │  scroll)     │                          │   server)    │
                   └──────────────┘                          └──────────────┘

Keybind Processing Layers

Keybinds are processed in priority order:

#![allow(unused)]
fn main() {
// Layer 1: Global keybinds (always active)
if let Some(action) = config.global_keybinds.match_key(&key) {
    return handle_global_action(action);
}

// Layer 2: Menu keybinds (in priority windows)
if has_priority_window(&ui_state.input_mode) {
    if let Some(action) = config.menu_keybinds.match_key(&key, context) {
        return handle_menu_action(action);
    }
}

// Layer 3: User keybinds (game mode)
if let Some(action) = config.keybinds.get(&key_string) {
    return handle_user_action(action);
}

// Layer 4: Default character input
command_input.insert_char(key.char);
}

Action Types

Action TypeExampleEffect
UI ActionOpen menu, scrollModifies UiState
Game CommandSend “look”Sent to server
MacroMulti-command sequenceMultiple server sends
InternalChange layoutConfig/layout change

Event Loop

Main Loop Structure

#![allow(unused)]
fn main() {
pub fn run(config: Config) -> Result<()> {
    // 1. Initialize
    let runtime = tokio::runtime::Builder::new_multi_thread().build()?;
    let mut app = AppCore::new(config)?;
    let mut frontend = TuiFrontend::new()?;

    // 2. Start network tasks
    let (server_tx, server_rx) = mpsc::unbounded_channel();
    let (command_tx, command_rx) = mpsc::unbounded_channel();
    runtime.spawn(network_task(server_tx, command_rx));

    // 3. Main event loop
    loop {
        // Poll for user input (non-blocking)
        let events = frontend.poll_events()?;

        for event in events {
            match event {
                FrontendEvent::Key(key) => {
                    if app.handle_key_event(key, &command_tx)? == ControlFlow::Break {
                        return Ok(());
                    }
                }
                FrontendEvent::Mouse(mouse) => {
                    app.handle_mouse_event(mouse)?;
                }
                FrontendEvent::Resize(w, h) => {
                    app.handle_resize(w, h)?;
                }
            }
        }

        // Process server messages (non-blocking drain)
        while let Ok(msg) = server_rx.try_recv() {
            match msg {
                ServerMessage::Text(text) => {
                    app.process_server_message(&text)?;
                }
                ServerMessage::Connected => { ... }
                ServerMessage::Disconnected => { ... }
            }
        }

        // Render frame
        frontend.render(&mut app)?;

        // Sleep to maintain frame rate (~60 FPS)
        std::thread::sleep(Duration::from_millis(16));
    }
}
}

Async Network Integration

Network I/O runs in separate Tokio tasks:

#![allow(unused)]
fn main() {
async fn reader_task(
    stream: TcpStream,
    server_tx: UnboundedSender<ServerMessage>,
) {
    let mut reader = BufReader::new(stream);
    let mut line = String::new();

    loop {
        line.clear();
        match reader.read_line(&mut line).await {
            Ok(0) => {
                server_tx.send(ServerMessage::Disconnected).ok();
                break;
            }
            Ok(_) => {
                server_tx.send(ServerMessage::Text(line.clone())).ok();
            }
            Err(_) => break,
        }
    }
}

async fn writer_task(
    mut stream: TcpStream,
    mut command_rx: UnboundedReceiver<String>,
) {
    while let Some(cmd) = command_rx.recv().await {
        if stream.write_all(cmd.as_bytes()).await.is_err() {
            break;
        }
    }
}
}

Detailed Scenarios

Scenario: Monster Attack Message

Server sends: "<pushBold/>A massive troll<popBold/> swings at you!"

1. Network Task
   └─▶ Receives bytes, sends ServerMessage::Text(...)

2. Main Loop
   └─▶ Receives from channel, calls process_server_message()

3. Parser
   └─▶ Parses XML tags:
       • <pushBold/> → Push bold state
       • "A massive troll" → Text with bold=true, span_type=Monsterbold
       • <popBold/> → Pop bold state
       • " swings at you!" → Text with bold=false
   └─▶ Returns Vec<ParsedElement>

4. MessageProcessor
   └─▶ For each Text element:
       • Applies highlight patterns
       • Routes to "main" stream
       • Adds to main window's TextContent
       • Increments generation counter

5. Sync Functions
   └─▶ Detects generation change
   └─▶ Copies new line to TextWindow widget

6. Frontend Render
   └─▶ Renders TextWindow with new content
   └─▶ Bold "A massive troll" in monsterbold color

Scenario: User Types Command

User types: "attack troll" and presses Enter

1. Frontend
   └─▶ Captures key events: 'a', 't', 't', ..., Enter
   └─▶ Sends FrontendEvent::Key for each

2. Input Router (for each key)
   └─▶ Not a keybind → Insert into command input

3. On Enter
   └─▶ Get command text: "attack troll"
   └─▶ Check for client command (starts with '.')
   └─▶ Not client command → Send to server
   └─▶ command_tx.send("attack troll\n")

4. Writer Task
   └─▶ Receives from channel
   └─▶ Writes to TCP stream

5. Meanwhile
   └─▶ Command echoed to main window
   └─▶ Server response arrives via reader task

Scenario: Progress Bar Update

Server sends: <progressBar id='health' value='75' text='health 375/500'/>

1. Parser
   └─▶ Creates ParsedElement::ProgressBar {
         id: "health",
         value: 375,
         max: 500,
         text: "health 375/500"
       }

2. MessageProcessor
   └─▶ Updates game_state.vitals.health = (375, 500)

3. Sync Functions
   └─▶ sync_progress_bars() reads vitals
   └─▶ Updates ProgressBar widget

4. Frontend
   └─▶ Renders bar at 75% filled

Scenario: Room Change

Server sends multiple elements for room transition:
1. <clearStream id='room'/>
2. <nav rm='12345'/>
3. <streamWindow id='room' subtitle='Town Square'/>
4. <component id='room desc'>A bustling town square...</component>
5. <compass><dir value='n'/><dir value='e'/><dir value='out'/></compass>

Processing:

1. ClearStream
   └─▶ Clears room window content

2. RoomId
   └─▶ game_state.room_id = Some("12345")

3. StreamWindow
   └─▶ game_state.room_name = Some("Town Square")
   └─▶ Updates window title

4. Component
   └─▶ Adds description to room window

5. Compass
   └─▶ game_state.exits = ["n", "e", "out"]
   └─▶ Compass widget updates

All synced and rendered in next frame.

Channel Communication

Channel Types

ChannelDirectionPurpose
server_txNetwork → MainServer messages
command_txMain → NetworkGame commands

Message Types

#![allow(unused)]
fn main() {
pub enum ServerMessage {
    Text(String),       // Server XML data
    Connected,          // Connection established
    Disconnected,       // Connection lost
    Error(String),      // Network error
}
}

Error Handling

Network Errors

#![allow(unused)]
fn main() {
// In reader task
match reader.read_line(&mut line).await {
    Ok(0) => {
        // EOF - server closed connection
        server_tx.send(ServerMessage::Disconnected).ok();
    }
    Err(e) => {
        // Read error
        server_tx.send(ServerMessage::Error(e.to_string())).ok();
    }
}
}

Parse Errors

Parse errors are logged but don’t crash:

#![allow(unused)]
fn main() {
// Invalid XML is skipped
if let Err(e) = parser.parse_chunk(&data) {
    tracing::warn!("Parse error: {}", e);
    // Continue processing
}
}

State Errors

Missing windows are handled gracefully:

#![allow(unused)]
fn main() {
// If window doesn't exist, message is silently dropped
if let Some(window) = ui_state.windows.get_mut(&stream) {
    // Process
}
}

See Also

Parser Protocol

Two-Face’s XML streaming parser converts GemStone IV/DragonRealms server data into strongly-typed Rust events.

Overview

The game server sends an XML-based protocol called “Wizard Front End” (WFE) or “Stormfront” protocol. The XmlParser maintains state across chunks and emits high-level ParsedElement values.

Parser Architecture

Server XML Stream
       ↓
┌──────────────────────┐
│     XmlParser        │
│  ──────────────────  │
│  • Color stacks      │
│  • Stream tracking   │
│  • Link metadata     │
│  • Event matchers    │
└──────────────────────┘
       ↓
Vec<ParsedElement>
       ↓
AppCore (routes to widgets)

ParsedElement Variants

The parser emits these typed events:

Text Content

#![allow(unused)]
fn main() {
ParsedElement::Text {
    content: String,           // Decoded text content
    stream: String,            // Target stream: "main", "speech", etc.
    fg_color: Option<String>,  // Foreground color "#RRGGBB"
    bg_color: Option<String>,  // Background color
    bold: bool,                // Bold styling
    span_type: SpanType,       // Semantic type
    link_data: Option<LinkData>, // Clickable link metadata
}
}

SpanType Enum

SpanTypeSourcePurpose
NormalPlain textDefault text
Link<a> or <d> tagsClickable game objects
Monsterbold<pushBold/>Monster/creature names
Spell<spell> tagsSpell names
Speech<preset id="speech">Player dialogue

Game State Updates

#![allow(unused)]
fn main() {
// Prompt (command input ready)
ParsedElement::Prompt {
    time: String,    // Unix timestamp
    text: String,    // Prompt character (e.g., ">")
}

// Roundtime countdown
ParsedElement::RoundTime { value: u32 }   // Seconds

// Cast time countdown
ParsedElement::CastTime { value: u32 }    // Seconds

// Hand contents
ParsedElement::LeftHand { item: String, link: Option<LinkData> }
ParsedElement::RightHand { item: String, link: Option<LinkData> }
ParsedElement::SpellHand { spell: String }

// Progress bars (health, mana, etc.)
ParsedElement::ProgressBar {
    id: String,      // "health", "mana", "spirit", "stamina"
    value: u32,      // Current value
    max: u32,        // Maximum value
    text: String,    // Display text like "mana 407/407"
}

// Compass directions
ParsedElement::Compass {
    directions: Vec<String>,  // ["n", "e", "out", "up"]
}
}

Stream Control

#![allow(unused)]
fn main() {
// Push new stream context
ParsedElement::StreamPush { id: String }

// Pop stream (return to main)
ParsedElement::StreamPop

// Clear stream contents
ParsedElement::ClearStream { id: String }

// Stream window metadata
ParsedElement::StreamWindow {
    id: String,
    subtitle: Option<String>,  // Room name for "room" stream
}
}

Room Information

#![allow(unused)]
fn main() {
// Room ID for mapping
ParsedElement::RoomId { id: String }

// Room component (name, description, objects, players)
ParsedElement::Component {
    id: String,      // "room name", "room desc", "room objs", "room players"
    value: String,   // Component content
}
}

Combat/Status

#![allow(unused)]
fn main() {
// Injury display
ParsedElement::InjuryImage {
    id: String,     // Body part: "head", "leftArm", "chest"
    name: String,   // Level: "Injury1"-"Injury3", "Scar1"-"Scar3"
}

// Status indicators
ParsedElement::StatusIndicator {
    id: String,     // "poisoned", "diseased", "bleeding", "stunned", "hidden"
    active: bool,   // true = active, false = cleared
}

// Active effects (spells, buffs, debuffs)
ParsedElement::ActiveEffect {
    category: String,  // "ActiveSpells", "Buffs", "Debuffs", "Cooldowns"
    id: String,
    value: u32,        // Progress percentage
    text: String,      // Effect name
    time: String,      // "HH:MM:SS"
}

ParsedElement::ClearActiveEffects { category: String }
}

Interactive Elements

#![allow(unused)]
fn main() {
// Menu response (from INV SEARCH, etc.)
ParsedElement::MenuResponse {
    id: String,                            // Correlation ID
    coords: Vec<(String, Option<String>)>, // (coord, optional noun) pairs
}

// Pattern-matched events (stun, webbed, etc.)
ParsedElement::Event {
    event_type: String,    // "stun", "webbed", "prone"
    action: EventAction,   // Set, Clear, or Increment
    duration: u32,         // Seconds
}

// External URL launch
ParsedElement::LaunchURL { url: String }
}

Clickable text carries metadata for game interaction:

#![allow(unused)]
fn main() {
pub struct LinkData {
    pub exist_id: String,        // Object ID or "_direct_"
    pub noun: String,            // Object noun or command
    pub text: String,            // Display text
    pub coord: Option<String>,   // Optional coord for direct commands
}
}
<a exist="12345" noun="sword">a rusty sword</a>
  • exist_id: “12345” (server object ID)
  • noun: “sword” (for commands like “get sword”)
  • Clicking sends: _INSPECT 12345 or contextual command

DragonRealms Direct Commands (<d> tags)

<d cmd='get #8735861 in #8735860'>Some item</d>
  • exist_id: “direct” (marker for direct command)
  • noun: “get #8735861 in #8735860” (full command)
  • Clicking sends the command directly

Stream System

The server uses streams to route content to different windows:

StreamPurposeWidget Type
mainPrimary game outputMain text window
speechPlayer dialogueSpeech tab/window
thoughtsESP/telepathyThoughts tab/window
invInventory listingsInventory window
roomRoom descriptionsRoom info widget
assessCombat assessmentAssessment window
experienceSkill/XP infoExperience window
percWindowPerception checksPerception window
deathDeath messagingDeath/recovery window
logonsLogin/logout noticesArrivals window
familiarFamiliar messagesFamiliar window
groupGroup informationGroup window

Stream Lifecycle

<!-- Server pushes to speech stream -->
<pushStream id='speech'/>
Soandso says, "Hello there!"
<popStream/>

<!-- Text returns to main stream -->
You nod to Soandso.

The parser tracks current_stream and emits StreamPush/StreamPop events.

Color Stack System

The parser maintains multiple stacks for nested styling:

#![allow(unused)]
fn main() {
pub struct XmlParser {
    color_stack: Vec<ColorStyle>,   // <color> tags
    preset_stack: Vec<ColorStyle>,  // <preset> tags
    style_stack: Vec<ColorStyle>,   // <style> tags
    bold_stack: Vec<bool>,          // <pushBold>/<popBold>
}
}

Priority (highest to lowest)

  1. color_stack - Explicit <color fg="..." bg="..."> tags
  2. preset_stack - Named presets like <preset id="speech">
  3. style_stack - Style IDs like <style id="roomName">

XML Color Tags

<!-- Explicit color -->
<color fg='#FF0000' bg='#000000'>Red on black</color>

<!-- Named preset (from colors.toml) -->
<preset id='speech'>Someone says, "Hello"</preset>

<!-- Bold/monsterbold -->
<pushBold/>A goblin<popBold/> attacks you!

<!-- Style (from colors.toml) -->
<style id='roomName'>Town Square</style>

Event Pattern Matching

The parser checks text against configurable patterns for game events:

#![allow(unused)]
fn main() {
pub struct EventPattern {
    pub pattern: String,           // Regex pattern
    pub event_type: String,        // "stun", "webbed", "prone"
    pub action: EventAction,       // Set, Clear, Increment
    pub duration: u32,             // Fixed duration (seconds)
    pub duration_capture: Option<usize>,  // Capture group for duration
    pub duration_multiplier: f32,  // Convert rounds to seconds
    pub enabled: bool,
}
}

Example: Stun Detection

[event_patterns.stun_start]
pattern = 'You are stunned!'
event_type = "stun"
action = "set"
duration = 5

[event_patterns.stun_with_rounds]
pattern = 'stunned for (\d+) rounds'
event_type = "stun"
action = "set"
duration_capture = 1
duration_multiplier = 5.0  # Rounds to seconds

[event_patterns.stun_end]
pattern = 'You are no longer stunned'
event_type = "stun"
action = "clear"

XML Entity Decoding

The parser decodes standard XML entities:

EntityCharacter
&lt;<
&gt;>
&amp;&
&quot;"
&apos;'

Progress Bar Parsing

Progress bars contain both percentage and actual values:

<progressBar id='health' value='85' text='health 425/500' />

The parser extracts:

  • id: “health”
  • value: 425 (parsed from text, not the percentage)
  • max: 500 (parsed from text)
  • text: “health 425/500”

Special Cases

  • mindState: Text like “clear as a bell” (no numeric extraction)
  • encumlevel: Encumbrance percentage
  • lblBPs: Blood Points (Betrayer profession)

Complete XML Tag Reference

Handled Tags

TagPurposeParsedElement
<prompt>Command readyPrompt
<roundTime>RT countdownRoundTime
<castTime>Cast countdownCastTime
<left>Left hand itemLeftHand
<right>Right hand itemRightHand
<spell>Prepared spellSpellHand
<compass>Available exitsCompass
<progressBar>Stat barsProgressBar
<indicator>Status iconsStatusIndicator
<dialogData>Dialog updatesVarious
<component>Room componentsComponent
<pushStream>Stream switchStreamPush
<popStream>Stream returnStreamPop
<clearStream>Clear windowClearStream
<streamWindow>Window metadataStreamWindow
<nav>Room IDRoomId
<a>Object linkText with LinkData
<d>Direct commandText with LinkData
<menu>Menu containerMenuResponse
<mi>Menu itemPart of MenuResponse
<preset>Named colorColor applied to Text
<color>Explicit colorColor applied to Text
<style>Style IDColor applied to Text
<pushBold>Start boldBold applied to Text
<popBold>End boldBold removed
<LaunchURL>External URLLaunchURL

Ignored Tags

These tags are silently ignored:

  • <dropDownBox>
  • <skin>
  • <clearContainer>
  • <container>
  • <exposeContainer>
  • <inv> content (between open/close)

Usage Example

#![allow(unused)]
fn main() {
use two_face::parser::{XmlParser, ParsedElement};

// Create parser with presets and event patterns
let parser = XmlParser::with_presets(preset_list, event_patterns);

// Parse a line from the server
let elements = parser.parse_line("<pushBold/>A goblin<popBold/> attacks!");

// Route elements to appropriate widgets
for element in elements {
    match element {
        ParsedElement::Text { content, stream, bold, .. } => {
            // Route to text widget for `stream`
        }
        ParsedElement::RoundTime { value } => {
            // Update RT countdown widget
        }
        ParsedElement::Compass { directions } => {
            // Update compass widget
        }
        // ... handle other variants
    }
}
}

See Also

Widget Synchronization

Two-Face uses generation-based change detection to efficiently sync data to frontend widgets without full comparisons.

Overview

The sync system:

  • Detects changes via generation counters (not content comparison)
  • Syncs only changed data
  • Handles incremental updates
  • Supports full resyncs when needed

Generation-Based Change Detection

TextContent Generation

Every TextContent has a generation counter:

#![allow(unused)]
fn main() {
pub struct TextContent {
    pub lines: VecDeque<StyledLine>,
    pub scroll_offset: usize,
    pub max_lines: usize,
    pub title: String,
    pub generation: u64,  // Increments on every add_line()
}

impl TextContent {
    pub fn add_line(&mut self, line: StyledLine) {
        self.lines.push_back(line);

        // Trim oldest lines when over limit
        while self.lines.len() > self.max_lines {
            self.lines.pop_front();
        }

        self.generation += 1;  // Always increment
    }
}
}

Sync Logic

#![allow(unused)]
fn main() {
// Get last synced generation for this window
let last_synced_gen = last_synced_generation.get(name).copied().unwrap_or(0);
let current_gen = text_content.generation;

// Only sync if generation changed
if current_gen > last_synced_gen {
    let gen_delta = (current_gen - last_synced_gen) as usize;

    // If delta > line count, need full resync (wrapped around or cleared)
    let needs_full_resync = gen_delta > text_content.lines.len();

    if needs_full_resync {
        text_window.clear();
    }

    // Add only new lines
    let lines_to_add = if needs_full_resync {
        text_content.lines.len()
    } else {
        gen_delta.min(text_content.lines.len())
    };

    let skip_count = text_content.lines.len().saturating_sub(lines_to_add);
    for line in text_content.lines.iter().skip(skip_count) {
        text_window.add_line(line.clone());
    }

    // Update synced generation
    last_synced_generation.insert(name.clone(), current_gen);
}
}

Benefits

  1. O(1) change detection - Compare numbers, not content
  2. Incremental updates - Only new lines synced
  3. Automatic full resync - Detects when buffer cleared
  4. Multi-window support - Each window tracked independently

Sync Architecture

WidgetManager

The WidgetManager caches all frontend widgets and tracks sync state:

#![allow(unused)]
fn main() {
pub struct WidgetManager {
    // Widget caches
    pub text_windows: HashMap<String, TextWindow>,
    pub tabbed_text_windows: HashMap<String, TabbedTextWindow>,
    pub command_inputs: HashMap<String, CommandInput>,
    pub progress_bars: HashMap<String, ProgressBar>,
    pub countdowns: HashMap<String, Countdown>,
    pub compass_widgets: HashMap<String, Compass>,
    pub hand_widgets: HashMap<String, Hand>,
    pub indicator_widgets: HashMap<String, StatusIndicator>,
    pub injury_doll_widgets: HashMap<String, InjuryDoll>,
    pub active_effects_widgets: HashMap<String, ActiveEffects>,
    pub room_windows: HashMap<String, RoomWindow>,
    pub dashboard_widgets: HashMap<String, Dashboard>,
    pub inventory_windows: HashMap<String, Inventory>,
    pub spell_windows: HashMap<String, SpellList>,
    pub performance_widgets: HashMap<String, PerformanceWidget>,

    // Generation tracking for incremental sync
    pub last_synced_generation: HashMap<String, u64>,
}
}

Sync Functions

Each widget type has a dedicated sync function:

FunctionWidget TypeData Source
sync_text_windows()Text windowsWindowState.text_content
sync_tabbed_text_windows()Tabbed textWindowState.text_content (per tab)
sync_command_inputs()Command inputUiState.command_input
sync_progress_bars()Progress barsGameState.vitals
sync_countdowns()Countdown timersGameState.roundtime
sync_compass_widgets()CompassGameState.exits
sync_hand_widgets()Hand displayGameState.left_hand/right_hand
sync_indicator_widgets()Status indicatorsGameState.status
sync_injury_doll_widgets()Injury displayGameState.injuries
sync_active_effects()Buffs/debuffsGameState.active_effects
sync_room_windows()Room descriptionGameState.room_*
sync_dashboard_widgets()DashboardMultiple sources
sync_inventory_windows()InventoryGameState.inventory
sync_spells_windows()SpellsGameState.spells
sync_performance_widgets()PerformancePerformanceStats

Sync Pattern

Each sync function follows this pattern:

#![allow(unused)]
fn main() {
pub fn sync_text_windows(
    ui_state: &UiState,
    layout: &Layout,
    widget_manager: &mut WidgetManager,
    theme: &Theme,
) {
    // 1. Find windows of this type in layout
    for window_def in layout.windows.iter().filter(|w| w.widget_type == WidgetType::Text) {
        let name = &window_def.name;

        // 2. Get data from state
        let window_state = match ui_state.windows.get(name) {
            Some(ws) => ws,
            None => continue,
        };

        let text_content = match &window_state.text_content {
            Some(tc) => tc,
            None => continue,
        };

        // 3. Ensure widget exists in cache
        let text_window = widget_manager.text_windows
            .entry(name.clone())
            .or_insert_with(|| TextWindow::new(name.clone()));

        // 4. Apply configuration
        text_window.set_width(window_def.width);
        text_window.set_colors(theme.resolve_window_colors(window_def));

        // 5. Check generation for changes
        let last_gen = widget_manager.last_synced_generation
            .get(name).copied().unwrap_or(0);
        let current_gen = text_content.generation;

        if current_gen <= last_gen {
            continue;  // No changes
        }

        // 6. Sync content
        let delta = (current_gen - last_gen) as usize;
        let needs_full_resync = delta > text_content.lines.len();

        if needs_full_resync {
            text_window.clear();
        }

        let lines_to_add = if needs_full_resync {
            text_content.lines.len()
        } else {
            delta.min(text_content.lines.len())
        };

        let skip = text_content.lines.len().saturating_sub(lines_to_add);
        for line in text_content.lines.iter().skip(skip) {
            text_window.add_line(line.clone());
        }

        // 7. Update generation
        widget_manager.last_synced_generation.insert(name.clone(), current_gen);
    }
}
}

Special Cases

Tabbed Text Windows

Tabbed windows track generation per tab:

#![allow(unused)]
fn main() {
pub struct TabbedTextSyncState {
    pub tab_generations: HashMap<String, u64>,  // Per-tab tracking
}

// In sync:
for tab in &tabbed_window.tabs {
    let tab_gen = sync_state.tab_generations.get(&tab.name).copied().unwrap_or(0);
    let current_gen = tab.text_content.generation;

    if current_gen > tab_gen {
        // Sync this tab
    }
}
}

Progress Bars

Progress bars don’t use generations - they directly copy values:

#![allow(unused)]
fn main() {
pub fn sync_progress_bars(
    game_state: &GameState,
    widget_manager: &mut WidgetManager,
) {
    // Direct sync - values are small, comparison is cheap
    if let Some(widget) = widget_manager.progress_bars.get_mut("health") {
        widget.set_value(game_state.vitals.health.0);
        widget.set_max(game_state.vitals.health.1);
    }
}
}

Countdowns

Countdowns track their own state and update per-frame:

#![allow(unused)]
fn main() {
pub fn sync_countdowns(
    game_state: &GameState,
    widget_manager: &mut WidgetManager,
) {
    if let Some(widget) = widget_manager.countdowns.get_mut("roundtime") {
        let remaining = game_state.roundtime_end
            .map(|end| end.saturating_duration_since(Instant::now()))
            .unwrap_or(Duration::ZERO);
        widget.set_remaining(remaining);
    }
}
}

Performance Optimizations

Width-Based Invalidation

Text windows invalidate wrap cache only when width changes:

#![allow(unused)]
fn main() {
impl TextWindow {
    pub fn set_width(&mut self, width: u16) {
        if self.current_width != width {
            self.current_width = width;
            self.invalidate_wrap_cache();
        }
    }
}
}

Lazy Line Wrapping

Lines are wrapped on-demand during render, not during sync:

#![allow(unused)]
fn main() {
fn render_visible_lines(&self, visible_height: usize) -> Vec<WrappedLine> {
    // Only wrap lines that will actually be displayed
    let visible_range = self.calculate_visible_range(visible_height);
    self.lines
        .iter()
        .skip(visible_range.start)
        .take(visible_range.len())
        .flat_map(|line| self.wrap_line(line))
        .collect()
}
}

Dirty Tracking

Widgets track whether they need re-rendering:

#![allow(unused)]
fn main() {
impl TextWindow {
    pub fn is_dirty(&self) -> bool {
        self.dirty
    }

    pub fn mark_clean(&mut self) {
        self.dirty = false;
    }

    fn add_line(&mut self, line: StyledLine) {
        // ...
        self.dirty = true;
    }
}
}

Sync Timing

When Sync Happens

Sync occurs every frame in the render loop:

#![allow(unused)]
fn main() {
// In TuiFrontend::render()
fn render(&mut self, app: &mut AppCore) -> Result<()> {
    // Sync all widget types
    sync_text_windows(&app.ui_state, &app.layout, &mut self.widget_manager, &app.theme);
    sync_tabbed_text_windows(&app.ui_state, &app.layout, &mut self.widget_manager, &app.theme);
    sync_progress_bars(&app.game_state, &mut self.widget_manager);
    sync_countdowns(&app.game_state, &mut self.widget_manager);
    sync_compass_widgets(&app.game_state, &mut self.widget_manager);
    // ... more sync functions

    // Then render
    self.terminal.draw(|frame| {
        // Render all widgets
    })?;

    Ok(())
}
}

Sync Frequency

  • ~60 times per second - Once per frame
  • Generation checks are O(1) - Very fast
  • Only changed widgets sync content - Efficient

Debugging Sync Issues

Check Generation

#![allow(unused)]
fn main() {
// Debug: Print generation info
tracing::debug!(
    "Window '{}': gen={}, last_synced={}, lines={}",
    name,
    text_content.generation,
    last_synced_gen,
    text_content.lines.len()
);
}

Check Widget Cache

#![allow(unused)]
fn main() {
// Debug: Check if widget exists
if widget_manager.text_windows.contains_key(name) {
    tracing::debug!("Widget '{}' exists in cache", name);
} else {
    tracing::warn!("Widget '{}' NOT in cache", name);
}
}

Common Issues

SymptomPossible CauseSolution
Content not appearingWidget not in layoutAdd to layout.toml
Content appears lateHigh generation deltaCheck buffer_size
Content duplicatedGeneration not trackingCheck add_line increments
Old content showingFull resync neededClear and resync

See Also

Theme System

Two-Face uses a layered color system for flexible theming and per-character customization.

Overview

The theme system provides:

  1. Presets - Named color schemes for game text (speech, monsterbold, etc.)
  2. Prompt Colors - Character-based prompt coloring (R, S, H, >)
  3. Spell Colors - Spell-specific bar colors in active effects
  4. UI Colors - Border, text, background colors for the interface
  5. Color Palette - Named colors for highlights and quick access

Configuration Files

~/.two-face/
├── colors.toml              # Global colors
└── characters/
    └── CharName/
        └── colors.toml      # Per-character overrides

Loading Order

  1. Load character-specific colors.toml if it exists
  2. Fall back to global ~/.two-face/colors.toml
  3. Fall back to embedded defaults

Presets

Presets define colors for semantic text types from the game server.

Default Presets

[presets.links]
fg = "#477ab3"

[presets.commands]
fg = "#477ab3"

[presets.speech]
fg = "#53a684"

[presets.roomName]
fg = "#9BA2B2"
bg = "#395573"

[presets.monsterbold]
fg = "#a29900"

[presets.familiar]
fg = "#767339"

[presets.thought]
fg = "#FF8080"

[presets.whisper]
fg = "#60b4bf"

Preset Structure

[presets.my_preset]
fg = "#RRGGBB"     # Foreground color (hex)
bg = "#RRGGBB"     # Background color (optional)

How Presets Are Applied

The parser applies presets when encountering specific XML tags:

PresetTriggered ByExample
speech<preset id="speech">Player dialogue
monsterbold<pushBold/>Monster names
roomName<style id="roomName">Room titles
links<a> tagsClickable objects
commands<d> tagsDirect commands
whisper<preset id="whisper">Whispers
thought<preset id="thought">ESP/thoughts
familiar<preset id="familiar">Familiar messages

Prompt Colors

Prompt colors apply to individual characters in the game prompt.

Configuration

[[prompt_colors]]
character = "R"
color = "#ff0000"

[[prompt_colors]]
character = "S"
color = "#ffff00"

[[prompt_colors]]
character = "H"
color = "#9370db"

[[prompt_colors]]
character = ">"
color = "#a9a9a9"

Common Prompt Characters

CharacterMeaningSuggested Color
RRoundtimeRed
SSpell roundtimeYellow
HHiddenPurple
>ReadyGray
KKneelingCustom
PProneCustom

UI Colors

UI colors control the application interface appearance.

Configuration

[ui]
command_echo_color = "#ffffff"      # Echo of typed commands
border_color = "#00ffff"            # Unfocused widget borders
focused_border_color = "#ffff00"    # Focused widget borders
text_color = "#ffffff"              # Default text color
background_color = "#000000"        # Widget background
selection_bg_color = "#4a4a4a"      # Text selection background
textarea_background = "-"           # Input field background ("-" = transparent)

Transparent Background

Set textarea_background = "-" to use a transparent background for the command input area.

Spell Colors

Spell colors assign colors to specific spell numbers in the Active Spells widget.

Configuration

[[spell_colors]]
spells = [503, 506, 507, 508, 509]    # Spell numbers
color = "#5c0000"                      # Bar color
bar_color = "#5c0000"                  # Same as color
text_color = "#909090"                 # Text on the bar
bg_color = "#000000"                   # Background behind bar

Spell Circle Examples

# Minor Spirit (500s) - Dark Red
[[spell_colors]]
spells = [503, 506, 507, 508, 509, 513, 515, 520, 525, 535, 540]
color = "#5c0000"

# Major Spirit (900s) - Purple
[[spell_colors]]
spells = [905, 911, 913, 918, 919, 920, 925, 930, 940]
color = "#9370db"

# Ranger (600s) - Green
[[spell_colors]]
spells = [601, 602, 604, 605, 606, 608, 612, 613, 617, 618, 620, 625, 640, 650]
color = "#1c731c"

# Sorcerer (700s) - Indigo
[[spell_colors]]
spells = [701, 703, 705, 708, 712, 713, 715, 720, 725, 730, 735, 740]
color = "#4b0082"

Color Palette

The color palette provides named colors for highlights and quick access.

Structure

[[color_palette]]
name = "red"
color = "#FF0000"
category = "red"

[[color_palette]]
name = "crimson"
color = "#DC143C"
category = "red"

[[color_palette]]
name = "forestgreen"
color = "#228B22"
category = "green"

Default Categories

  • red - Red, crimson, darkred, firebrick, indianred
  • orange - Orange, darkorange, coral, tomato
  • yellow - Yellow, gold, khaki, lightyellow
  • green - Green, lime, darkgreen, forestgreen, seagreen
  • cyan - Cyan, aqua, darkcyan, teal, turquoise
  • blue - Blue, darkblue, navy, royalblue, steelblue
  • purple - Purple, darkviolet, darkorchid, indigo, violet
  • magenta - Magenta, fuchsia, deeppink, hotpink, pink
  • brown - Brown, saddlebrown, sienna, chocolate, tan
  • gray - White, black, gray, darkgray, lightgray, silver

Using Palette Colors

In highlights, reference palette colors by name:

[death_blow]
pattern = "death blow"
fg = "crimson"          # Resolved to #DC143C
bg = "darkred"          # Resolved to #8B0000

Color Abstraction Layer

Two-Face provides a frontend-agnostic color system.

Color Struct

#![allow(unused)]
fn main() {
pub struct Color {
    pub r: u8,
    pub g: u8,
    pub b: u8,
}

impl Color {
    pub const fn rgb(r: u8, g: u8, b: u8) -> Self;
    pub fn from_hex(hex: &str) -> Option<Self>;
    pub fn to_hex(&self) -> String;

    // Constants
    pub const BLACK: Self = Self::rgb(0, 0, 0);
    pub const WHITE: Self = Self::rgb(255, 255, 255);
    pub const RED: Self = Self::rgb(255, 0, 0);
    // ... more constants
}
}

NamedColor Enum

#![allow(unused)]
fn main() {
pub enum NamedColor {
    // Standard ANSI colors
    Black, Red, Green, Yellow, Blue, Magenta, Cyan,
    Gray, DarkGray, LightRed, LightGreen, LightYellow,
    LightBlue, LightMagenta, LightCyan, White,

    // RGB color
    Rgb(u8, u8, u8),

    // ANSI 256-color palette (0-255)
    Indexed(u8),

    // Reset to default
    Reset,
}
}

ANSI 256-Color Support

RangeDescription
0-15Standard 16 ANSI colors
16-2316×6×6 color cube (216 colors)
232-255Grayscale ramp (24 grays)

Color Resolution

When resolving a color reference:

#![allow(unused)]
fn main() {
pub fn resolve_palette_color(&self, input: &str) -> String {
    // If starts with #, use as-is
    if input.starts_with('#') {
        return input.to_string();
    }

    // Look up in palette (case-insensitive)
    for entry in &self.color_palette {
        if entry.name.eq_ignore_ascii_case(input) {
            return entry.color.clone();
        }
    }

    // Return original if not found
    input.to_string()
}
}

Hot-Switching

Reload colors without restarting:

.reload colors

This:

  1. Reloads colors.toml from disk
  2. Updates parser presets
  3. Refreshes all widgets
  4. Applies immediately to new text

Creating Custom Themes

Step 1: Copy Defaults

cp ~/.two-face/colors.toml ~/.two-face/colors-backup.toml

Step 2: Edit colors.toml

# Dark theme with blue accents
[presets.speech]
fg = "#6eb5ff"

[presets.monsterbold]
fg = "#ffd700"

[presets.links]
fg = "#87ceeb"

[ui]
border_color = "#2196f3"
focused_border_color = "#64b5f6"
text_color = "#e0e0e0"
background_color = "#121212"

Step 3: Reload

.reload colors

Theme Examples

High Contrast (Accessibility)

[ui]
text_color = "#ffffff"
background_color = "#000000"
focused_border_color = "#ffff00"

[presets.monsterbold]
fg = "#ff0000"

[presets.speech]
fg = "#00ff00"

Solarized Dark

[ui]
background_color = "#002b36"
text_color = "#839496"
border_color = "#073642"
focused_border_color = "#2aa198"

[presets.speech]
fg = "#859900"

[presets.monsterbold]
fg = "#cb4b16"

Nord Theme

[ui]
background_color = "#2e3440"
text_color = "#d8dee9"
border_color = "#3b4252"
focused_border_color = "#88c0d0"

[presets.speech]
fg = "#a3be8c"

[presets.monsterbold]
fg = "#d08770"

Color Format Reference

FormatExampleNotes
#RRGGBB#FF5733Standard hex
#rrggbb#ff5733Lowercase also valid
NamedcrimsonFrom color_palette
Empty""Use default/transparent
Dash"-"Transparent (textarea only)

Browser Commands

CommandDescription
.colorsBrowse color palette
.spellcolorsBrowse/edit spell colors
.addspellcolor <spell> <color>Add spell color
.reload colorsHot-reload colors.toml

See Also

Browser & Editor System

Two-Face provides interactive popup interfaces for browsing and editing configuration without leaving the application.

Overview

The browser/editor system includes:

  • Browsers - Read-only lists with selection (highlights, keybinds, colors)
  • Editors - Forms for creating/modifying configuration
  • Window Editor - Complex form for widget configuration

Architecture

┌─────────────────────────────────────────────────────────────────┐
│                        User Interface                           │
├─────────────────────────────────────────────────────────────────┤
│  ┌──────────────┐  ┌──────────────┐  ┌───────────────────────┐ │
│  │   Browser    │  │    Editor    │  │    WindowEditor       │ │
│  │  (List View) │  │  (Form View) │  │  (Complex Form)       │ │
│  └──────────────┘  └──────────────┘  └───────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│                        Widget Traits                            │
│  ┌───────────┐ ┌───────────┐ ┌──────────┐ ┌────────────────┐   │
│  │ Navigable │ │ Selectable│ │ Saveable │ │ TextEditable   │   │
│  └───────────┘ └───────────┘ └──────────┘ └────────────────┘   │
└─────────────────────────────────────────────────────────────────┘

Widget Traits

All browsers and editors implement behavior traits.

For list navigation:

#![allow(unused)]
fn main() {
pub trait Navigable {
    fn navigate_up(&mut self);      // Move selection up
    fn navigate_down(&mut self);    // Move selection down
    fn page_up(&mut self);          // Move up ~10 items
    fn page_down(&mut self);        // Move down ~10 items
    fn home(&mut self) {}           // Move to first (optional)
    fn end(&mut self) {}            // Move to last (optional)
}
}

Selectable

For selecting items:

#![allow(unused)]
fn main() {
pub trait Selectable {
    fn get_selected(&self) -> Option<String>;
    fn delete_selected(&mut self) -> Option<String>;
}
}

TextEditable

For text input fields:

#![allow(unused)]
fn main() {
pub trait TextEditable {
    fn get_focused_field(&self) -> Option<&TextArea<'static>>;
    fn get_focused_field_mut(&mut self) -> Option<&mut TextArea<'static>>;
    fn select_all(&mut self);           // Ctrl+A
    fn copy_to_clipboard(&self);        // Ctrl+C
    fn cut_to_clipboard(&mut self);     // Ctrl+X
    fn paste_from_clipboard(&mut self); // Ctrl+V
}
}

FieldNavigable

For form field navigation:

#![allow(unused)]
fn main() {
pub trait FieldNavigable {
    fn next_field(&mut self);       // Tab
    fn previous_field(&mut self);   // Shift+Tab
    fn field_count(&self) -> usize;
    fn current_field(&self) -> usize;
}
}

Saveable

For forms that persist data:

#![allow(unused)]
fn main() {
pub trait Saveable {
    type SaveResult;
    fn try_save(&mut self) -> Option<Self::SaveResult>;
    fn is_modified(&self) -> bool;
}
}

Toggleable / Cyclable

For boolean and enum fields:

#![allow(unused)]
fn main() {
pub trait Toggleable {
    fn toggle_focused(&mut self) -> Option<bool>;
}

pub trait Cyclable {
    fn cycle_forward(&mut self);   // Space/Down
    fn cycle_backward(&mut self);  // Up
}
}

Browser Types

HighlightBrowser

Browse configured text highlights.

Command: .highlights

Features:

  • Sorted by category, then name
  • Color preview with sample text
  • Category filtering
  • Shows squelch status
  • Shows sound indicator
  • Shows redirect info
┌─ Highlights ────────────────────────────────────────┐
│                                                     │
│ ═══ Combat ═══                                      │
│ > critical_hit    "critical hit"   [█████]          │
│   damage_taken    "strikes you"    [█████]          │
│   damage_dealt    "You hit"        [█████]          │
│                                                     │
│ ═══ Chat ═══                                        │
│   speech          "^\\w+ says"     [█████]          │
│   whisper         "whispers"       [█████] ♪        │
│                                                     │
│ [Enter] Edit  [Delete] Remove  [Escape] Close       │
└─────────────────────────────────────────────────────┘

KeybindBrowser

Browse configured keybinds.

Command: .keybinds

Features:

  • Grouped by type (Actions, Macros)
  • Shows key combo and action
  • Columnar layout
┌─ Keybinds ──────────────────────────────────────────┐
│                                                     │
│ ═══ Actions ═══                                     │
│ > Ctrl+L         layout_editor                      │
│   Ctrl+H         highlight_browser                  │
│   F1             help                               │
│                                                     │
│ ═══ Macros ═══                                      │
│   Ctrl+1         "attack target"                    │
│   Ctrl+2         "stance defensive"                 │
│                                                     │
│ [Enter] Edit  [Delete] Remove  [Escape] Close       │
└─────────────────────────────────────────────────────┘

ColorPaletteBrowser

Browse named color palette.

Command: .colors

Features:

  • Grouped by color category
  • Visual color swatches
  • Hex code display
┌─ Color Palette ─────────────────────────────────────┐
│                                                     │
│ ═══ Red ═══                                         │
│ > ██ red         #FF0000                            │
│   ██ crimson     #DC143C                            │
│   ██ darkred     #8B0000                            │
│                                                     │
│ ═══ Green ═══                                       │
│   ██ green       #00FF00                            │
│   ██ forestgreen #228B22                            │
│                                                     │
│ [Escape] Close                                      │
└─────────────────────────────────────────────────────┘

SpellColorBrowser

Browse spell-specific colors.

Command: .spellcolors

Features:

  • Spell number to color mapping
  • Color preview bars
  • Add/edit/delete support

ThemeBrowser

Browse available themes.

Command: .themes

Features:

  • List saved theme profiles
  • Preview theme colors
  • Apply/save themes

Window Editor

The WindowEditor provides comprehensive widget configuration.

Command: .window <widget_name>

Field Categories

Geometry:

  • row, col - Position
  • rows, cols - Size
  • min_rows, min_cols - Minimum size
  • max_rows, max_cols - Maximum size

Appearance:

  • title - Window title
  • show_title - Toggle title display
  • title_position - Title placement
  • bg_color - Background color
  • text_color - Text color
  • transparent_bg - Use terminal background

Borders:

  • show_border - Enable borders
  • border_style - Style (plain, rounded, double)
  • border_color - Border color
  • border_top/bottom/left/right - Individual sides

Content:

  • streams - Stream IDs for text widgets
  • buffer_size - Max line count
  • wordwrap - Enable word wrapping
  • timestamps - Show timestamps
  • content_align - Text alignment

Widget-Specific Fields

Tabbed Text:

  • tab_bar_position - Top or bottom
  • tab_active_color - Active tab color
  • tab_inactive_color - Inactive tab color

Compass:

  • compass_active_color - Available direction color
  • compass_inactive_color - Unavailable direction color

Progress Bars:

  • progress_label - Bar label
  • progress_color - Bar fill color

Dashboard:

  • dashboard_layout - Horizontal or vertical
  • dashboard_spacing - Item spacing

Common Browser Features

All browsers support dragging by the title bar:

#![allow(unused)]
fn main() {
pub fn handle_mouse(
    &mut self,
    mouse_col: u16,
    mouse_row: u16,
    mouse_down: bool,
    area: Rect,
) -> bool {
    let on_title_bar = mouse_row == self.popup_y
        && mouse_col > self.popup_x
        && mouse_col < self.popup_x + popup_width - 1;

    if mouse_down && on_title_bar && !self.is_dragging {
        self.is_dragging = true;
        self.drag_offset_x = mouse_col.saturating_sub(self.popup_x);
        self.drag_offset_y = mouse_row.saturating_sub(self.popup_y);
        return true;
    }
    // ...
}
}

Scroll with Category Headers

Browsers with categories handle scrolling with sticky headers for context.

Common keybinds for browsers and editors:

KeyActionContext
Up/kNavigate upAll browsers
Down/jNavigate downAll browsers
PageUpPage upAll browsers
PageDownPage downAll browsers
EnterSelect/confirmBrowsers → Editor
EscapeClose/cancelAll
TabNext fieldEditors
Shift+TabPrevious fieldEditors
SpaceToggle/cycleToggleable/Cyclable
DeleteDelete selectedSelectable
Ctrl+SSaveSaveable

Form Validation

Editors validate input before saving:

#![allow(unused)]
fn main() {
impl Saveable for HighlightForm {
    type SaveResult = HighlightPattern;

    fn try_save(&mut self) -> Option<Self::SaveResult> {
        // Validate required fields
        let name = self.name_field.lines()[0].trim();
        if name.is_empty() {
            self.error_message = Some("Name is required".to_string());
            return None;
        }

        // Validate pattern syntax
        let pattern = self.pattern_field.lines()[0].trim();
        if let Err(e) = regex::Regex::new(pattern) {
            self.error_message = Some(format!("Invalid regex: {}", e));
            return None;
        }

        // Build result
        Some(HighlightPattern {
            name: name.to_string(),
            pattern: pattern.to_string(),
            // ...
        })
    }
}
}

Integration Points

Commands

CommandBrowser/EditorFunction
.highlightsHighlightBrowserBrowse highlights
.highlight <name>HighlightFormEdit highlight
.addhighlightHighlightFormAdd highlight
.keybindsKeybindBrowserBrowse keybinds
.keybind <key>KeybindFormEdit keybind
.addkeybindKeybindFormAdd keybind
.colorsColorPaletteBrowserBrowse palette
.spellcolorsSpellColorBrowserBrowse spell colors
.themesThemeBrowserBrowse themes
.window <name>WindowEditorEdit widget
.layoutLayoutEditorEdit layout

UI State

Browsers are tracked in MenuState:

#![allow(unused)]
fn main() {
pub enum MenuState {
    None,
    HighlightBrowser(HighlightBrowser),
    HighlightForm(HighlightForm),
    KeybindBrowser(KeybindBrowser),
    KeybindForm(KeybindForm),
    ColorPaletteBrowser(ColorPaletteBrowser),
    SpellColorBrowser(SpellColorBrowser),
    ThemeBrowser(ThemeBrowser),
    WindowEditor(WindowEditor),
    // ...
}
}

Creating a New Browser

1. Define Entry Structure

#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct MyEntry {
    pub id: String,
    pub name: String,
    pub value: String,
}
}

2. Create Browser Struct

#![allow(unused)]
fn main() {
pub struct MyBrowser {
    entries: Vec<MyEntry>,
    selected_index: usize,
    scroll_offset: usize,

    // Popup dragging
    pub popup_x: u16,
    pub popup_y: u16,
    pub is_dragging: bool,
    pub drag_offset_x: u16,
    pub drag_offset_y: u16,
}
}

3. Implement Traits

#![allow(unused)]
fn main() {
impl Navigable for MyBrowser {
    fn navigate_up(&mut self) {
        if self.selected_index > 0 {
            self.selected_index -= 1;
            self.adjust_scroll();
        }
    }
    // ...
}

impl Selectable for MyBrowser {
    fn get_selected(&self) -> Option<String> {
        self.entries.get(self.selected_index).map(|e| e.id.clone())
    }
    // ...
}
}

4. Implement Widget Rendering

#![allow(unused)]
fn main() {
impl Widget for &MyBrowser {
    fn render(self, area: Rect, buf: &mut Buffer) {
        // Clear popup area
        Clear.render(area, buf);

        // Draw border and title
        let block = Block::default()
            .borders(Borders::ALL)
            .title(" My Browser ");
        block.render(area, buf);

        // Render entries with selection highlighting
        for (idx, entry) in self.visible_entries().enumerate() {
            let style = if idx == self.selected_index {
                Style::default().add_modifier(Modifier::REVERSED)
            } else {
                Style::default()
            };
            // ... render entry
        }
    }
}
}

5. Register Command

#![allow(unused)]
fn main() {
".mybrowser" => {
    let browser = MyBrowser::new(&config.my_data);
    app.ui_state.menu_state = MenuState::MyBrowser(browser);
}
}

See Also

Performance Optimization

Two-Face is designed for smooth, low-latency gameplay with 60+ FPS rendering and sub-millisecond event processing.

Performance Goals

  • 60+ FPS - Smooth terminal rendering
  • Sub-millisecond event processing - Responsive input
  • Efficient memory usage - Bounded buffers
  • Low network overhead - Streaming parser

Performance Telemetry

PerformanceStats

The centralized telemetry collector:

#![allow(unused)]
fn main() {
pub struct PerformanceStats {
    // Frame timing
    frame_times: VecDeque<Duration>,
    last_frame_time: Instant,
    max_frame_samples: usize,      // Default: 60

    // Network stats
    bytes_received: u64,
    bytes_sent: u64,
    bytes_received_last_second: u64,
    bytes_sent_last_second: u64,

    // Parser stats
    parse_times: VecDeque<Duration>,
    chunks_parsed: u64,
    chunks_parsed_last_second: u64,

    // Render timing
    render_times: VecDeque<Duration>,
    ui_render_times: VecDeque<Duration>,
    text_wrap_times: VecDeque<Duration>,

    // Event processing
    event_process_times: VecDeque<Duration>,
    events_processed: u64,

    // Memory tracking
    total_lines_buffered: usize,
    active_window_count: usize,

    // Application uptime
    app_start_time: Instant,
}
}

Recording Methods

#![allow(unused)]
fn main() {
// Frame timing
stats.record_frame();                    // Called each frame
stats.record_render_time(duration);      // Total render
stats.record_ui_render_time(duration);   // UI widgets
stats.record_text_wrap_time(duration);   // Text wrapping

// Network
stats.record_bytes_received(bytes);
stats.record_bytes_sent(bytes);

// Parser
stats.record_parse(duration);
stats.record_elements_parsed(count);

// Events
stats.record_event_process_time(duration);

// Memory
stats.update_memory_stats(total_lines, window_count);
}

Available Metrics

MetricMethodUnit
FPSfps()frames/sec
Avg frame timeavg_frame_time_ms()ms
Min frame timemin_frame_time_ms()ms
Max frame timemax_frame_time_ms()ms
Frame jitterframe_jitter_ms()ms (stddev)
Frame spikesframe_spike_count()count >33ms
Network inbytes_received_per_sec()bytes/sec
Network outbytes_sent_per_sec()bytes/sec
Parse timeavg_parse_time_us()μs
Chunks/secchunks_per_sec()count/sec
Render timeavg_render_time_ms()ms
Lines bufferedtotal_lines_buffered()count
Memory estimateestimated_memory_mb()MB
Uptimeuptime()Duration

Generation-Based Change Detection

Efficient Change Tracking

Widgets use generation counters instead of content comparison:

#![allow(unused)]
fn main() {
pub struct TextContent {
    pub lines: VecDeque<StyledLine>,
    pub generation: u64,  // Increments on every add_line()
}
}

Sync Logic

#![allow(unused)]
fn main() {
let last_gen = last_synced_generation.get(name).unwrap_or(0);
let current_gen = text_content.generation;

// Only sync if generation changed - O(1) check
if current_gen > last_gen {
    let delta = (current_gen - last_gen) as usize;
    // Sync only new lines
}
}

Benefits

  1. O(1) change detection - Compare numbers, not content
  2. Incremental updates - Only new lines synced
  3. Automatic full resync - Detects when buffer cleared

Text Wrapping Optimization

Text wrapping is expensive. Two-Face optimizes it through:

Width-Based Invalidation

#![allow(unused)]
fn main() {
pub fn set_width(&mut self, width: u16) {
    if self.current_width != width {
        self.current_width = width;
        self.invalidate_wrap_cache();
    }
}
}

Lazy Wrapping

Lines wrapped on-demand during render:

#![allow(unused)]
fn main() {
fn render_visible_lines(&self, visible_height: usize) -> Vec<WrappedLine> {
    // Only wrap lines that will be displayed
    let visible_range = self.calculate_visible_range(visible_height);
    self.lines
        .iter()
        .skip(visible_range.start)
        .take(visible_range.len())
        .flat_map(|line| self.wrap_line(line))
        .collect()
}
}

Segment-Aware Wrapping

Preserves styling across word boundaries:

#![allow(unused)]
fn main() {
fn wrap_line(&self, line: &StyledLine) -> Vec<WrappedLine> {
    for segment in &line.segments {
        for word in segment.text.split_whitespace() {
            if current_width + word_width > self.max_width {
                // Emit current line, start new
            }
            // Add word with segment's style
        }
    }
}
}

Memory Management

Line Buffer Limits

Each text window has a maximum:

#![allow(unused)]
fn main() {
pub struct TextContent {
    pub lines: VecDeque<StyledLine>,
    pub max_lines: usize,  // Default: 1000
}

impl TextContent {
    pub fn add_line(&mut self, line: StyledLine) {
        self.lines.push_back(line);

        // Trim oldest lines
        while self.lines.len() > self.max_lines {
            self.lines.pop_front();
        }

        self.generation += 1;
    }
}
}

VecDeque Efficiency

VecDeque provides O(1) operations at both ends:

#![allow(unused)]
fn main() {
lines.push_back(new_line);  // O(1) add to end
lines.pop_front();           // O(1) remove from start
}

Memory Estimation

#![allow(unused)]
fn main() {
pub fn estimated_memory_mb(&self) -> f64 {
    // ~200 bytes per line average
    let line_bytes = self.total_lines_buffered * 200;
    line_bytes as f64 / (1024.0 * 1024.0)
}
}

Per-Window Configuration

[[windows]]
name = "main"
buffer_size = 2000  # More history for main

[[windows]]
name = "thoughts"
buffer_size = 500   # Less for secondary

Render Optimization

Dirty Tracking

Widgets track whether they need re-rendering:

#![allow(unused)]
fn main() {
impl TextWindow {
    pub fn is_dirty(&self) -> bool {
        self.dirty
    }

    fn add_line(&mut self, line: StyledLine) {
        self.dirty = true;
    }
}
}

Double Buffering

Ratatui uses double-buffered rendering to minimize flicker.

Partial Rendering

Only changed widgets are re-rendered when possible.

Highlight Pattern Optimization

Regex Caching

Patterns compiled once at load:

#![allow(unused)]
fn main() {
pub fn compile_highlight_patterns(highlights: &mut HashMap<String, HighlightPattern>) {
    for (name, pattern) in highlights.iter_mut() {
        if !pattern.fast_parse {
            pattern.compiled_regex = Some(regex::Regex::new(&pattern.pattern)?);
        }
    }
}
}

Fast Parse Mode

For simple literals, Aho-Corasick is faster:

[highlights.names]
pattern = "Bob|Alice|Charlie"
fg = "#ffff00"
fast_parse = true  # Uses Aho-Corasick

Early Exit

Skip highlight if text already styled:

#![allow(unused)]
fn main() {
if segment.span_type != SpanType::Normal {
    continue;  // Monsterbold, Link, etc. take priority
}
}

Network Optimization

Buffered I/O

#![allow(unused)]
fn main() {
let reader = BufReader::new(stream);
}

Non-Blocking Polling

#![allow(unused)]
fn main() {
if crossterm::event::poll(Duration::from_millis(10))? {
    // Handle event
}
}

Streaming Parser

Processes data as it arrives, not waiting for complete messages.

Performance Widget

Display real-time metrics:

[[windows]]
name = "performance"
type = "performance"
show_fps = true
show_frame_times = true
show_render_times = true
show_net = true
show_parse = true
show_memory = true
show_lines = true
show_uptime = true

Selective Collection

Enable only displayed metrics to reduce overhead:

#![allow(unused)]
fn main() {
stats.apply_enabled_from(&performance_widget_data);
}

Profiling

Built-in Metrics

Use the performance widget for real-time profiling.

Tracing

RUST_LOG=two_face=debug cargo run

Release Build

Always profile with release builds:

cargo build --release

Release optimizations:

  • Full optimization (opt-level = 3)
  • Link-time optimization (lto = true)
  • Single codegen unit

Best Practices

For Users

  1. Reduce buffer sizes for secondary windows
  2. Use fast_parse for simple highlight patterns
  3. Disable unused performance metrics
  4. Keep highlight count reasonable (<100 patterns)
  5. Use compact layouts when possible

For Developers

  1. Use generation counters for change detection
  2. Avoid content comparison - use flags or counters
  3. Batch updates when possible
  4. Profile with release builds
  5. Use VecDeque for FIFO buffers
  6. Cache expensive computations

Performance Troubleshooting

Low FPS

  1. Reduce buffer sizes
  2. Disable unused widgets
  3. Reduce highlight patterns
  4. Use fast_parse = true

High Memory

  1. Reduce buffer_size on windows
  2. Close unused tabs
  3. Check for memory leaks (rising delta)

High Latency

  1. Check network connection
  2. Reduce highlight patterns
  3. Simplify layout

Frame Spikes

  1. Check text wrapping (long lines)
  2. Reduce highlight complexity
  3. Profile with tracing

See Also

Network Overview

Two-Face supports two connection modes for connecting to GemStone IV and DragonRealms servers.

Connection Modes

ModeDescriptionUse Case
Lich ProxyConnect through Lich middlewareStandard setup, script support
Direct eAccessAuthenticate directly with SimutronicsStandalone, no Lich required

Quick Comparison

FeatureLich ProxyDirect eAccess
Lich scripts✓ Yes✗ No
Setup complexityMediumSimple
DependenciesLich, RubyOpenSSL
AuthenticationHandled by LichBuilt-in
LatencySlightly higherMinimal

Connection Flow

Lich Proxy Mode

┌─────────┐     ┌──────┐     ┌────────────┐     ┌──────────┐
│Two-Face │────▶│ Lich │────▶│ Game Server │────▶│ Response │
└─────────┘     └──────┘     └────────────┘     └──────────┘
     ▲              │                                │
     └──────────────┴────────────────────────────────┘

Two-Face connects to Lich’s local proxy port (typically 8000). Lich handles authentication and provides script execution capabilities.

Direct eAccess Mode

┌─────────┐     ┌─────────┐     ┌────────────┐
│Two-Face │────▶│ eAccess │────▶│ Game Server │
└─────────┘     └─────────┘     └────────────┘
     │               │                │
     └───────────────┴────────────────┘
         Direct TLS connection

Two-Face authenticates directly with Simutronics’ eAccess servers, then connects to the game server.

Which Mode Should I Use?

Use Lich Proxy If:

  • You use Lich scripts (recommended for most players)
  • You want the Lich ecosystem (scripts, plugins)
  • You’re already familiar with Lich
  • You need advanced automation

Use Direct eAccess If:

  • You want a standalone client
  • You don’t use Lich scripts
  • You want minimal latency
  • You prefer fewer dependencies
  • Lich isn’t available for your platform

Network Requirements

Ports

ServicePortProtocol
Lich proxy8000 (configurable)TCP
eAccess7900/7910TLS
Game serversVariousTCP

Firewall Rules

If behind a firewall, ensure outbound connections to:

  • eaccess.play.net (port 7900/7910)
  • Game server IPs (assigned dynamically)

Security

Lich Mode

  • Lich handles credential storage
  • Connection to Lich is local (127.0.0.1)
  • Game traffic may or may not be encrypted depending on Lich version

Direct Mode

  • TLS encryption to eAccess
  • Certificate pinning for authentication servers
  • Credentials passed at runtime (not stored)

Configuration

Lich Mode

# config.toml
[connection]
mode = "lich"
host = "127.0.0.1"
port = 8000

Direct Mode

# config.toml
[connection]
mode = "direct"
game = "gs4"          # or "dr" for DragonRealms

Credentials are provided via command line or environment variables (never stored in config).

Guides

This section covers:

Quick Start

  1. Install and configure Lich
  2. Start Lich and log into your character
  3. Launch Two-Face: two-face --host 127.0.0.1 --port 8000

Direct Mode

  1. Launch Two-Face with credentials:
    two-face --direct \
      --account YOUR_ACCOUNT \
      --password YOUR_PASSWORD \
      --game prime \
      --character CHARACTER_NAME
    

See Also

Lich Proxy Connection

Connect Two-Face through Lich for full script support and the Lich ecosystem.

Overview

Lich is a middleware application that sits between your client and the game server. It provides:

  • Script execution (Ruby-based automation)
  • Plugin support
  • Community scripts repository
  • Character management

Two-Face connects to Lich’s local proxy port, receiving game data and sending commands through Lich.

Prerequisites

  • Lich installed and configured
  • Ruby (required by Lich)
  • Game account and subscription

Installing Lich

Download Lich

Visit the Lich Project website or community resources to download Lich.

Install Ruby

Lich requires Ruby. Install the appropriate version for your platform:

Windows:

  • Download RubyInstaller from https://rubyinstaller.org/
  • Install with DevKit option

macOS:

brew install ruby

Linux:

sudo apt install ruby ruby-dev

Configure Lich

  1. Run Lich setup wizard
  2. Enter your Simutronics credentials
  3. Select your character
  4. Configure proxy port (default: 8000)

Connecting Two-Face

Basic Connection

Once Lich is running and logged in:

two-face --host 127.0.0.1 --port 8000

Configuration File

Add to ~/.two-face/config.toml:

[connection]
mode = "lich"
host = "127.0.0.1"
port = 8000
auto_reconnect = true
reconnect_delay = 5

Command Line Options

two-face --host HOST --port PORT [OPTIONS]

Options:
  --host HOST      Lich proxy host (default: 127.0.0.1)
  --port PORT      Lich proxy port (default: 8000)
  --no-reconnect   Disable auto-reconnect

Connection Workflow

Step-by-Step

  1. Start Lich

    ruby lich.rb
    
  2. Log into character via Lich

    • Use Lich’s login interface
    • Select character
    • Wait for game connection
  3. Verify Lich is listening

    • Lich should show proxy port status
    • Default: listening on port 8000
  4. Connect Two-Face

    two-face --host 127.0.0.1 --port 8000
    
  5. Verify connection

    • Two-Face should display game output
    • Commands typed in Two-Face should work in game

Diagram

┌──────────────────────────────────────────────────────────────┐
│                       Your Computer                          │
│                                                              │
│  ┌───────────┐         ┌──────────┐         ┌──────────┐    │
│  │ Two-Face  │◄───────▶│   Lich   │◄───────▶│ Scripts  │    │
│  └───────────┘         └──────────┘         └──────────┘    │
│       │                     │                               │
│       │                     │ Port 8000                     │
│       └─────────────────────┘                               │
│                             │                               │
└─────────────────────────────┼───────────────────────────────┘
                              │
                              ▼ Internet
                    ┌──────────────────┐
                    │   Game Server    │
                    └──────────────────┘

Multi-Character Setup

Different Ports per Character

If running multiple characters:

Lich configuration:

  • Character 1: Port 8000
  • Character 2: Port 8001
  • Character 3: Port 8002

Two-Face instances:

# Terminal 1
two-face --port 8000

# Terminal 2
two-face --port 8001

# Terminal 3
two-face --port 8002

Profile-Based Configuration

Create character profiles in ~/.two-face/profiles/:

# ~/.two-face/profiles/warrior.toml
[connection]
port = 8000

# ~/.two-face/profiles/wizard.toml
[connection]
port = 8001

Launch with profile:

two-face --profile warrior
two-face --profile wizard

Lich Script Integration

Running Scripts

Lich scripts are controlled through Lich, not Two-Face. Common commands:

;script_name          # Run a script
;kill script_name     # Stop a script
;pause script_name    # Pause a script
;unpause script_name  # Resume a script
;list                 # List running scripts

These commands are sent to Lich, which executes them.

Script Output

Script output appears in Two-Face’s main text window alongside game output. You can filter script messages using stream filtering if Lich tags them appropriately.

Script Commands vs Game Commands

PrefixDestinationExample
;Lich (scripts);go2 bank
.Two-Face (client).reload config
(none)Game servernorth

Lich Settings

Proxy Configuration

In Lich’s configuration:

# Listen on all interfaces (for remote access)
proxy_host = "0.0.0.0"
proxy_port = 8000

# Listen on localhost only (more secure)
proxy_host = "127.0.0.1"
proxy_port = 8000

XML Mode

Ensure Lich is configured to pass through game XML:

xml_passthrough = true

Two-Face requires XML tags to properly parse game state.

Performance Considerations

Latency

Lich adds minimal latency (typically <10ms) due to local proxy processing.

Memory

Running Lich + Ruby + scripts uses additional memory. Typical usage:

  • Lich base: ~50-100MB
  • Per character: +20-50MB
  • Scripts: Varies by script

CPU

Script execution uses CPU. Complex scripts may impact performance during heavy automation.

Troubleshooting

Connection Refused

Error: Connection refused to 127.0.0.1:8000

Solutions:

  1. Verify Lich is running
  2. Check Lich has logged into a character
  3. Confirm proxy port matches (check Lich status)
  4. Try a different port if 8000 is in use

Lich Not Receiving Commands

Solutions:

  1. Check Two-Face is connected (look for game output)
  2. Verify commands aren’t being intercepted
  3. Check for Lich script conflicts

XML Not Parsing

If widgets aren’t updating:

Solutions:

  1. Enable XML passthrough in Lich
  2. Check Lich version supports XML
  3. Verify game has XML mode enabled

Disconnects

If connection drops frequently:

Solutions:

  1. Check network stability
  2. Enable auto-reconnect:
    [connection]
    auto_reconnect = true
    reconnect_delay = 5
    
  3. Check Lich logs for errors

Port Already in Use

Error: Port 8000 already in use

Solutions:

  1. Close other applications using the port
  2. Change Lich’s proxy port
  3. Find the process using the port:
    # Linux/macOS
    lsof -i :8000
    
    # Windows
    netstat -ano | findstr :8000
    

Advanced Configuration

Remote Lich Access

To connect from a different machine (advanced):

  1. Configure Lich to listen on network:

    proxy_host = "0.0.0.0"
    
  2. Connect from Two-Face:

    two-face --host 192.168.1.100 --port 8000
    
  3. Security warning: Only do this on trusted networks!

SSH Tunneling

For secure remote access:

# On remote machine
ssh -L 8000:localhost:8000 user@lich-server

# Then connect Two-Face locally
two-face --host 127.0.0.1 --port 8000

See Also

Direct eAccess Connection

Connect directly to GemStone IV and DragonRealms without requiring Lich as middleware.

Overview

Direct eAccess mode allows Two-Face to authenticate directly with Simutronics’ servers, bypassing the need for Lich. This provides:

  • Standalone operation
  • Lower latency
  • Fewer dependencies
  • Simpler setup

Trade-off: No Lich script support in this mode.

Prerequisites

  • OpenSSL library
  • Game account and subscription
  • Character already created

Installation Requirements

Windows

OpenSSL is installed via vcpkg during the build process:

  1. Ensure VCPKG_ROOT environment variable is set
  2. vcpkg handles OpenSSL installation automatically

If building from source:

vcpkg install openssl:x64-windows

macOS

OpenSSL is typically available via Homebrew:

brew install openssl

Linux

Install OpenSSL development package:

# Debian/Ubuntu
sudo apt install libssl-dev

# Fedora/RHEL
sudo dnf install openssl-devel

# Arch
sudo pacman -S openssl

Connecting

Command Line

two-face --direct \
  --account YOUR_ACCOUNT \
  --password YOUR_PASSWORD \
  --game prime \
  --character CHARACTER_NAME

Options

OptionDescriptionExample
--directEnable direct eAccess modeRequired flag
--accountSimutronics account namemyaccount
--passwordAccount passwordmypassword
--gameGame instanceprime, test, dr
--characterCharacter nameMycharacter

Game Instance Values

ValueGame
primeGemStone IV (Prime)
testGemStone IV (Test)
platGemStone IV (Platinum)
drDragonRealms
drtDragonRealms (Test)

Environment Variables

For security, credentials can be passed via environment:

export TF_ACCOUNT="myaccount"
export TF_PASSWORD="mypassword"
export TF_GAME="prime"
export TF_CHARACTER="Mycharacter"

two-face --direct

Authentication Flow

How It Works

┌──────────────────────────────────────────────────────────────────┐
│                    Direct eAccess Flow                           │
│                                                                  │
│  ┌─────────┐    TLS     ┌─────────────┐                         │
│  │Two-Face │◄──────────▶│   eAccess   │  1. TLS Handshake       │
│  └─────────┘            │  7900/7910  │  2. Request hash key    │
│       │                 └─────────────┘  3. Send credentials    │
│       │                       │          4. Get character list  │
│       │                       │          5. Request launch key  │
│       │                       ▼                                  │
│       │                 ┌─────────────┐                         │
│       └────────────────▶│ Game Server │  6. Connect with key    │
│                         └─────────────┘                         │
└──────────────────────────────────────────────────────────────────┘

Technical Details

  1. TLS Connection: Connects to eaccess.play.net:7910 using TLS
  2. Challenge-Response:
    • Requests 32-byte hash key from server
    • Password is obfuscated: ((char - 32) ^ hashkey[i]) + 32
  3. Session Creation: Receives character list and subscription info
  4. Launch Ticket: Requests and receives game server connection key
  5. Game Connection: Connects to assigned game server with launch key

Security Implementation

  • TLS Encryption: All eAccess communication is encrypted
  • Certificate Pinning: Server certificate is verified (stored at ~/.two-face/simu.pem)
  • No SNI: Server Name Indication is disabled to match protocol requirements
  • Single-Write TLS: Commands sent as single TLS records for compatibility

Certificate Management

First Connection

On first direct connection, Two-Face:

  1. Downloads the eAccess server certificate
  2. Stores it at ~/.two-face/simu.pem
  3. Uses it for verification on subsequent connections

Certificate Location

~/.two-face/
└── simu.pem        # eAccess TLS certificate

Refreshing Certificate

If authentication fails due to certificate issues:

# Delete stored certificate
rm ~/.two-face/simu.pem

# Reconnect to download fresh certificate
two-face --direct --account ... --password ... --game ... --character ...

Configuration

Config File (Partial)

You can specify some options in config, but credentials should not be stored:

# ~/.two-face/config.toml
[connection]
mode = "direct"
game = "prime"

# Do NOT store credentials in config file!

Use a wrapper script or shell alias:

# ~/.bashrc or ~/.zshrc
alias gs4='two-face --direct --game prime --account $GS4_ACCOUNT --password $GS4_PASSWORD --character'

# Usage
gs4 Mycharacter

Or a script with secure password handling:

#!/bin/bash
# gs4-connect.sh

read -sp "Password: " PASSWORD
echo

two-face --direct \
  --account "$1" \
  --password "$PASSWORD" \
  --game prime \
  --character "$2"

Multi-Character

Sequential Characters

Direct mode connects one character at a time. To switch:

  1. Exit Two-Face
  2. Reconnect with different --character parameter

Parallel Characters

Run multiple Two-Face instances in separate terminals:

# Terminal 1
two-face --direct --account myaccount --password mypass --game prime --character Warrior

# Terminal 2
two-face --direct --account myaccount --password mypass --game prime --character Wizard

Each instance maintains its own connection.

Performance

Latency Comparison

ModeTypical Latency
Direct~50-100ms (network only)
Lich~60-120ms (network + proxy)

Direct mode eliminates the local proxy hop, providing marginally lower latency.

Resource Usage

  • Memory: ~20-30MB base (no Lich overhead)
  • CPU: Minimal (only TLS processing)
  • Network: Standard game traffic

Troubleshooting

Authentication Failed

Error: Authentication failed

Solutions:

  1. Verify account credentials are correct
  2. Test login via official client or Lich
  3. Check account is active (not banned/suspended)
  4. Delete and re-download certificate: rm ~/.two-face/simu.pem

Invalid Character

Error: Character not found

Solutions:

  1. Check character name spelling (case-sensitive)
  2. Verify character exists on specified game instance
  3. Check account has access to that game

Connection Timeout

Error: Connection to eaccess.play.net timed out

Solutions:

  1. Check internet connectivity
  2. Verify firewall allows port 7910 outbound
  3. Try again (may be temporary server issue)

TLS Handshake Failed

Error: TLS handshake failed

Solutions:

  1. Update OpenSSL to latest version
  2. Delete certificate and retry: rm ~/.two-face/simu.pem
  3. Check system time is correct (TLS requires accurate time)

OpenSSL Not Found

Error: OpenSSL library not found

Solutions:

  1. Install OpenSSL for your platform
  2. Set library path if non-standard location
  3. On Windows, verify vcpkg installation

Security Considerations

Credential Handling

  • Never store passwords in config files
  • Use environment variables or interactive prompts
  • Clear shell history if credentials were typed

Network Security

  • All eAccess traffic is TLS encrypted
  • Game server traffic uses standard (unencrypted) protocol
  • Certificate pinning prevents MITM attacks on authentication

Logging

Debug logs may contain sensitive information:

  • Don’t share logs publicly without redaction
  • Logs are stored in ~/.two-face/two-face.log

Comparison with Lich Mode

AspectDirect eAccessLich Proxy
Scripts✗ None✓ Full Lich scripts
SetupSimpleMedium
DependenciesOpenSSLLich + Ruby
LatencyLowerSlightly higher
MemoryLowerHigher
Multi-charSeparate instancesLich manages
CommunityLimitedLarge ecosystem

When to Use Direct Mode

Good for:

  • Players who don’t use scripts
  • Minimal installations
  • Performance-sensitive setups
  • Learning/testing the game

Not recommended for:

  • Heavy script users
  • Automation needs
  • Community script ecosystem access

See Also

TLS Certificates

Understanding and managing TLS certificates for secure connections.

Overview

Two-Face uses TLS (Transport Layer Security) for encrypted communication with Simutronics’ eAccess authentication servers. This page covers certificate management for direct eAccess mode.

Certificate Architecture

Connection Security

┌─────────────────────────────────────────────────────────────┐
│                  TLS Connection Flow                        │
│                                                             │
│  Two-Face                    eAccess Server                 │
│     │                             │                         │
│     │──── ClientHello ───────────▶│                         │
│     │                             │                         │
│     │◀─── ServerHello + Cert ─────│                         │
│     │                             │                         │
│     │  Verify cert against        │                         │
│     │  stored simu.pem            │                         │
│     │                             │                         │
│     │──── Key Exchange ──────────▶│                         │
│     │                             │                         │
│     │◀═══ Encrypted Channel ═════▶│                         │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Why Certificate Pinning?

The eAccess server uses a self-signed certificate. Certificate pinning:

  1. First connection: Downloads and stores the certificate
  2. Subsequent connections: Verifies server presents the same certificate
  3. Protection: Prevents man-in-the-middle attacks

Certificate Storage

Location

~/.two-face/
└── simu.pem        # eAccess server certificate

File Format

The certificate is stored in PEM format:

-----BEGIN CERTIFICATE-----
MIIDxTCCAq2gAwIBAgIJAK... (base64 encoded)
-----END CERTIFICATE-----

Permissions

The certificate file should have restricted permissions:

# Linux/macOS
chmod 600 ~/.two-face/simu.pem

# Windows
# File inherits user permissions from .two-face folder

Certificate Lifecycle

First Connection

On first direct eAccess connection:

  1. Two-Face connects to eaccess.play.net:7910
  2. Server sends its certificate during TLS handshake
  3. Two-Face saves certificate to ~/.two-face/simu.pem
  4. Connection continues with authentication

Subsequent Connections

On later connections:

  1. Two-Face loads stored certificate
  2. During TLS handshake, compares server’s certificate
  3. If match: connection proceeds
  4. If mismatch: connection fails (security protection)

Certificate Renewal

If Simutronics updates their certificate:

  1. Connection will fail (certificate mismatch)
  2. Delete the old certificate: rm ~/.two-face/simu.pem
  3. Reconnect to download new certificate
  4. Future connections use new certificate

Managing Certificates

Viewing Certificate

# View certificate details
openssl x509 -in ~/.two-face/simu.pem -text -noout

# View expiration date
openssl x509 -in ~/.two-face/simu.pem -enddate -noout

# View fingerprint
openssl x509 -in ~/.two-face/simu.pem -fingerprint -noout

Example Output

Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number: ...
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: C=US, ST=..., O=Simutronics...
        Validity
            Not Before: Jan  1 00:00:00 2020 GMT
            Not After : Dec 31 23:59:59 2030 GMT
        Subject: CN=eaccess.play.net...

Deleting Certificate

To force certificate refresh:

# Linux/macOS
rm ~/.two-face/simu.pem

# Windows (PowerShell)
Remove-Item ~\.two-face\simu.pem

# Windows (Command Prompt)
del %USERPROFILE%\.two-face\simu.pem

Backing Up Certificate

If you want to preserve your certificate:

cp ~/.two-face/simu.pem ~/.two-face/simu.pem.backup

Troubleshooting

Certificate Verification Failed

Error: Certificate verification failed

Causes:

  • Certificate changed on server
  • Certificate file corrupted
  • System time incorrect

Solutions:

  1. Delete and re-download:
    rm ~/.two-face/simu.pem
    
  2. Check system time is accurate
  3. Verify network isn’t intercepting traffic

Certificate Not Found

Error: Could not load certificate from ~/.two-face/simu.pem

Causes:

  • First connection hasn’t occurred
  • Certificate was deleted
  • Permission issues

Solutions:

  1. Run direct connection to auto-download
  2. Check folder permissions
  3. Verify path is correct

TLS Handshake Error

Error: TLS handshake failed

Causes:

  • OpenSSL version incompatibility
  • Network proxy interference
  • Server configuration changed

Solutions:

  1. Update OpenSSL
  2. Disable network proxies
  3. Delete certificate and retry

Self-Signed Certificate Warning

The eAccess certificate is self-signed, which is expected. Two-Face handles this through certificate pinning rather than chain validation.

Advanced Topics

Manual Certificate Download

If auto-download fails, manually retrieve:

# Connect and save certificate
openssl s_client -connect eaccess.play.net:7910 \
  -servername "" \
  </dev/null 2>/dev/null | \
  openssl x509 -outform PEM > ~/.two-face/simu.pem

Note: The empty -servername "" disables SNI, which is required.

Certificate Validation

To manually verify a certificate:

# Compare fingerprints
openssl x509 -in ~/.two-face/simu.pem -fingerprint -sha256 -noout

Compare the fingerprint with known-good values from the community.

Multiple Certificates

If connecting to different game servers (test vs production):

~/.two-face/
├── simu.pem           # Production eAccess
├── simu-test.pem      # Test server (if different)

Configure via:

[connection]
certificate = "simu.pem"  # or "simu-test.pem"

Security Best Practices

Do

  • ✓ Keep certificate file permissions restrictive
  • ✓ Verify certificate fingerprint periodically
  • ✓ Delete and refresh if authentication fails unexpectedly
  • ✓ Keep OpenSSL updated

Don’t

  • ✗ Share your certificate file publicly
  • ✗ Ignore certificate verification failures
  • ✗ Use the same certificate across different machines (download fresh)
  • ✗ Disable certificate verification

Technical Details

TLS Configuration

Two-Face’s TLS connection uses:

SettingValueReason
ProtocolTLS 1.2+Security
SNIDisabledServer requirement
Session cachingDisabledProtocol compatibility
Cipher suitesSystem defaultOpenSSL manages

Single-Write Requirement

A critical implementation detail: commands must be sent as single TLS Application Data records. Two-Face ensures this by building complete messages in memory before writing to the TLS stream.

#![allow(unused)]
fn main() {
// Correct: Single write
let message = format!("{}\n", line);
stream.write_all(message.as_bytes())?;

// Incorrect: Multiple writes (would fail)
stream.write_all(line.as_bytes())?;
stream.write_all(b"\n")?;
}

See Also

Network Troubleshooting

Solutions for common connection issues with Two-Face.

Quick Diagnostics

Check Connection Status

# Verify Two-Face is running
ps aux | grep two-face

# Check if port is in use (Lich mode)
netstat -an | grep 8000

# Test internet connectivity
ping eaccess.play.net

View Logs

# Tail the log file
tail -f ~/.two-face/two-face.log

# Search for errors
grep -i error ~/.two-face/two-face.log

Common Issues

Connection Refused

Symptom:

Error: Connection refused to 127.0.0.1:8000

Cause: Lich proxy isn’t running or listening on the expected port.

Solutions:

  1. Verify Lich is running:

    # Check for Lich process
    ps aux | grep -i lich
    
  2. Check Lich has connected to game:

    • Lich must be logged into a character
    • The proxy only starts after game connection
  3. Verify port number:

    • Check Lich’s configured proxy port
    • Default is 8000, but may be different
  4. Try a different port:

    # If 8000 is blocked, configure Lich for 8001
    two-face --port 8001
    

Connection Timeout

Symptom:

Error: Connection timed out

Cause: Network issue between Two-Face and the target.

Solutions:

  1. Lich mode - Check Lich is responding:

    telnet 127.0.0.1 8000
    
  2. Direct mode - Check internet connectivity:

    # Test eAccess server
    nc -zv eaccess.play.net 7910
    
    # Or with OpenSSL
    openssl s_client -connect eaccess.play.net:7910
    
  3. Firewall check:

    • Ensure port 7910 is allowed outbound
    • Check corporate/school firewall policies
  4. Try again later:

    • Simutronics servers may be temporarily unavailable
    • Check official forums for server status

Authentication Failed

Symptom:

Error: Authentication failed

Cause: Invalid credentials or account issue.

Solutions:

  1. Verify credentials:

    • Double-check account name (case-insensitive)
    • Verify password (case-sensitive)
    • Test login via official client first
  2. Account status:

    • Ensure subscription is active
    • Check account isn’t banned/suspended
    • Verify no billing issues
  3. Character name:

    • Confirm character exists
    • Check spelling (often case-sensitive)
    • Verify character is on correct game instance
  4. Certificate issue (direct mode):

    # Reset certificate
    rm ~/.two-face/simu.pem
    # Retry connection
    

TLS Handshake Failed

Symptom:

Error: TLS handshake failed

Cause: SSL/TLS configuration issue.

Solutions:

  1. Update OpenSSL:

    # Check version
    openssl version
    
    # Update (varies by OS)
    # macOS: brew upgrade openssl
    # Ubuntu: sudo apt update && sudo apt upgrade openssl
    
  2. Reset certificate:

    rm ~/.two-face/simu.pem
    
  3. Check system time:

    • TLS requires accurate system time
    • Sync with NTP server
  4. Network proxy:

    • Disable SSL inspection proxies
    • Corporate proxies may intercept TLS

Character Not Found

Symptom:

Error: Character 'Mychar' not found on account

Cause: Character doesn’t exist or wrong game instance.

Solutions:

  1. Check character name spelling:

    • Some systems are case-sensitive
    • No spaces or special characters
  2. Verify game instance:

    # GemStone IV Prime
    --game prime
    
    # GemStone IV Platinum
    --game plat
    
    # GemStone IV Test
    --game test
    
    # DragonRealms
    --game dr
    
  3. List characters:

    • Login via official client to see character list
    • Verify account has characters on that game

Disconnected Unexpectedly

Symptom: Connection drops during gameplay.

Cause: Network instability, server kick, or timeout.

Solutions:

  1. Enable auto-reconnect:

    [connection]
    auto_reconnect = true
    reconnect_delay = 5
    
  2. Check network stability:

    # Monitor packet loss
    ping -c 100 eaccess.play.net
    
  3. Idle timeout:

    • Game may disconnect idle connections
    • Configure keepalive if available
  4. Server-side kick:

    • Check for policy violations
    • Too many connections from same IP
    • Anti-automation detection

Port Already in Use

Symptom:

Error: Address already in use: 0.0.0.0:8000

Cause: Another application is using the port.

Solutions:

  1. Find the process:

    # Linux/macOS
    lsof -i :8000
    
    # Windows
    netstat -ano | findstr :8000
    
  2. Kill the process:

    # Linux/macOS
    kill -9 <PID>
    
    # Windows
    taskkill /F /PID <PID>
    
  3. Use different port:

    • Configure Lich for different port
    • Connect Two-Face to new port

No Data Received

Symptom: Connected but no game output appears.

Cause: Stream configuration or parsing issue.

Solutions:

  1. Check stream configuration:

    # Ensure main window has appropriate streams
    [[widgets]]
    type = "text"
    streams = ["main", "room", "combat"]
    
  2. Verify XML passthrough (Lich mode):

    • Lich must pass through game XML
    • Check Lich configuration
  3. Test with simple layout:

    # Minimal test layout
    [[widgets]]
    type = "text"
    streams = []  # Empty = all streams
    x = 0
    y = 0
    width = 100
    height = 100
    
  4. Check logs for parsing errors:

    grep -i parse ~/.two-face/two-face.log
    

SSL Certificate Error

Symptom:

Error: SSL certificate problem: self signed certificate

Cause: Certificate verification issue (expected for eAccess).

Solutions:

  1. This is normal for first connection - certificate should be pinned automatically

  2. If persistent, reset certificate:

    rm ~/.two-face/simu.pem
    
  3. Check no MITM proxy is intercepting traffic

Platform-Specific Issues

Windows

OpenSSL not found:

# Ensure vcpkg installed OpenSSL
vcpkg list | findstr openssl

# Set VCPKG_ROOT
set VCPKG_ROOT=C:\path\to\vcpkg

Permission denied on config:

  • Run as Administrator once to create directories
  • Or manually create %USERPROFILE%\.two-face\

macOS

OpenSSL version mismatch:

# Use Homebrew OpenSSL
export OPENSSL_DIR=$(brew --prefix openssl)

# Or install specific version
brew install openssl@3

Keychain access:

  • macOS may prompt for keychain access
  • Allow Two-Face to access certificates

Linux

Missing libraries:

# Debian/Ubuntu
sudo apt install libssl-dev ca-certificates

# Fedora
sudo dnf install openssl-devel ca-certificates

# Arch
sudo pacman -S openssl

SELinux blocking:

# Check for denials
ausearch -m AVC -ts recent

# Temporarily permissive (testing only)
sudo setenforce 0

Advanced Diagnostics

Network Capture

# Capture Lich traffic
tcpdump -i lo port 8000 -w lich_traffic.pcap

# Capture eAccess traffic
tcpdump -i eth0 host eaccess.play.net -w eaccess_traffic.pcap

Debug Logging

Enable verbose logging:

# config.toml
[logging]
level = "debug"
file = "~/.two-face/debug.log"

Test Mode

Some issues can be isolated with test connections:

# Test Lich connection
echo "look" | nc localhost 8000

# Test eAccess with OpenSSL
openssl s_client -connect eaccess.play.net:7910 -quiet

Getting Help

If issues persist:

  1. Check logs: ~/.two-face/two-face.log
  2. Reproduce minimally: Simple config, default layout
  3. Search issues: Check GitHub issues for similar problems
  4. Report bug: Include:
    • Two-Face version
    • OS and version
    • Connection mode
    • Relevant log excerpts (redact credentials!)
    • Steps to reproduce

See Also

Development Guide

Resources for developers who want to build, modify, or contribute to Two-Face.

Overview

Two-Face is written in Rust, targeting terminal-based user interfaces. This section covers:

  • Building from source
  • Understanding the codebase
  • Extending functionality
  • Contributing to the project

Quick Start

Clone and Build

# Clone the repository
git clone https://github.com/your-repo/two-face.git
cd two-face

# Build in release mode
cargo build --release

# Run
./target/release/two-face --help

Prerequisites

  • Rust 1.70+ (latest stable recommended)
  • Cargo (comes with Rust)
  • OpenSSL development libraries
  • Platform-specific build tools

Documentation Structure

Building

Complete build instructions for all platforms:

  • Development builds
  • Release builds
  • Cross-compilation
  • Troubleshooting build issues

Project Structure

Tour of the codebase:

  • Directory layout
  • Module organization
  • Key files and their purposes
  • Architecture overview

Adding Widgets

Create new widget types:

  • Widget trait implementation
  • State management
  • Rendering
  • Testing widgets

Adding Browsers

Create new popup browser windows:

  • Browser trait implementation
  • Integration with widget system
  • Event handling

Parser Extensions

Extend the XML parser:

  • Adding new element types
  • Custom parsing logic
  • Stream handling

Testing

Test patterns and practices:

  • Unit tests
  • Integration tests
  • Manual testing
  • CI/CD

Contributing

How to contribute:

  • Code style
  • Pull request process
  • Issue reporting
  • Community guidelines

Development Workflow

Typical Development Cycle

1. Create feature branch
   git checkout -b feature/my-feature

2. Make changes
   - Write code
   - Add tests
   - Update documentation

3. Test locally
   cargo test
   cargo run -- [test args]

4. Format and lint
   cargo fmt
   cargo clippy

5. Submit PR
   git push origin feature/my-feature

Development Build vs Release

AspectDebugRelease
Commandcargo buildcargo build --release
SpeedSlowFast
Binary sizeLargeOptimized
Debug symbolsYesNo (by default)
Compile timeFastSlower

Use debug builds for development, release for testing performance.

Architecture Overview

Three-Layer Design

┌───────────────────────────────────────────────┐
│                  Frontend (TUI)               │
│        Rendering, Input Handling, Themes      │
├───────────────────────────────────────────────┤
│                    Core                       │
│   State Management, Business Logic, Events    │
├───────────────────────────────────────────────┤
│                    Data                       │
│        Parsing, Models, Serialization         │
└───────────────────────────────────────────────┘

Key Components

ComponentPurposeLocation
ParserXML protocol handlingsrc/parser.rs
WidgetsUI componentssrc/data/widget.rs
StateApplication statesrc/core/
TUITerminal renderingsrc/frontend/tui/
ConfigConfiguration loadingsrc/config.rs
NetworkConnection handlingsrc/network.rs

Getting Help

Documentation

  • This guide (you’re reading it)
  • Inline code documentation (cargo doc --open)
  • Architecture docs in docs/

Community

  • GitHub Issues for bugs and features
  • Discussions for questions

Code Questions

  • Read existing similar code
  • Check test files for usage examples
  • Ask in GitHub Discussions

Tools

  • VS Code with rust-analyzer
  • IntelliJ IDEA with Rust plugin
  • Vim/Neovim with rust.vim

Useful Commands

# Generate documentation
cargo doc --open

# Run specific test
cargo test test_name

# Check without building
cargo check

# Format code
cargo fmt

# Lint code
cargo clippy

# Watch for changes
cargo watch -x check

See Also

Building from Source

Complete instructions for building Two-Face on all supported platforms.

Prerequisites

Rust Toolchain

Install Rust via rustup:

# Install rustup (Linux/macOS)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# Or on Windows, download from https://rustup.rs/

Verify installation:

rustc --version  # Should be 1.70+
cargo --version

Platform-Specific Dependencies

Windows

Option 1: Visual Studio Build Tools

  1. Install Visual Studio Build Tools
  2. Select “Desktop development with C++”

Option 2: vcpkg for OpenSSL

# Clone vcpkg
git clone https://github.com/Microsoft/vcpkg.git
cd vcpkg
./bootstrap-vcpkg.bat

# Install OpenSSL
./vcpkg install openssl:x64-windows

# Set environment variable
set VCPKG_ROOT=C:\path\to\vcpkg

macOS

# Install Xcode Command Line Tools
xcode-select --install

# Install OpenSSL via Homebrew
brew install openssl

# Set OpenSSL path (add to ~/.zshrc or ~/.bashrc)
export OPENSSL_DIR=$(brew --prefix openssl)

Linux (Debian/Ubuntu)

sudo apt update
sudo apt install build-essential pkg-config libssl-dev

Linux (Fedora/RHEL)

sudo dnf install gcc openssl-devel pkg-config

Linux (Arch)

sudo pacman -S base-devel openssl pkg-config

Basic Build

Clone Repository

git clone https://github.com/your-repo/two-face.git
cd two-face

Debug Build

For development with debug symbols:

cargo build

Binary location: target/debug/two-face

Release Build

For optimized production build:

cargo build --release

Binary location: target/release/two-face

Run Directly

Without building separately:

# Debug mode
cargo run -- --help

# Release mode
cargo run --release -- --host 127.0.0.1 --port 8000

Build Profiles

Debug Profile (default)

# Cargo.toml [profile.dev]
opt-level = 0
debug = true

Fast compilation, slow execution, large binary.

Release Profile

# Cargo.toml [profile.release]
opt-level = 3
lto = true
codegen-units = 1

Slow compilation, fast execution, small binary.

Custom Profile

Create a custom profile in Cargo.toml:

[profile.release-with-debug]
inherits = "release"
debug = true

Build with: cargo build --profile release-with-debug

Feature Flags

Two-Face may have optional features:

# Build with specific features
cargo build --features "feature1,feature2"

# Build with all features
cargo build --all-features

# Build without default features
cargo build --no-default-features

Check Cargo.toml for available features.

Cross-Compilation

Setup

Install cross-compilation targets:

# List available targets
rustup target list

# Add a target
rustup target add x86_64-unknown-linux-gnu
rustup target add x86_64-pc-windows-gnu
rustup target add aarch64-apple-darwin

Build for Target

cargo build --release --target x86_64-unknown-linux-gnu

Using cross

For easier cross-compilation with Docker:

# Install cross
cargo install cross

# Build for Linux from any platform
cross build --release --target x86_64-unknown-linux-gnu

Build Optimization

Already enabled in release profile:

[profile.release]
lto = true

Strip Symbols

Reduce binary size:

# After building
strip target/release/two-face

Or configure in Cargo.toml:

[profile.release]
strip = true

Size Optimization

For minimal binary size:

[profile.release]
opt-level = "z"  # Optimize for size
lto = true
strip = true
panic = "abort"
codegen-units = 1

Dependency Management

Update Dependencies

# Check for outdated dependencies
cargo outdated

# Update all dependencies
cargo update

# Update specific dependency
cargo update -p package_name

Audit Dependencies

# Install cargo-audit
cargo install cargo-audit

# Check for security vulnerabilities
cargo audit

Build Troubleshooting

OpenSSL Not Found

Error: Could not find directory of OpenSSL installation

Windows Solution:

# Set vcpkg root
set VCPKG_ROOT=C:\path\to\vcpkg

# Or set OpenSSL dir directly
set OPENSSL_DIR=C:\path\to\openssl

macOS Solution:

export OPENSSL_DIR=$(brew --prefix openssl)
export OPENSSL_INCLUDE_DIR=$OPENSSL_DIR/include
export OPENSSL_LIB_DIR=$OPENSSL_DIR/lib

Linux Solution:

# Debian/Ubuntu
sudo apt install libssl-dev pkg-config

# Ensure pkg-config can find it
pkg-config --libs openssl

Linker Errors

Error: linking with cc failed

Solutions:

  1. Install build tools for your platform
  2. Check library paths are correct
  3. Ensure all dependencies are installed

Out of Memory

Error: out of memory during compilation

Solutions:

  1. Use fewer codegen units:
    [profile.release]
    codegen-units = 1
    
  2. Reduce parallel jobs:
    cargo build -j 2
    
  3. Close other applications

Slow Builds

Improve build times:

  1. Use sccache:

    cargo install sccache
    export RUSTC_WRAPPER=sccache
    
  2. Use mold linker (Linux):

    # Install mold
    sudo apt install mold
    
    # Configure in .cargo/config.toml
    [target.x86_64-unknown-linux-gnu]
    linker = "clang"
    rustflags = ["-C", "link-arg=-fuse-ld=mold"]
    
  3. Incremental compilation (default in debug)

CI/CD

GitHub Actions Example

name: Build

on: [push, pull_request]

jobs:
  build:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]

    steps:
      - uses: actions/checkout@v3

      - name: Install Rust
        uses: dtolnay/rust-action@stable

      - name: Build
        run: cargo build --release

      - name: Test
        run: cargo test

Verification

Run Tests

cargo test

Run Application

# Show help
./target/release/two-face --help

# Test connection
./target/release/two-face --host 127.0.0.1 --port 8000

Check Binary

# File type
file target/release/two-face

# Dependencies (Linux)
ldd target/release/two-face

# Size
ls -lh target/release/two-face

See Also

Project Structure

A guided tour of the Two-Face codebase.

Directory Layout

two-face/
├── Cargo.toml           # Project manifest
├── Cargo.lock           # Dependency lock file
├── CLAUDE.md            # Development notes
├── README.md            # Project readme
│
├── src/
│   ├── main.rs          # Entry point
│   ├── lib.rs           # Library root (if applicable)
│   ├── config.rs        # Configuration loading
│   ├── parser.rs        # XML protocol parser
│   ├── network.rs       # Network connections
│   │
│   ├── core/            # Core business logic
│   │   ├── mod.rs
│   │   ├── app_core/    # Application state
│   │   │   ├── mod.rs
│   │   │   ├── state.rs
│   │   │   └── layout.rs
│   │   └── menu_actions.rs
│   │
│   ├── data/            # Data models
│   │   ├── mod.rs
│   │   └── widget.rs    # Widget data structures
│   │
│   └── frontend/        # User interface
│       └── tui/         # Terminal UI
│           ├── mod.rs
│           ├── input.rs
│           ├── input_handlers.rs
│           ├── command_input.rs
│           ├── text_window.rs
│           ├── tabbed_text_window.rs
│           ├── search.rs
│           ├── sync.rs
│           ├── menu_actions.rs
│           └── window_editor.rs
│
├── book/                # mdbook documentation
│   ├── book.toml
│   └── src/
│       └── *.md
│
├── tests/               # Integration tests
│   └── *.rs
│
└── docs/                # Additional documentation

Module Organization

Three-Layer Architecture

┌─────────────────────────────────────────────────────────────┐
│                    src/frontend/tui/                        │
│                                                             │
│    Terminal rendering, input handling, visual themes        │
│    Knows about: core, data                                  │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                        src/core/                            │
│                                                             │
│    Application state, business logic, event processing      │
│    Knows about: data                                        │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                        src/data/                            │
│                                                             │
│    Data models, parsing, serialization                      │
│    Knows about: nothing (foundation layer)                  │
└─────────────────────────────────────────────────────────────┘

Import rule: Upper layers can import lower layers, not vice versa.

Key Files

Entry Point: src/main.rs

// Typical structure
fn main() {
    // Parse command line arguments
    let args = Args::parse();

    // Load configuration
    let config = Config::load(&args)?;

    // Initialize application
    let app = App::new(config)?;

    // Run main loop
    app.run()?;
}

Responsibilities:

  • CLI argument parsing
  • Configuration loading
  • Application initialization
  • Main event loop

Configuration: src/config.rs

#![allow(unused)]
fn main() {
// Configuration structures
pub struct Config {
    pub connection: ConnectionConfig,
    pub layout: LayoutConfig,
    pub colors: ColorConfig,
    pub keybinds: KeybindConfig,
    // ...
}

impl Config {
    pub fn load(path: &Path) -> Result<Self> { ... }
    pub fn save(&self, path: &Path) -> Result<()> { ... }
}
}

Responsibilities:

  • TOML parsing
  • Default values
  • Validation
  • Serialization

Parser: src/parser.rs

#![allow(unused)]
fn main() {
// XML protocol parsing
pub enum ParsedElement {
    Text(String),
    RoomName(String),
    RoomDesc(String),
    Prompt(PromptData),
    Vitals(VitalsData),
    // Many more variants...
}

pub struct Parser {
    // Parser state
}

impl Parser {
    pub fn parse(&mut self, input: &str) -> Vec<ParsedElement> { ... }
}
}

Responsibilities:

  • XML tag recognition
  • State machine parsing
  • Element extraction
  • Stream identification

Network: src/network.rs

#![allow(unused)]
fn main() {
// Connection handling
pub enum ConnectionMode {
    Lich { host: String, port: u16 },
    Direct { account: String, ... },
}

pub struct Connection {
    mode: ConnectionMode,
    stream: TcpStream,
}

impl Connection {
    pub fn connect(mode: ConnectionMode) -> Result<Self> { ... }
    pub fn send(&mut self, data: &str) -> Result<()> { ... }
    pub fn receive(&mut self) -> Result<String> { ... }
}
}

Responsibilities:

  • Connection establishment
  • TLS handling (direct mode)
  • Data transmission
  • Reconnection logic

Widget Data: src/data/widget.rs

#![allow(unused)]
fn main() {
// Widget type definitions
pub enum WidgetType {
    Text,
    TabbedText,
    Progress,
    Compass,
    // ...
}

pub struct WidgetConfig {
    pub widget_type: WidgetType,
    pub name: String,
    pub x: u16,
    pub y: u16,
    pub width: u16,
    pub height: u16,
    // Type-specific config...
}
}

Responsibilities:

  • Widget type enumeration
  • Configuration structures
  • Layout data
  • Serialization traits

Application State: src/core/app_core/state.rs

#![allow(unused)]
fn main() {
// Application state management
pub struct AppState {
    pub vitals: Vitals,
    pub room: RoomInfo,
    pub inventory: Inventory,
    pub indicators: Indicators,
    // ...
    generation: u64,  // Change tracking
}

impl AppState {
    pub fn update(&mut self, element: ParsedElement) {
        // Update state based on parsed element
        self.generation += 1;
    }
}
}

Responsibilities:

  • Game state tracking
  • Update processing
  • Change notification
  • Generation counting

Layout: src/core/app_core/layout.rs

#![allow(unused)]
fn main() {
// Layout management
pub struct Layout {
    widgets: Vec<Widget>,
    focus_index: usize,
}

impl Layout {
    pub fn load(config: &LayoutConfig) -> Result<Self> { ... }
    pub fn render(&self, frame: &mut Frame) { ... }
    pub fn handle_input(&mut self, key: KeyEvent) { ... }
}
}

Responsibilities:

  • Widget arrangement
  • Focus management
  • Layout loading
  • Coordinate calculation

TUI Module: src/frontend/tui/mod.rs

#![allow(unused)]
fn main() {
// Terminal UI main module
pub struct Tui {
    terminal: Terminal<Backend>,
    layout: Layout,
    state: AppState,
}

impl Tui {
    pub fn new() -> Result<Self> { ... }
    pub fn run(&mut self) -> Result<()> { ... }
    pub fn render(&mut self) -> Result<()> { ... }
    pub fn handle_event(&mut self, event: Event) -> Result<()> { ... }
}
}

Responsibilities:

  • Terminal setup/teardown
  • Event loop
  • Rendering coordination
  • Input routing

Data Flow

Incoming Data

Network → Parser → State Update → Widget Sync → Render
   │         │           │              │           │
   │         │           │              │           └─ TUI draws frame
   │         │           │              └─ Widgets check generation
   │         │           └─ AppState updates, bumps generation
   │         └─ Raw XML → ParsedElements
   └─ TCP/TLS receives bytes

User Input

Terminal Event → TUI → Handler → Action → Effect
      │           │        │        │         │
      │           │        │        │         └─ State change or command send
      │           │        │        └─ Keybind lookup / macro expansion
      │           │        └─ Input type routing (key/mouse/resize)
      │           └─ Event loop captures
      └─ User presses key

Testing Structure

tests/
├── integration_test.rs   # Full application tests
├── parser_tests.rs       # Parser-specific tests
└── widget_tests.rs       # Widget rendering tests

src/
├── parser.rs
│   └── #[cfg(test)] mod tests { ... }  # Unit tests
└── config.rs
    └── #[cfg(test)] mod tests { ... }  # Unit tests

Configuration Files

User configuration (runtime):

~/.two-face/
├── config.toml
├── layout.toml
├── colors.toml
├── highlights.toml
├── keybinds.toml
├── triggers.toml
└── simu.pem

Documentation

book/                    # mdbook documentation (this!)
├── book.toml
└── src/
    ├── SUMMARY.md
    └── *.md

docs/                    # Additional docs
└── *.md

Build Artifacts

target/
├── debug/               # Debug build
│   └── two-face
├── release/             # Release build
│   └── two-face
└── doc/                 # Generated documentation
    └── two_face/

See Also

Adding Widgets

Guide to creating new widget types for Two-Face.

Overview

Widgets are the visual building blocks of Two-Face. Each widget type:

  • Displays specific data
  • Has its own rendering logic
  • Responds to state changes
  • May handle user input

Widget Architecture

Widget Lifecycle

Configuration → Creation → State Sync → Render → Input (optional)
     │             │            │          │           │
     │             │            │          │           └─ Handle focus/keys
     │             │            │          └─ Draw to terminal frame
     │             │            └─ Check generation, update data
     │             └─ Instantiate from config
     └─ TOML defines type and properties

Key Traits

Widgets implement several traits:

#![allow(unused)]
fn main() {
// Core widget behavior
pub trait Widget {
    fn render(&self, frame: &mut Frame, area: Rect);
    fn name(&self) -> &str;
    fn can_focus(&self) -> bool;
}

// State synchronization
pub trait Syncable {
    fn sync(&mut self, state: &AppState) -> bool;
    fn last_generation(&self) -> u64;
}

// Input handling (optional)
pub trait Interactive {
    fn handle_key(&mut self, key: KeyEvent) -> bool;
    fn handle_mouse(&mut self, mouse: MouseEvent) -> bool;
}
}

Step-by-Step: New Widget

Let’s create a “SpellTimer” widget that displays active spell durations.

Step 1: Define the Widget Type

Add to src/data/widget.rs:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum WidgetType {
    Text,
    Progress,
    Compass,
    // ... existing types ...
    SpellTimer,  // Add new type
}
}

Step 2: Create Widget Configuration

#![allow(unused)]
fn main() {
// In src/data/widget.rs or dedicated file

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpellTimerConfig {
    pub name: String,
    pub x: u16,
    pub y: u16,
    pub width: u16,
    pub height: u16,

    // Widget-specific options
    pub show_duration: bool,
    pub group_by_circle: bool,
    pub max_display: usize,
}

impl Default for SpellTimerConfig {
    fn default() -> Self {
        Self {
            name: "spell_timer".into(),
            x: 0,
            y: 0,
            width: 20,
            height: 10,
            show_duration: true,
            group_by_circle: false,
            max_display: 10,
        }
    }
}
}

Step 3: Create Widget Structure

Create src/frontend/tui/spell_timer.rs:

#![allow(unused)]
fn main() {
use crate::core::AppState;
use crate::data::SpellTimerConfig;
use ratatui::prelude::*;
use ratatui::widgets::*;

pub struct SpellTimerWidget {
    config: SpellTimerConfig,
    spells: Vec<ActiveSpell>,
    last_generation: u64,
}

#[derive(Clone)]
struct ActiveSpell {
    name: String,
    duration: u32,
    circle: u8,
}

impl SpellTimerWidget {
    pub fn new(config: SpellTimerConfig) -> Self {
        Self {
            config,
            spells: Vec::new(),
            last_generation: 0,
        }
    }
}
}

Step 4: Implement Widget Trait

#![allow(unused)]
fn main() {
impl Widget for SpellTimerWidget {
    fn render(&self, frame: &mut Frame, area: Rect) {
        // Create border
        let block = Block::default()
            .title("Spells")
            .borders(Borders::ALL);

        let inner = block.inner(area);
        frame.render_widget(block, area);

        // Render spell list
        let items: Vec<ListItem> = self.spells
            .iter()
            .take(self.config.max_display)
            .map(|spell| {
                let text = if self.config.show_duration {
                    format!("{}: {}s", spell.name, spell.duration)
                } else {
                    spell.name.clone()
                };
                ListItem::new(text)
            })
            .collect();

        let list = List::new(items);
        frame.render_widget(list, inner);
    }

    fn name(&self) -> &str {
        &self.config.name
    }

    fn can_focus(&self) -> bool {
        false  // This widget doesn't need focus
    }
}
}

Step 5: Implement Syncable Trait

#![allow(unused)]
fn main() {
impl Syncable for SpellTimerWidget {
    fn sync(&mut self, state: &AppState) -> bool {
        // Check if state has changed
        if state.generation() == self.last_generation {
            return false;  // No changes
        }

        // Update spell list from state
        self.spells = state.active_spells()
            .iter()
            .map(|s| ActiveSpell {
                name: s.name.clone(),
                duration: s.remaining_seconds,
                circle: s.circle,
            })
            .collect();

        // Apply grouping if configured
        if self.config.group_by_circle {
            self.spells.sort_by_key(|s| s.circle);
        }

        self.last_generation = state.generation();
        true  // Widget needs redraw
    }

    fn last_generation(&self) -> u64 {
        self.last_generation
    }
}
}

Step 6: Register Widget Type

In the widget factory (typically src/frontend/tui/mod.rs or dedicated factory):

#![allow(unused)]
fn main() {
pub fn create_widget(config: &WidgetConfig) -> Box<dyn Widget> {
    match config.widget_type {
        WidgetType::Text => Box::new(TextWidget::new(config.into())),
        WidgetType::Progress => Box::new(ProgressWidget::new(config.into())),
        // ... existing types ...
        WidgetType::SpellTimer => Box::new(SpellTimerWidget::new(config.into())),
    }
}
}

Step 7: Add to Module

In src/frontend/tui/mod.rs:

#![allow(unused)]
fn main() {
mod spell_timer;
pub use spell_timer::SpellTimerWidget;
}

Step 8: Document Configuration

Update TOML documentation:

# Example spell timer configuration
[[widgets]]
type = "spell_timer"
name = "spells"
x = 80
y = 0
width = 20
height = 15
show_duration = true
group_by_circle = true
max_display = 10

Widget Best Practices

State Management

#![allow(unused)]
fn main() {
// GOOD: Check generation before updating
fn sync(&mut self, state: &AppState) -> bool {
    if state.generation() == self.last_generation {
        return false;
    }
    // ... update ...
    self.last_generation = state.generation();
    true
}

// BAD: Always update (wasteful)
fn sync(&mut self, state: &AppState) -> bool {
    // ... update ...
    true  // Always redraws
}
}

Efficient Rendering

#![allow(unused)]
fn main() {
// GOOD: Cache computed values
struct MyWidget {
    cached_lines: Vec<Line<'static>>,
    // ...
}

fn sync(&mut self, state: &AppState) -> bool {
    // Rebuild cache only when data changes
    self.cached_lines = self.compute_lines(state);
    true
}

fn render(&self, frame: &mut Frame, area: Rect) {
    // Use cached data
    let paragraph = Paragraph::new(self.cached_lines.clone());
    frame.render_widget(paragraph, area);
}
}

Configuration Validation

#![allow(unused)]
fn main() {
impl SpellTimerConfig {
    pub fn validate(&self) -> Result<(), String> {
        if self.width == 0 || self.height == 0 {
            return Err("Widget must have non-zero dimensions".into());
        }
        if self.max_display == 0 {
            return Err("max_display must be at least 1".into());
        }
        Ok(())
    }
}
}

Error Handling

#![allow(unused)]
fn main() {
fn render(&self, frame: &mut Frame, area: Rect) {
    // Handle edge cases gracefully
    if area.width < 3 || area.height < 3 {
        // Area too small to render
        return;
    }

    if self.spells.is_empty() {
        // Show placeholder
        let text = Paragraph::new("No active spells");
        frame.render_widget(text, area);
        return;
    }

    // Normal rendering...
}
}

Testing Widgets

Unit Tests

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_widget_creation() {
        let config = SpellTimerConfig::default();
        let widget = SpellTimerWidget::new(config);
        assert_eq!(widget.name(), "spell_timer");
    }

    #[test]
    fn test_sync_updates_generation() {
        let mut widget = SpellTimerWidget::new(Default::default());
        let mut state = AppState::new();

        state.set_generation(1);
        assert!(widget.sync(&state));
        assert_eq!(widget.last_generation(), 1);

        // Same generation shouldn't trigger update
        assert!(!widget.sync(&state));
    }
}
}

Visual Testing

Create a test layout that isolates your widget:

# test_layout.toml
[[widgets]]
type = "spell_timer"
name = "test"
x = 0
y = 0
width = 100
height = 100

See Also

Adding Browsers

Guide to creating new browser (popup window) types for Two-Face.

Overview

Browsers are popup windows that provide extended functionality:

  • Configuration editors
  • Search interfaces
  • Help dialogs
  • Selection menus

They overlay the main interface temporarily and capture input focus.

Browser Architecture

Browser Lifecycle

Trigger → Creation → Render → Input Loop → Result → Cleanup
   │          │         │          │          │         │
   │          │         │          │          │         └─ Restore main UI
   │          │         │          │          └─ Return data or action
   │          │         │          └─ Handle keys until dismiss
   │          │         └─ Draw overlay on screen
   │          └─ Instantiate browser with context
   └─ Keybind or menu action

Key Components

#![allow(unused)]
fn main() {
// Browser trait defines popup behavior
pub trait Browser {
    fn render(&self, frame: &mut Frame, area: Rect);
    fn handle_input(&mut self, key: KeyEvent) -> BrowserResult;
    fn title(&self) -> &str;
}

// Result of browser interaction
pub enum BrowserResult {
    Continue,           // Keep browser open
    Close,              // Close without action
    Action(BrowserAction),  // Close with action
}

pub enum BrowserAction {
    SelectItem(String),
    UpdateConfig(ConfigChange),
    ExecuteCommand(String),
    // ...
}
}

Step-by-Step: New Browser

Let’s create a “HighlightEditor” browser for editing highlight patterns.

Step 1: Define Browser Structure

Create src/frontend/tui/highlight_editor.rs:

#![allow(unused)]
fn main() {
use ratatui::prelude::*;
use ratatui::widgets::*;

pub struct HighlightEditor {
    highlights: Vec<HighlightEntry>,
    selected: usize,
    editing: Option<EditField>,
    scroll_offset: usize,
}

struct HighlightEntry {
    pattern: String,
    foreground: String,
    background: Option<String>,
    enabled: bool,
}

enum EditField {
    Pattern,
    Foreground,
    Background,
}

impl HighlightEditor {
    pub fn new(highlights: Vec<HighlightConfig>) -> Self {
        let entries = highlights.into_iter()
            .map(|h| HighlightEntry {
                pattern: h.pattern,
                foreground: h.fg,
                background: h.bg,
                enabled: h.enabled,
            })
            .collect();

        Self {
            highlights: entries,
            selected: 0,
            editing: None,
            scroll_offset: 0,
        }
    }
}
}

Step 2: Implement Browser Trait

#![allow(unused)]
fn main() {
impl Browser for HighlightEditor {
    fn title(&self) -> &str {
        "Highlight Editor"
    }

    fn render(&self, frame: &mut Frame, area: Rect) {
        // Calculate centered popup area
        let popup_area = centered_rect(80, 80, area);

        // Clear background
        frame.render_widget(Clear, popup_area);

        // Draw border
        let block = Block::default()
            .title(self.title())
            .borders(Borders::ALL)
            .border_style(Style::default().fg(Color::Cyan));

        let inner = block.inner(popup_area);
        frame.render_widget(block, popup_area);

        // Split into list and detail areas
        let chunks = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([
                Constraint::Percentage(50),
                Constraint::Percentage(50),
            ])
            .split(inner);

        // Render highlight list
        self.render_list(frame, chunks[0]);

        // Render selected highlight details
        self.render_details(frame, chunks[1]);

        // Render help bar at bottom
        self.render_help(frame, popup_area);
    }

    fn handle_input(&mut self, key: KeyEvent) -> BrowserResult {
        match key.code {
            KeyCode::Esc => {
                if self.editing.is_some() {
                    self.editing = None;
                    BrowserResult::Continue
                } else {
                    BrowserResult::Close
                }
            }
            KeyCode::Enter => {
                if self.editing.is_some() {
                    self.commit_edit();
                    self.editing = None;
                } else {
                    self.start_edit();
                }
                BrowserResult::Continue
            }
            KeyCode::Up => {
                self.move_selection(-1);
                BrowserResult::Continue
            }
            KeyCode::Down => {
                self.move_selection(1);
                BrowserResult::Continue
            }
            KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                BrowserResult::Action(BrowserAction::UpdateConfig(
                    self.build_config_change()
                ))
            }
            KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.add_new_highlight();
                BrowserResult::Continue
            }
            KeyCode::Delete => {
                self.delete_selected();
                BrowserResult::Continue
            }
            _ => {
                if self.editing.is_some() {
                    self.handle_edit_key(key);
                }
                BrowserResult::Continue
            }
        }
    }
}
}

Step 3: Implement Helper Methods

#![allow(unused)]
fn main() {
impl HighlightEditor {
    fn render_list(&self, frame: &mut Frame, area: Rect) {
        let items: Vec<ListItem> = self.highlights
            .iter()
            .enumerate()
            .skip(self.scroll_offset)
            .map(|(i, h)| {
                let style = if i == self.selected {
                    Style::default().bg(Color::DarkGray)
                } else {
                    Style::default()
                };

                let enabled = if h.enabled { "✓" } else { " " };
                let text = format!("[{}] {}", enabled, h.pattern);
                ListItem::new(text).style(style)
            })
            .collect();

        let list = List::new(items)
            .block(Block::default().title("Patterns").borders(Borders::ALL));

        frame.render_widget(list, area);
    }

    fn render_details(&self, frame: &mut Frame, area: Rect) {
        if let Some(highlight) = self.highlights.get(self.selected) {
            let details = vec![
                Line::from(vec![
                    Span::raw("Pattern: "),
                    Span::styled(&highlight.pattern, Style::default().fg(Color::Yellow)),
                ]),
                Line::from(vec![
                    Span::raw("FG: "),
                    Span::styled(&highlight.foreground, Style::default().fg(Color::Green)),
                ]),
                Line::from(vec![
                    Span::raw("BG: "),
                    Span::raw(highlight.background.as_deref().unwrap_or("none")),
                ]),
            ];

            let paragraph = Paragraph::new(details)
                .block(Block::default().title("Details").borders(Borders::ALL));

            frame.render_widget(paragraph, area);
        }
    }

    fn render_help(&self, frame: &mut Frame, area: Rect) {
        let help = " Enter: Edit | Ctrl+S: Save | Ctrl+N: New | Del: Delete | Esc: Close ";
        let help_area = Rect {
            x: area.x,
            y: area.y + area.height - 1,
            width: area.width,
            height: 1,
        };
        let help_text = Paragraph::new(help)
            .style(Style::default().bg(Color::DarkGray));
        frame.render_widget(help_text, help_area);
    }

    fn move_selection(&mut self, delta: i32) {
        let new_idx = (self.selected as i32 + delta)
            .max(0)
            .min(self.highlights.len() as i32 - 1) as usize;
        self.selected = new_idx;
    }

    fn start_edit(&mut self) {
        self.editing = Some(EditField::Pattern);
    }

    fn commit_edit(&mut self) {
        // Apply edit changes
    }

    fn add_new_highlight(&mut self) {
        self.highlights.push(HighlightEntry {
            pattern: "new_pattern".into(),
            foreground: "white".into(),
            background: None,
            enabled: true,
        });
        self.selected = self.highlights.len() - 1;
    }

    fn delete_selected(&mut self) {
        if !self.highlights.is_empty() {
            self.highlights.remove(self.selected);
            if self.selected >= self.highlights.len() && self.selected > 0 {
                self.selected -= 1;
            }
        }
    }

    fn build_config_change(&self) -> ConfigChange {
        // Convert entries back to config format
        ConfigChange::Highlights(
            self.highlights.iter()
                .map(|h| HighlightConfig {
                    pattern: h.pattern.clone(),
                    fg: h.foreground.clone(),
                    bg: h.background.clone(),
                    enabled: h.enabled,
                })
                .collect()
        )
    }
}

// Helper function for centered popups
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
    let popup_layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Percentage((100 - percent_y) / 2),
            Constraint::Percentage(percent_y),
            Constraint::Percentage((100 - percent_y) / 2),
        ])
        .split(area);

    Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage((100 - percent_x) / 2),
            Constraint::Percentage(percent_x),
            Constraint::Percentage((100 - percent_x) / 2),
        ])
        .split(popup_layout[1])[1]
}
}

Step 4: Register Browser

Add to browser registry:

#![allow(unused)]
fn main() {
pub enum BrowserType {
    Search,
    Help,
    LayoutEditor,
    HighlightEditor,  // New browser
}

pub fn open_browser(browser_type: BrowserType, context: BrowserContext) -> Box<dyn Browser> {
    match browser_type {
        BrowserType::Search => Box::new(SearchBrowser::new(context)),
        BrowserType::Help => Box::new(HelpBrowser::new()),
        BrowserType::LayoutEditor => Box::new(LayoutEditor::new(context)),
        BrowserType::HighlightEditor => Box::new(HighlightEditor::new(context.highlights)),
    }
}
}

Step 5: Add Keybind

Allow users to open the browser:

#![allow(unused)]
fn main() {
// In keybind action handling
KeybindAction::OpenHighlightEditor => {
    self.active_browser = Some(open_browser(
        BrowserType::HighlightEditor,
        self.build_browser_context()
    ));
}
}

Browser Best Practices

Input Handling

#![allow(unused)]
fn main() {
// Always allow Escape to close
fn handle_input(&mut self, key: KeyEvent) -> BrowserResult {
    match key.code {
        KeyCode::Esc => BrowserResult::Close,
        // ... other handling
    }
}

// Provide keyboard help
fn render_help(&self) {
    // Always show available key commands
}
}

State Management

#![allow(unused)]
fn main() {
// Keep original data for cancel
struct MyBrowser {
    original: Vec<Item>,  // For reverting
    modified: Vec<Item>,  // Working copy
}

fn handle_input(&mut self, key: KeyEvent) -> BrowserResult {
    match key.code {
        KeyCode::Esc => {
            // Discard changes
            BrowserResult::Close
        }
        KeyCode::Char('s') if ctrl => {
            // Save changes
            BrowserResult::Action(self.build_action())
        }
    }
}
}

Visual Feedback

#![allow(unused)]
fn main() {
// Indicate edit mode
fn render(&self, frame: &mut Frame, area: Rect) {
    let title = if self.editing.is_some() {
        format!("{} [EDITING]", self.title())
    } else {
        self.title().to_string()
    };
    // ...
}

// Highlight modified fields
fn render_field(&self, value: &str, modified: bool) -> Span {
    let style = if modified {
        Style::default().fg(Color::Yellow)  // Modified indicator
    } else {
        Style::default()
    };
    Span::styled(value, style)
}
}

Testing Browsers

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_browser_escape_closes() {
        let mut browser = HighlightEditor::new(vec![]);
        let result = browser.handle_input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
        assert!(matches!(result, BrowserResult::Close));
    }

    #[test]
    fn test_selection_movement() {
        let mut browser = HighlightEditor::new(vec![
            HighlightConfig::default(),
            HighlightConfig::default(),
        ]);
        assert_eq!(browser.selected, 0);

        browser.handle_input(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
        assert_eq!(browser.selected, 1);

        browser.handle_input(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
        assert_eq!(browser.selected, 0);
    }
}
}

See Also

Parser Extensions

Guide to extending the XML protocol parser for new game elements.

Overview

The parser converts raw game XML into structured data that widgets can display. Extending the parser allows Two-Face to:

  • Recognize new game elements
  • Extract additional data
  • Support new game features
  • Handle protocol changes

Parser Architecture

Data Flow

Raw Input → Tokenizer → Parser State Machine → ParsedElements
    │           │              │                    │
    │           │              │                    └─ Vec<ParsedElement>
    │           │              └─ Match patterns, build elements
    │           └─ Split into tags/text
    └─ Bytes from network

Key Types

#![allow(unused)]
fn main() {
// Parser output - each element represents parsed game data
pub enum ParsedElement {
    Text(String),
    RoomName(String),
    RoomDesc(String),
    Prompt(PromptData),
    Vitals(VitalsData),
    Indicator(IndicatorData),
    Compass(CompassData),
    Stream { id: String, content: String },
    // ... many more
}

// Parser state tracks context
pub struct Parser {
    state: ParserState,
    buffer: String,
    current_stream: Option<String>,
    // ...
}

enum ParserState {
    Normal,
    InTag(String),
    InStream(String),
    // ...
}
}

Step-by-Step: New Element

Let’s add parsing for a hypothetical “spell_active” element.

Step 1: Analyze the Protocol

First, understand the XML format:

<!-- Example game XML -->
<spell_active id="107" name="Spirit Shield" duration="300" circle="1"/>
<spell_expire id="107"/>

Document what data we need:

  • Spell ID
  • Spell name
  • Duration (seconds)
  • Circle

Step 2: Define the ParsedElement Variant

Add to src/parser.rs:

#![allow(unused)]
fn main() {
pub enum ParsedElement {
    // ... existing variants ...

    /// Active spell notification
    SpellActive {
        id: u32,
        name: String,
        duration: u32,
        circle: u8,
    },

    /// Spell expiration notification
    SpellExpire {
        id: u32,
    },
}
}

Step 3: Create Data Structures

#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
pub struct SpellActiveData {
    pub id: u32,
    pub name: String,
    pub duration: u32,
    pub circle: u8,
}

impl SpellActiveData {
    pub fn from_attributes(attrs: &HashMap<String, String>) -> Option<Self> {
        Some(Self {
            id: attrs.get("id")?.parse().ok()?,
            name: attrs.get("name")?.clone(),
            duration: attrs.get("duration")?.parse().ok()?,
            circle: attrs.get("circle")?.parse().ok()?,
        })
    }
}
}

Step 4: Add Parsing Logic

In the parser’s tag handling:

#![allow(unused)]
fn main() {
impl Parser {
    fn handle_tag(&mut self, tag: &str) -> Option<ParsedElement> {
        let (name, attrs) = self.parse_tag(tag)?;

        match name.as_str() {
            // ... existing tags ...

            "spell_active" => {
                let data = SpellActiveData::from_attributes(&attrs)?;
                Some(ParsedElement::SpellActive {
                    id: data.id,
                    name: data.name,
                    duration: data.duration,
                    circle: data.circle,
                })
            }

            "spell_expire" => {
                let id = attrs.get("id")?.parse().ok()?;
                Some(ParsedElement::SpellExpire { id })
            }

            _ => None,
        }
    }
}
}

Step 5: Handle in Application State

In src/core/app_core/state.rs:

#![allow(unused)]
fn main() {
impl AppState {
    pub fn process_element(&mut self, element: ParsedElement) {
        match element {
            // ... existing handlers ...

            ParsedElement::SpellActive { id, name, duration, circle } => {
                self.active_spells.insert(id, ActiveSpell {
                    id,
                    name,
                    remaining: duration,
                    circle,
                });
                self.generation += 1;
            }

            ParsedElement::SpellExpire { id } => {
                self.active_spells.remove(&id);
                self.generation += 1;
            }

            // ...
        }
    }
}
}

Step 6: Add Tests

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_spell_active() {
        let mut parser = Parser::new();
        let input = r#"<spell_active id="107" name="Spirit Shield" duration="300" circle="1"/>"#;

        let elements = parser.parse(input);

        assert_eq!(elements.len(), 1);
        match &elements[0] {
            ParsedElement::SpellActive { id, name, duration, circle } => {
                assert_eq!(*id, 107);
                assert_eq!(name, "Spirit Shield");
                assert_eq!(*duration, 300);
                assert_eq!(*circle, 1);
            }
            _ => panic!("Expected SpellActive"),
        }
    }

    #[test]
    fn test_parse_spell_expire() {
        let mut parser = Parser::new();
        let input = r#"<spell_expire id="107"/>"#;

        let elements = parser.parse(input);

        assert_eq!(elements.len(), 1);
        match &elements[0] {
            ParsedElement::SpellExpire { id } => {
                assert_eq!(*id, 107);
            }
            _ => panic!("Expected SpellExpire"),
        }
    }

    #[test]
    fn test_missing_attributes() {
        let mut parser = Parser::new();
        // Missing required 'name' attribute
        let input = r#"<spell_active id="107" duration="300"/>"#;

        let elements = parser.parse(input);

        // Should not produce SpellActive (missing name)
        assert!(elements.iter().all(|e| !matches!(e, ParsedElement::SpellActive { .. })));
    }
}
}

Common Patterns

Self-Closing Tags

#![allow(unused)]
fn main() {
// <tag attr="value"/>
fn parse_self_closing(&mut self, tag: &str) -> Option<ParsedElement> {
    if !tag.ends_with('/') {
        return None;
    }
    let clean = tag.trim_end_matches('/');
    self.handle_tag(clean)
}
}

Paired Tags with Content

#![allow(unused)]
fn main() {
// <tag>content</tag>
fn handle_open_tag(&mut self, name: &str, attrs: &HashMap<String, String>) {
    match name {
        "room_desc" => {
            self.state = ParserState::InRoomDesc;
            self.buffer.clear();
        }
        // ...
    }
}

fn handle_close_tag(&mut self, name: &str) -> Option<ParsedElement> {
    match name {
        "room_desc" => {
            let desc = std::mem::take(&mut self.buffer);
            self.state = ParserState::Normal;
            Some(ParsedElement::RoomDesc(desc))
        }
        // ...
    }
}
}

Streaming Content

#![allow(unused)]
fn main() {
// <pushStream id="combat"/>...<popStream/>
fn handle_push_stream(&mut self, id: &str) {
    self.stream_stack.push(id.to_string());
    self.current_stream = Some(id.to_string());
}

fn handle_pop_stream(&mut self) {
    self.stream_stack.pop();
    self.current_stream = self.stream_stack.last().cloned();
}
}

Attribute Extraction

#![allow(unused)]
fn main() {
fn parse_attributes(tag_content: &str) -> HashMap<String, String> {
    let mut attrs = HashMap::new();
    let re = regex::Regex::new(r#"(\w+)="([^"]*)""#).unwrap();

    for cap in re.captures_iter(tag_content) {
        attrs.insert(cap[1].to_string(), cap[2].to_string());
    }

    attrs
}
}

Best Practices

Graceful Degradation

#![allow(unused)]
fn main() {
// GOOD: Handle missing attributes
fn from_attributes(attrs: &HashMap<String, String>) -> Option<Self> {
    Some(Self {
        required: attrs.get("required")?.clone(),
        optional: attrs.get("optional").cloned(),  // Optional field
    })
}

// BAD: Panic on missing data
fn from_attributes(attrs: &HashMap<String, String>) -> Self {
    Self {
        required: attrs["required"].clone(),  // Panics if missing!
    }
}
}

Preserve Unknown Elements

#![allow(unused)]
fn main() {
// Don't discard unrecognized tags
fn handle_unknown_tag(&mut self, tag: &str) -> ParsedElement {
    ParsedElement::Unknown {
        tag: tag.to_string(),
        raw: self.current_raw.clone(),
    }
}
}

Performance

#![allow(unused)]
fn main() {
// GOOD: Compile regex once
lazy_static! {
    static ref ATTR_REGEX: Regex = Regex::new(r#"(\w+)="([^"]*)""#).unwrap();
}

// BAD: Compile regex every time
fn parse_attrs(s: &str) {
    let re = Regex::new(r#"(\w+)="([^"]*)""#).unwrap();  // Slow!
}
}

Debugging Parser Issues

Logging

#![allow(unused)]
fn main() {
fn handle_tag(&mut self, tag: &str) -> Option<ParsedElement> {
    log::trace!("Parsing tag: {}", tag);

    let result = self.parse_tag_internal(tag);

    if result.is_none() {
        log::debug!("Unhandled tag: {}", tag);
    }

    result
}
}

Test with Real Data

Save actual game output for test fixtures:

#![allow(unused)]
fn main() {
#[test]
fn test_real_game_output() {
    let input = include_str!("../test_fixtures/combat_sample.xml");
    let mut parser = Parser::new();
    let elements = parser.parse(input);

    // Verify expected elements are present
    assert!(elements.iter().any(|e| matches!(e, ParsedElement::Combat { .. })));
}
}

See Also

Testing

Test patterns and practices for Two-Face development.

Current Test Status

Two-Face has comprehensive test coverage:

Test TypeCountDescription
Unit Tests907In-module tests (#[cfg(test)])
Parser Integration34XML parsing verification
UI Integration57End-to-end UI state tests
Doc Tests4Documentation examples
Ignored1Clipboard (requires system)
Total1,003All tests pass ✅

Testing Philosophy

Two-Face testing prioritizes:

  1. Parser accuracy - Correctly parse game protocol
  2. State consistency - Updates propagate correctly
  3. Widget rendering - Display matches data
  4. Configuration loading - Files parse without errors
  5. Real-world validation - Tests use actual game XML
  6. End-to-end flow - XML → Parser → MessageProcessor → UiState

Test Organization

two-face/
├── src/
│   ├── parser.rs
│   │   └── #[cfg(test)] mod tests { ... }
│   ├── config.rs
│   │   └── #[cfg(test)] mod tests { ... }
│   ├── selection.rs
│   │   └── #[cfg(test)] mod tests { ... }
│   ├── clipboard.rs
│   │   └── #[cfg(test)] mod tests { ... }
│   └── ...
│
├── tests/                     # Integration tests
│   ├── parser_integration.rs  # 34 parser-level tests
│   ├── ui_integration.rs      # 57 end-to-end UI tests
│   └── fixtures/              # 45 real game XML fixtures
│       ├── session_start.xml
│       ├── vitals_indicators.xml
│       ├── room_navigation.xml
│       ├── combat_roundtime.xml
│       ├── buffs_progress.xml
│       ├── active_effects.xml
│       ├── injuries.xml
│       ├── text_routing.xml
│       └── ... (45 total fixtures)
│
└── Cargo.toml

Unit Tests

Basic Structure

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_function_name() {
        // Arrange
        let input = "test data";

        // Act
        let result = function_under_test(input);

        // Assert
        assert_eq!(result, expected_value);
    }
}
}

Parser Tests

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_room_name() {
        let mut parser = Parser::new();
        let input = "<roomName>Town Square</roomName>";

        let elements = parser.parse(input);

        assert_eq!(elements.len(), 1);
        assert!(matches!(
            &elements[0],
            ParsedElement::RoomName(name) if name == "Town Square"
        ));
    }

    #[test]
    fn test_parse_vitals() {
        let mut parser = Parser::new();
        let input = r#"<progressBar id="health" value="75"/>"#;

        let elements = parser.parse(input);

        assert_eq!(elements.len(), 1);
        if let ParsedElement::Vitals(data) = &elements[0] {
            assert_eq!(data.health, Some(75));
        } else {
            panic!("Expected Vitals element");
        }
    }

    #[test]
    fn test_parse_malformed_input() {
        let mut parser = Parser::new();
        let input = "<unclosed tag without closing";

        // Should not panic, may return empty or partial results
        let elements = parser.parse(input);
        // Parser should handle gracefully
    }

    #[test]
    fn test_parse_empty_input() {
        let mut parser = Parser::new();
        let elements = parser.parse("");
        assert!(elements.is_empty());
    }
}
}

Configuration Tests

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_default_config() {
        let config = Config::default();

        assert_eq!(config.connection.port, 8000);
        assert!(config.connection.auto_reconnect);
    }

    #[test]
    fn test_load_valid_config() {
        let toml = r#"
            [connection]
            host = "127.0.0.1"
            port = 8001

            [[widgets]]
            type = "text"
            name = "main"
            x = 0
            y = 0
            width = 100
            height = 100
        "#;

        let config: Config = toml::from_str(toml).unwrap();

        assert_eq!(config.connection.port, 8001);
        assert_eq!(config.widgets.len(), 1);
    }

    #[test]
    fn test_invalid_config() {
        let toml = "invalid toml [[[";

        let result: Result<Config, _> = toml::from_str(toml);
        assert!(result.is_err());
    }

    #[test]
    fn test_config_validation() {
        let mut config = Config::default();
        config.widgets.push(WidgetConfig {
            width: 0,  // Invalid
            height: 0, // Invalid
            ..Default::default()
        });

        let result = config.validate();
        assert!(result.is_err());
    }
}
}

Widget Tests

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_widget_creation() {
        let config = TextWidgetConfig {
            name: "test".into(),
            width: 80,
            height: 24,
            ..Default::default()
        };

        let widget = TextWidget::new(config);

        assert_eq!(widget.name(), "test");
        assert!(widget.can_focus());
    }

    #[test]
    fn test_widget_sync() {
        let mut widget = TextWidget::new(Default::default());
        let mut state = AppState::new();

        // Initial sync
        state.set_generation(1);
        assert!(widget.sync(&state));
        assert_eq!(widget.last_generation(), 1);

        // No change
        assert!(!widget.sync(&state));

        // After state change
        state.add_text("Hello");
        state.set_generation(2);
        assert!(widget.sync(&state));
        assert_eq!(widget.last_generation(), 2);
    }

    #[test]
    fn test_scrollback_limit() {
        let config = TextWidgetConfig {
            scrollback: 100,
            ..Default::default()
        };
        let mut widget = TextWidget::new(config);

        // Add more lines than scrollback limit
        for i in 0..200 {
            widget.add_line(format!("Line {}", i));
        }

        assert!(widget.line_count() <= 100);
    }
}
}

Integration Tests

Two-Face has 91 integration tests across two test files using 45 real game XML fixtures.

Parser Integration (parser_integration.rs - 34 tests)

Tests XML parsing at the parser level:

CategoryTestsDescription
Session Start7Mode, player ID, stream windows
Vitals & Indicators5Health, mana, hands, status
Room Navigation6Compass, exits, room components
Combat5Roundtime, casttime, combat flow
Edge Cases6Empty input, malformed XML, unicode
Performance2Parsing speed benchmarks
Parser State3Stream tracking, state reusability

UI Integration (ui_integration.rs - 57 tests)

End-to-end tests: XML → Parser → MessageProcessor → UiState:

CategoryTestsDescription
Progress Bars12Vitals, stance, mindstate, custom IDs
Countdowns4Roundtime, casttime timers
Active Effects8Buffs, debuffs, cooldowns, spells
Indicators6Status indicators, dashboard icons
Room & Navigation5Room components, compass, subtitle
Text Routing8Stream routing, tabbed windows
Hands & Inventory6Left/right hands, spell hand, inventory
Entity Counts4Player count, target count
Edge Cases4Clear events, unknown streams

Test Fixtures (45 files)

Located in tests/fixtures/:

CategoryFixturesContent
Sessionsession_start.xml, player_counts.xmlLogin, player data
Vitalsvitals_indicators.xml, progress_*.xmlHealth, mana, custom bars
Combatcombat_*.xml, roundtime_*.xmlCombat, RT/CT, targets
Effectsactive_effects*.xml, buffs_*.xmlBuffs, debuffs, cooldowns
Indicatorsindicators_*.xml, icon_*.xmlStatus indicators
Roomroom_*.xml, text_routing*.xmlRoom data, stream routing
Injuriesinjuries*.xmlBody part injuries
Hands*_hand*.xmlEquipment in hands
Miscspells_stream.xml, tabbed_*.xmlSpells, tabbed windows

Example Integration Test

#![allow(unused)]
fn main() {
use two_face::parser::{ParsedElement, XmlParser};

/// Helper to parse XML and collect all elements
fn parse_xml(xml: &str) -> Vec<ParsedElement> {
    let mut parser = XmlParser::new();
    let mut all_elements = Vec::new();
    for line in xml.lines() {
        all_elements.extend(parser.parse_line(line));
    }
    all_elements
}

#[test]
fn test_extracts_compass_directions() {
    let xml = include_str!("fixtures/room_navigation.xml");
    let elements = parse_xml(xml);

    let compass = elements.iter().find(|e| {
        matches!(e, ParsedElement::Compass { .. })
    });

    assert!(compass.is_some(), "Should find compass element");

    if let Some(ParsedElement::Compass { directions }) = compass {
        assert!(directions.contains(&"n".to_string()));
        assert!(directions.contains(&"s".to_string()));
    }
}
}

Creating New Fixtures

Capture real game output for testing:

# Game logs are stored by Lich at:
# ~/.lich5/logs/GSIV-CharName/YYYY/MM/

# Copy relevant XML sections to test fixtures:
cp ~/.lich5/logs/GSIV-Nisugi/2025/10/session.xml tests/fixtures/

Using Fixtures

#![allow(unused)]
fn main() {
const COMBAT_FIXTURE: &str = include_str!("fixtures/combat_roundtime.xml");

#[test]
fn test_combat_parsing() {
    let elements = parse_xml(COMBAT_FIXTURE);

    // Find roundtime elements
    let roundtime = elements.iter().find(|e| {
        matches!(e, ParsedElement::RoundTime { .. })
    });

    assert!(roundtime.is_some());
}
}

Running Tests

All Tests

cargo test

Specific Test

cargo test test_parse_room_name

Specific Module

cargo test parser::tests

With Output

cargo test -- --nocapture

Verbose

cargo test -- --show-output

Test Patterns

Table-Driven Tests

#![allow(unused)]
fn main() {
#[test]
fn test_parse_indicators() {
    let cases = vec![
        ("<indicator id='IconHIDDEN' visible='y'/>", "hidden", true),
        ("<indicator id='IconHIDDEN' visible='n'/>", "hidden", false),
        ("<indicator id='IconSTUNNED' visible='y'/>", "stunned", true),
        ("<indicator id='IconPRONE' visible='y'/>", "prone", true),
    ];

    for (input, expected_name, expected_visible) in cases {
        let mut parser = Parser::new();
        let elements = parser.parse(input);

        if let ParsedElement::Indicator { name, visible } = &elements[0] {
            assert_eq!(name, expected_name, "Failed for input: {}", input);
            assert_eq!(*visible, expected_visible, "Failed for input: {}", input);
        } else {
            panic!("Expected Indicator for input: {}", input);
        }
    }
}
}

Property-Based Testing

#![allow(unused)]
fn main() {
use proptest::prelude::*;

proptest! {
    #[test]
    fn test_scrollback_never_exceeds_limit(lines in 1..1000usize, limit in 1..500usize) {
        let mut widget = TextWidget::new(TextWidgetConfig {
            scrollback: limit,
            ..Default::default()
        });

        for i in 0..lines {
            widget.add_line(format!("Line {}", i));
        }

        prop_assert!(widget.line_count() <= limit);
    }
}
}

Snapshot Testing

#![allow(unused)]
fn main() {
#[test]
fn test_render_snapshot() {
    let widget = ProgressWidget::new(Default::default());
    let mut state = AppState::new();
    state.vitals_mut().health = 75;

    widget.sync(&state);

    // Render to string buffer
    let rendered = widget.render_to_string();

    // Compare with stored snapshot
    insta::assert_snapshot!(rendered);
}
}

Mocking

Mock Network

#![allow(unused)]
fn main() {
struct MockConnection {
    responses: Vec<String>,
    index: usize,
}

impl MockConnection {
    fn new(responses: Vec<String>) -> Self {
        Self { responses, index: 0 }
    }
}

impl Connection for MockConnection {
    fn receive(&mut self) -> Result<String> {
        if self.index < self.responses.len() {
            let response = self.responses[self.index].clone();
            self.index += 1;
            Ok(response)
        } else {
            Err(Error::EndOfStream)
        }
    }
}

#[test]
fn test_with_mock_connection() {
    let mock = MockConnection::new(vec![
        "<roomName>Test Room</roomName>".into(),
    ]);

    let mut app = App::with_connection(mock);
    app.tick();

    assert_eq!(app.state().room_name(), Some("Test Room"));
}
}

Continuous Integration

GitHub Actions

name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: dtolnay/rust-action@stable
      - run: cargo test --all-features

Coverage

# Install tarpaulin
cargo install cargo-tarpaulin

# Run coverage
cargo tarpaulin --out Html

See Also

Contributing Guide

How to contribute to Two-Face development.

Welcome!

Thank you for considering contributing to Two-Face! This document provides guidelines and information for contributors.

Ways to Contribute

Code Contributions

  • Bug fixes
  • New features
  • Performance improvements
  • Widget implementations
  • Parser extensions

Non-Code Contributions

  • Documentation improvements
  • Bug reports
  • Feature suggestions
  • User support
  • Testing and feedback

Getting Started

1. Fork and Clone

# Fork on GitHub, then clone your fork
git clone https://github.com/YOUR_USERNAME/two-face.git
cd two-face

2. Set Up Development Environment

# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# Install dependencies (varies by platform)
# See building.md for platform-specific instructions

# Build
cargo build

# Run tests
cargo test

3. Create a Branch

git checkout -b feature/my-feature
# or
git checkout -b fix/bug-description

Development Workflow

Making Changes

  1. Write code following the style guide
  2. Add tests for new functionality
  3. Update documentation if needed
  4. Run checks before committing

Pre-Commit Checks

# Format code
cargo fmt

# Run linter
cargo clippy -- -D warnings

# Run tests
cargo test

# Check documentation
cargo doc --no-deps

Commit Messages

Follow conventional commit format:

type(scope): description

[optional body]

[optional footer]

Types:

  • feat: New feature
  • fix: Bug fix
  • docs: Documentation
  • refactor: Code restructuring
  • test: Adding tests
  • chore: Maintenance

Examples:

feat(parser): add spell duration parsing

fix(widget): correct scrollback overflow

docs: update installation instructions

refactor(tui): extract render helpers

Pull Request Process

Before Submitting

  • Code follows style guide
  • All tests pass
  • No clippy warnings
  • Documentation updated
  • Commit messages are clear

PR Description Template

## Description
Brief description of changes

## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update

## Testing
How was this tested?

## Checklist
- [ ] Tests added
- [ ] Documentation updated
- [ ] No breaking changes (or documented)

Review Process

  1. Submit PR against main branch
  2. CI checks run automatically
  3. Maintainers review code
  4. Address feedback
  5. Merge when approved

Code Style

Rust Style

Follow standard Rust conventions:

#![allow(unused)]
fn main() {
// Use rustfmt defaults
cargo fmt

// Naming
fn function_name() {}       // snake_case for functions
struct StructName {}        // PascalCase for types
const CONSTANT: u32 = 1;    // SCREAMING_SNAKE_CASE for constants
let variable_name = 1;      // snake_case for variables
}

Documentation

#![allow(unused)]
fn main() {
/// Brief description of the function.
///
/// More detailed explanation if needed.
///
/// # Arguments
///
/// * `param` - Description of parameter
///
/// # Returns
///
/// Description of return value
///
/// # Examples
///
/// ```
/// let result = my_function("input");
/// assert_eq!(result, expected);
/// ```
pub fn my_function(param: &str) -> Result<Output> {
    // ...
}
}

Error Handling

#![allow(unused)]
fn main() {
// Use Result for recoverable errors
fn parse_config(path: &Path) -> Result<Config, ConfigError> {
    let content = fs::read_to_string(path)?;
    let config: Config = toml::from_str(&content)?;
    Ok(config)
}

// Use Option for optional values
fn find_widget(&self, name: &str) -> Option<&Widget> {
    self.widgets.iter().find(|w| w.name() == name)
}
}

Testing

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_descriptive_name() {
        // Arrange
        let input = setup_test_data();

        // Act
        let result = function_under_test(input);

        // Assert
        assert_eq!(result, expected);
    }
}
}

Issue Guidelines

Bug Reports

Include:

  • Two-Face version
  • Operating system
  • Steps to reproduce
  • Expected behavior
  • Actual behavior
  • Relevant logs

Template:

**Version**: 0.1.0
**OS**: Windows 10 / macOS 14 / Ubuntu 22.04

**Steps to Reproduce**:
1. Do this
2. Then this
3. See error

**Expected**: What should happen
**Actual**: What actually happens

**Logs**:

relevant log output

Feature Requests

Include:

  • Use case description
  • Proposed solution
  • Alternatives considered

Template:

**Use Case**:
Why do you need this feature?

**Proposed Solution**:
How should it work?

**Alternatives**:
What other approaches did you consider?

Community Guidelines

Code of Conduct

  • Be respectful and inclusive
  • Focus on constructive feedback
  • Help others learn
  • Credit others’ work
  • Assume good intentions

Communication

  • GitHub Issues for bugs and features
  • GitHub Discussions for questions
  • Pull Request comments for code review

Getting Help

  • Check existing documentation
  • Search closed issues
  • Ask in GitHub Discussions
  • Be specific in your questions

Release Process

Version Numbering

Follow Semantic Versioning (SemVer):

  • MAJOR: Breaking changes
  • MINOR: New features (backwards compatible)
  • PATCH: Bug fixes

Changelog

Update CHANGELOG.md with:

  • Version number and date
  • Breaking changes (if any)
  • New features
  • Bug fixes
  • Known issues

Recognition

Contributors are recognized in:

  • CONTRIBUTORS.md
  • Release notes
  • Project README

Thank you for contributing to Two-Face!

See Also

Reference

Comprehensive reference documentation for Two-Face.

Quick Access

ReferenceDescription
CLI OptionsCommand line flags
Config SchemaComplete configuration reference
Keybind ActionsAll keybind actions A-Z
Parsed ElementsAll ParsedElement variants
Stream IDsAll stream identifiers
Preset ColorsNamed color presets
Environment VariablesEnvironment configuration
Default FilesDefault configuration contents

How to Use This Section

This reference section is designed for lookup, not learning. Use it when you:

  • Need exact syntax for a configuration option
  • Want to know all available values for a setting
  • Need to look up a specific color name or stream ID
  • Are troubleshooting a configuration issue

For learning and tutorials, see:

Quick Reference Cards

Common CLI Flags

two-face --host HOST --port PORT    # Lich mode
two-face --direct --account X ...   # Direct mode
two-face --config PATH              # Custom config
two-face --debug                    # Debug logging

Essential Config Keys

[connection]
mode = "lich"           # or "direct"
host = "127.0.0.1"
port = 8000

[[widgets]]
type = "text"           # Widget type
name = "main"           # Unique name
x = 0                   # Position (0-100)
y = 0
width = 100             # Size (0-100)
height = 100

Common Keybind Actions

[keybinds."key"]
action = "scroll_up"        # Widget action
macro = "command"           # Send command

Stream Quick Reference

StreamContent
mainPrimary game output
roomRoom descriptions
combatCombat messages
speechPlayer speech
thoughtsESP/thoughts
whisperWhispers

Conventions

Value Types

TypeExampleDescription
string"value"Text in quotes
integer100Whole number
float1.5Decimal number
booleantruetrue or false
array["a", "b"]List of values

Color Values

Colors can be specified as:

  • Preset name: "red", "bright_blue"
  • Hex code: "#ff0000"
  • RGB: (in some contexts)

Path Values

Paths support:

  • Absolute: /home/user/.two-face/
  • Home expansion: ~/.two-face/
  • Relative (from config location)

See Also

CLI Options

Complete command line interface reference for Two-Face.

Usage

two-face [OPTIONS]

Connection Options

Lich Proxy Mode

OptionTypeDefaultDescription
--hoststring127.0.0.1Lich proxy hostname
--portinteger8000Lich proxy port
two-face --host 127.0.0.1 --port 8000

Direct eAccess Mode

OptionTypeRequiredDescription
--directflagYesEnable direct mode
--accountstringYesSimutronics account
--passwordstringYesAccount password
--gamestringYesGame instance
--characterstringYesCharacter name
two-face --direct \
  --account myaccount \
  --password mypassword \
  --game prime \
  --character Mycharacter

Game Instance Values

ValueGame
primeGemStone IV (Prime)
testGemStone IV (Test)
platGemStone IV (Platinum)
drDragonRealms (Prime)
drtDragonRealms (Test)

Configuration Options

OptionTypeDefaultDescription
--configpath~/.two-face/config.tomlMain config file
--layoutpath~/.two-face/layout.tomlLayout config file
--colorspath~/.two-face/colors.tomlColor theme file
--keybindspath~/.two-face/keybinds.tomlKeybinds file
--profilestringnoneLoad named profile
# Custom config location
two-face --config /path/to/config.toml

# Load profile
two-face --profile warrior

Logging Options

OptionTypeDefaultDescription
--debugflagfalseEnable debug logging
--log-filepath~/.two-face/two-face.logLog file location
--log-levelstringinfoLog verbosity

Log Levels

LevelDescription
errorErrors only
warnWarnings and errors
infoGeneral information (default)
debugDebug information
traceVerbose tracing
# Debug logging
two-face --debug

# Custom log file
two-face --log-file /tmp/debug.log --log-level trace

Display Options

OptionTypeDefaultDescription
--no-colorflagfalseDisable colors
--force-colorflagfalseForce color output
# Disable colors (for logging/piping)
two-face --no-color

Miscellaneous Options

OptionTypeDescription
--version, -VflagPrint version
--help, -hflagPrint help
--dump-configflagPrint default config
# Show version
two-face --version

# Show help
two-face --help

# Print default configuration
two-face --dump-config > ~/.two-face/config.toml

Option Precedence

Options are applied in this order (later overrides earlier):

  1. Default values
  2. Configuration files
  3. Environment variables
  4. Command line arguments

Examples

Basic Lich Connection

two-face --host 127.0.0.1 --port 8000

Direct Connection with Debug

two-face --direct \
  --account myaccount \
  --password mypassword \
  --game prime \
  --character Warrior \
  --debug

Custom Configuration

two-face \
  --config ~/games/two-face/config.toml \
  --layout ~/games/two-face/hunting.toml

Profile-Based Launch

# Assumes ~/.two-face/profiles/wizard.toml exists
two-face --profile wizard

Scripted Launch (Lich)

#!/bin/bash
# launch_gs4.sh

# Start Lich in background
lich &
sleep 5

# Connect Two-Face
two-face --host 127.0.0.1 --port 8000

Environment-Based (Direct)

#!/bin/bash
# Credentials from environment
export TF_ACCOUNT="myaccount"
export TF_PASSWORD="mypassword"
export TF_GAME="prime"

two-face --direct --character "$1"

Short Options

ShortLongDescription
-h--hostHost (Lich mode)
-p--portPort (Lich mode)
-c--configConfig file
-d--debugDebug mode
-V--versionVersion
# Short form
two-face -h 127.0.0.1 -p 8000 -d

Exit Codes

CodeMeaning
0Success
1General error
2Configuration error
3Connection error
4Authentication error

See Also

Config Schema

Complete configuration file reference for Two-Face.

File Overview

FilePurpose
config.tomlMain configuration
layout.tomlWidget layout
colors.tomlColor theme
keybinds.tomlKey bindings
highlights.tomlText patterns
triggers.tomlAutomation triggers

config.toml

Connection Section

[connection]
mode = "lich"           # "lich" or "direct"
host = "127.0.0.1"      # Lich host (lich mode)
port = 8000             # Lich port (lich mode)
auto_reconnect = true   # Auto-reconnect on disconnect
reconnect_delay = 5     # Seconds between reconnect attempts
game = "prime"          # Game instance (direct mode)
KeyTypeDefaultDescription
modestring"lich"Connection mode
hoststring"127.0.0.1"Proxy host
portinteger8000Proxy port
auto_reconnectbooleantrueEnable auto-reconnect
reconnect_delayinteger5Reconnect delay (seconds)
gamestring"prime"Game instance (direct)

TTS Section

[tts]
enabled = false
engine = "default"      # "sapi", "say", "espeak"
voice = "default"
rate = 1.0              # 0.5 to 2.0
volume = 1.0            # 0.0 to 1.0

speak_room_descriptions = true
speak_combat = false
speak_speech = true
speak_whispers = true
speak_thoughts = false
KeyTypeDefaultDescription
enabledbooleanfalseEnable TTS
enginestring"default"TTS engine
voicestring"default"Voice name
ratefloat1.0Speech rate
volumefloat1.0Volume level
speak_*booleanvariesSpeak stream types

Logging Section

[logging]
level = "info"          # "error", "warn", "info", "debug", "trace"
file = "~/.two-face/two-face.log"
max_size = 10485760     # 10MB
rotate = true

Performance Section

[performance]
render_rate = 60        # Target FPS
batch_updates = true
lazy_render = true
max_scrollback = 10000

layout.toml

Widget Definition

[[widgets]]
type = "text"           # Widget type (required)
name = "main"           # Unique name (required)
x = 0                   # X position (0-100)
y = 0                   # Y position (0-100)
width = 100             # Width (0-100)
height = 100            # Height (0-100)

# Optional common properties
title = "Main"          # Widget title
border = true           # Show border
border_color = "white"  # Border color
focus_order = 1         # Tab order
visible = true          # Initial visibility

Widget Types

text

[[widgets]]
type = "text"
streams = ["main", "room"]
scrollback = 5000
auto_scroll = true
show_timestamps = false
wrap = true

tabbed_text

[[widgets]]
type = "tabbed_text"
tabs = [
    { name = "All", streams = ["main", "room", "combat"] },
    { name = "Combat", streams = ["combat"] },
    { name = "Chat", streams = ["speech", "thoughts"] },
]

progress

[[widgets]]
type = "progress"
data_source = "vitals.health"
color = "health"
show_text = true
show_percentage = true

countdown

[[widgets]]
type = "countdown"
data_source = "roundtime"
warning_threshold = 2
critical_threshold = 0

compass

[[widgets]]
type = "compass"
style = "unicode"       # "ascii", "unicode", "minimal"
clickable = true

indicator

[[widgets]]
type = "indicator"
indicators = ["hidden", "stunned", "prone"]
columns = 2

room

[[widgets]]
type = "room"
show_exits = true
show_creatures = true
show_players = false

command_input

[[widgets]]
type = "command_input"
prompt = "> "
history_size = 500
show_roundtime = true

colors.toml

Theme Definition

[theme]
name = "Custom Theme"

# Base colors
background = "#000000"
text = "#ffffff"
text_dim = "#808080"

# Borders
border = "#404040"
border_focused = "#ffffff"

# Vitals
health = "#00ff00"
health_low = "#ffff00"
health_critical = "#ff0000"
mana = "#0080ff"
stamina = "#ff8000"
spirit = "#ff00ff"

# Streams
main = "#ffffff"
combat = "#ff4444"
speech = "#00ffff"
thoughts = "#00ff00"
whisper = "#ff00ff"
room = "#ffff00"

# Indicators
hidden = "#00ff00"
stunned = "#ffff00"
prone = "#00ffff"

Color Values

Colors accept:

  • Preset names: "red", "bright_blue"
  • Hex codes: "#ff0000", "#f00"

keybinds.toml

Keybind Definition

[keybinds."key_name"]
action = "action_name"  # Widget action

# OR

[keybinds."key_name"]
macro = "game command"  # Send command

Key Names

CategoryExamples
Letters"a", "z", "A" (shift+a)
Numbers"1", "0"
Function"f1", "f12"
Navigation"up", "down", "left", "right"
Editing"enter", "tab", "backspace", "delete"
Special"escape", "space", "home", "end"
Numpad"numpad0", "numpad_plus"

Modifiers

Combine with +:

  • "ctrl+a"
  • "shift+f1"
  • "alt+enter"
  • "ctrl+shift+s"

highlights.toml

Highlight Definition

[[highlights]]
pattern = "regex pattern"
fg = "color"            # Foreground color
bg = "color"            # Background color (optional)
bold = false
italic = false
underline = false

# Optional
enabled = true
priority = 100
fast_parse = false      # Use literal matching

triggers.toml

Trigger Definition

[[triggers]]
name = "trigger_name"
pattern = "regex pattern"
command = "action"

# Optional
enabled = true
priority = 100
cooldown = 1000         # Milliseconds
category = "combat"
stream = "main"         # Limit to stream

Type Reference

TypeFormatExample
stringQuoted text"value"
integerWhole number100
floatDecimal number1.5
booleantrue/falsetrue
arrayBrackets["a", "b"]
tableSection[section]

See Also

Keybind Actions

Complete reference of all available keybind actions.

Action Syntax

[keybinds."key"]
action = "action_name"

Widget Focus

ActionDescription
next_widgetFocus next widget in tab order
prev_widgetFocus previous widget
focus_inputFocus command input
focus_widgetFocus specific widget (requires widget param)
[keybinds."tab"]
action = "next_widget"

[keybinds."shift+tab"]
action = "prev_widget"

[keybinds."escape"]
action = "focus_input"

[keybinds."alt+m"]
action = "focus_widget"
widget = "main"

Scrolling

ActionDescription
scroll_upScroll up one page
scroll_downScroll down one page
scroll_half_upScroll up half page
scroll_half_downScroll down half page
scroll_line_upScroll up one line
scroll_line_downScroll down one line
scroll_topScroll to top
scroll_bottomScroll to bottom
[keybinds."page_up"]
action = "scroll_up"

[keybinds."page_down"]
action = "scroll_down"

[keybinds."home"]
action = "scroll_top"

[keybinds."end"]
action = "scroll_bottom"

Input Actions

Command Input

ActionDescription
submit_inputSubmit current input
clear_inputClear input line
history_prevPrevious command in history
history_nextNext command in history
history_searchSearch command history
[keybinds."enter"]
action = "submit_input"

[keybinds."ctrl+u"]
action = "clear_input"

[keybinds."up"]
action = "history_prev"

Text Editing

ActionDescription
cursor_leftMove cursor left
cursor_rightMove cursor right
cursor_homeMove to line start
cursor_endMove to line end
delete_charDelete character at cursor
delete_wordDelete word at cursor
backspaceDelete character before cursor

Browser Actions

Open Browsers

ActionDescription
open_searchOpen text search
open_helpOpen help browser
open_layout_editorOpen layout editor
open_highlight_editorOpen highlight editor
open_keybind_editorOpen keybind editor
[keybinds."ctrl+f"]
action = "open_search"

[keybinds."f1"]
action = "open_help"

Search Actions

ActionDescription
search_nextFind next match
search_prevFind previous match
close_searchClose search

Widget Actions

Tabbed Text

ActionDescription
next_tabSwitch to next tab
prev_tabSwitch to previous tab
tab_1 through tab_9Switch to numbered tab
close_tabClose current tab
[keybinds."ctrl+tab"]
action = "next_tab"

[keybinds."ctrl+shift+tab"]
action = "prev_tab"

[keybinds."ctrl+1"]
action = "tab_1"

Compass

ActionDescription
compass_northGo north
compass_southGo south
compass_eastGo east
compass_westGo west
compass_northeastGo northeast
compass_northwestGo northwest
compass_southeastGo southeast
compass_southwestGo southwest
compass_outGo out
compass_upGo up
compass_downGo down

Application Actions

General

ActionDescription
quitExit application
reload_configReload all configuration
toggle_debugToggle debug overlay
[keybinds."ctrl+q"]
action = "quit"

[keybinds."ctrl+r"]
action = "reload_config"

Layout

ActionDescription
toggle_widgetToggle widget visibility
maximize_widgetMaximize focused widget
restore_layoutRestore default layout
[keybinds."ctrl+h"]
action = "toggle_widget"
widget = "health"

TTS Actions

ActionDescription
toggle_ttsEnable/disable TTS
speak_statusSpeak current status
speak_roomSpeak room description
speak_lastRepeat last spoken text
stop_speakingStop current speech
tts_rate_upIncrease speech rate
tts_rate_downDecrease speech rate
tts_volume_upIncrease volume
tts_volume_downDecrease volume
[keybinds."ctrl+space"]
action = "toggle_tts"

[keybinds."f1"]
action = "speak_status"

Macro Actions

Send game commands:

[keybinds."f1"]
macro = "attack target"

[keybinds."ctrl+1"]
macro = "prep 101;cast"

# With delay
[keybinds."f5"]
macro = "prep 901;{2000};cast"

# With input prompt
[keybinds."ctrl+g"]
macro = "go $input"

Macro Variables

VariableDescription
$inputPrompt for input
$targetCurrent target
$lasttargetLast targeted creature
{N}Delay N milliseconds

Action Parameters

Some actions require additional parameters:

[keybinds."alt+m"]
action = "focus_widget"
widget = "main"          # Required: widget name

[keybinds."ctrl+h"]
action = "toggle_widget"
widget = "health"        # Required: widget name

Complete Example

# keybinds.toml

# Navigation
[keybinds."tab"]
action = "next_widget"

[keybinds."shift+tab"]
action = "prev_widget"

[keybinds."escape"]
action = "focus_input"

# Scrolling
[keybinds."page_up"]
action = "scroll_up"

[keybinds."page_down"]
action = "scroll_down"

# Movement macros
[keybinds."numpad8"]
macro = "north"

[keybinds."numpad2"]
macro = "south"

# Combat macros
[keybinds."f1"]
macro = "attack target"

[keybinds."f2"]
macro = "stance defensive"

# Application
[keybinds."ctrl+q"]
action = "quit"

[keybinds."ctrl+f"]
action = "open_search"

See Also

Parsed Elements

Reference of all ParsedElement variants from the game protocol parser.

Overview

ParsedElements are the structured output of the XML parser. Each variant represents a specific type of game data.

Text Elements

Text

Plain text content.

#![allow(unused)]
fn main() {
ParsedElement::Text(String)
}

Source: Untagged text between XML elements.

StyledText

Text with formatting.

#![allow(unused)]
fn main() {
ParsedElement::StyledText {
    text: String,
    bold: bool,
    preset: Option<String>,
}
}

Source: <pushBold/>, <popBold/>, <preset id="..."/>

Room Elements

RoomName

Current room name.

#![allow(unused)]
fn main() {
ParsedElement::RoomName(String)
}

Source: <roomName>...</roomName>

RoomDesc

Room description text.

#![allow(unused)]
fn main() {
ParsedElement::RoomDesc(String)
}

Source: <roomDesc>...</roomDesc>

RoomObjects

Objects in the room.

#![allow(unused)]
fn main() {
ParsedElement::RoomObjects(String)
}

Source: <roomObjs>...</roomObjs>

RoomPlayers

Players in the room.

#![allow(unused)]
fn main() {
ParsedElement::RoomPlayers(String)
}

Source: <roomPlayers>...</roomPlayers>

RoomExits

Available exits.

#![allow(unused)]
fn main() {
ParsedElement::RoomExits(String)
}

Source: <roomExits>...</roomExits>

Vitals Elements

Vitals

Character vital statistics.

#![allow(unused)]
fn main() {
ParsedElement::Vitals {
    health: Option<u8>,
    mana: Option<u8>,
    stamina: Option<u8>,
    spirit: Option<u8>,
}
}

Source: <progressBar id="..." value="..."/>

Progress bar IDs:

  • health, manapoints, stamina, spirit
  • encumlevel, mindState, nextLvlPB

Timing Elements

Roundtime

Action roundtime.

#![allow(unused)]
fn main() {
ParsedElement::Roundtime(u32)
}

Source: <roundTime value="..."/> or Roundtime: N sec

CastTime

Spell casting time.

#![allow(unused)]
fn main() {
ParsedElement::CastTime(u32)
}

Source: <castTime value="..."/>

Status Elements

Indicator

Status indicator change.

#![allow(unused)]
fn main() {
ParsedElement::Indicator {
    id: String,
    visible: bool,
}
}

Source: <indicator id="..." visible="y|n"/>

Common indicator IDs:

  • IconHIDDEN, IconSTUNNED, IconWEBBED
  • IconPRONE, IconKNEELING, IconSITTING
  • IconBLEEDING, IconPOISONED, IconDISEASED
  • IconINVISIBLE, IconDEAD

Stance

Combat stance.

#![allow(unused)]
fn main() {
ParsedElement::Stance(String)
}

Source: <stance id="..."/>

Values: offensive, forward, neutral, guarded, defensive

Compass

Available directions.

#![allow(unused)]
fn main() {
ParsedElement::Compass {
    directions: Vec<String>,
}
}

Source: <compass><dir value="..."/>...</compass>

Direction values:

  • n, s, e, w (cardinal)
  • ne, nw, se, sw (diagonal)
  • up, down, out

Hand Elements

LeftHand

Item in left hand.

#![allow(unused)]
fn main() {
ParsedElement::LeftHand {
    noun: String,
    name: String,
    exist: Option<String>,
}
}

Source: <left exist="..." noun="...">...</left>

RightHand

Item in right hand.

#![allow(unused)]
fn main() {
ParsedElement::RightHand {
    noun: String,
    name: String,
    exist: Option<String>,
}
}

Source: <right exist="..." noun="...">...</right>

Spell

Prepared spell.

#![allow(unused)]
fn main() {
ParsedElement::Spell(String)
}

Source: <spell>...</spell>

Stream Elements

PushStream

Start of named stream.

#![allow(unused)]
fn main() {
ParsedElement::PushStream(String)
}

Source: <pushStream id="..."/>

PopStream

End of current stream.

#![allow(unused)]
fn main() {
ParsedElement::PopStream
}

Source: <popStream/>

StreamContent

Content within a stream.

#![allow(unused)]
fn main() {
ParsedElement::StreamContent {
    stream: String,
    content: String,
}
}

Prompt Elements

Prompt

Game prompt.

#![allow(unused)]
fn main() {
ParsedElement::Prompt {
    text: String,
    time: Option<String>,
}
}

Source: <prompt time="...">...</prompt>

Clickable object link.

#![allow(unused)]
fn main() {
ParsedElement::Link {
    exist: String,
    noun: String,
    text: String,
}
}

Source: <a exist="..." noun="...">...</a>

Clickable command link.

#![allow(unused)]
fn main() {
ParsedElement::CommandLink {
    cmd: String,
    text: String,
}
}

Source: <d cmd="...">...</d>

Combat Elements

Combat

Combat message.

#![allow(unused)]
fn main() {
ParsedElement::Combat {
    text: String,
}
}

Source: Text within combat stream.

Damage

Damage dealt or received.

#![allow(unused)]
fn main() {
ParsedElement::Damage {
    amount: u32,
    target: Option<String>,
}
}

Container Elements

Container

Container contents.

#![allow(unused)]
fn main() {
ParsedElement::Container {
    id: String,
    name: String,
    contents: Vec<ContainerItem>,
}
}

ContainerItem

Item within container.

#![allow(unused)]
fn main() {
ContainerItem {
    exist: String,
    noun: String,
    name: String,
}
}

Spell Elements

ActiveSpell

Currently active spell.

#![allow(unused)]
fn main() {
ParsedElement::ActiveSpell {
    name: String,
    duration: Option<u32>,
}
}

Source: <spell>...</spell> with duration

SpellExpire

Spell expired notification.

#![allow(unused)]
fn main() {
ParsedElement::SpellExpire {
    name: String,
}
}

System Elements

Mode

Game mode change.

#![allow(unused)]
fn main() {
ParsedElement::Mode {
    mode: String,
}
}

Source: <mode id="..."/>

Output

Output configuration.

#![allow(unused)]
fn main() {
ParsedElement::Output {
    class: String,
}
}

Source: <output class="..."/>

ClearStream

Clear stream content.

#![allow(unused)]
fn main() {
ParsedElement::ClearStream(String)
}

Source: <clearStream id="..."/>

Unknown Element

Unrecognized XML.

#![allow(unused)]
fn main() {
ParsedElement::Unknown {
    tag: String,
    content: String,
}
}

Preserves unhandled elements for debugging.

Element Processing

Elements are processed in order:

#![allow(unused)]
fn main() {
for element in parser.parse(input) {
    match element {
        ParsedElement::RoomName(name) => {
            state.room.name = name;
        }
        ParsedElement::Vitals { health, .. } => {
            if let Some(h) = health {
                state.vitals.health = h;
            }
        }
        // ...
    }
}
}

See Also

Stream IDs

Reference of all game stream identifiers.

Overview

Streams categorize game output. Each text window can subscribe to specific streams to filter content.

Core Streams

Stream IDDescriptionContent Type
mainPrimary game outputGeneral text, actions, results
roomRoom descriptionsRoom name, description, exits, objects
combatCombat messagesAttacks, damage, death
speechSpoken communicationSay, ask, exclaim
whisperPrivate messagesWhispers
thoughtsESP/mental communicationThink, group chat

Communication Streams

Stream IDDescription
speechPlayer speech (say, ask)
whisperPrivate whispers
thoughtsESP channel (think)
shoutShouted messages
singSung messages

Game State Streams

Stream IDDescription
roomRoom information
invInventory updates
percWindowPerception window
familiarFamiliar view
bountyBounty task info

Combat Streams

Stream IDDescription
combatCombat actions and results
assessCreature assessments
deathDeath notifications

Specialty Streams

Stream IDDescription
logonsPlayer login/logout
atmosphericsWeather and atmosphere
lootLoot messages
groupGroup/party messages

System Streams

Stream IDDescription
rawUnprocessed output
debugDebug information
scriptScript output (Lich)

Using Streams

Widget Configuration

# Main window - everything
[[widgets]]
type = "text"
name = "main"
streams = ["main", "room", "combat"]

# Chat window - communication only
[[widgets]]
type = "text"
name = "chat"
streams = ["speech", "whisper", "thoughts"]

# Combat window - combat only
[[widgets]]
type = "text"
name = "combat"
streams = ["combat"]

Empty Streams (All)

# Empty array = receive all streams
[[widgets]]
type = "text"
streams = []

Tabbed Text

[[widgets]]
type = "tabbed_text"
tabs = [
    { name = "All", streams = [] },
    { name = "Game", streams = ["main", "room"] },
    { name = "Combat", streams = ["combat"] },
    { name = "Chat", streams = ["speech", "thoughts", "whisper"] },
]

Triggers

# Trigger on specific stream
[[triggers]]
pattern = "whispers,"
command = ".notify Whisper!"
stream = "whisper"

# Trigger on combat stream
[[triggers]]
pattern = "falls dead"
command = "search"
stream = "combat"

Stream Colors

Configure stream-specific colors in colors.toml:

[theme]
# Stream colors
main = "#ffffff"
room = "#ffff00"
combat = "#ff4444"
speech = "#00ffff"
whisper = "#ff00ff"
thoughts = "#00ff00"

Stream Priority

When streams overlap, content appears in all matching windows. Configure priority in layout:

[[widgets]]
type = "text"
name = "combat"
streams = ["combat"]
priority = 100        # Higher = checked first

Common Configurations

Single Window (All Content)

[[widgets]]
type = "text"
name = "main"
streams = []          # All streams

Two Windows (Game + Chat)

[[widgets]]
type = "text"
name = "game"
streams = ["main", "room", "combat"]

[[widgets]]
type = "text"
name = "chat"
streams = ["speech", "thoughts", "whisper"]

Three Windows (Specialized)

[[widgets]]
type = "text"
name = "story"
streams = ["main", "room"]

[[widgets]]
type = "text"
name = "combat"
streams = ["combat"]

[[widgets]]
type = "text"
name = "social"
streams = ["speech", "thoughts", "whisper"]

RP Layout

[[widgets]]
type = "text"
name = "story"
streams = ["main", "room"]        # Immersive content

[[widgets]]
type = "text"
name = "ic"
streams = ["speech", "whisper"]   # In-character

[[widgets]]
type = "text"
name = "ooc"
streams = ["thoughts"]            # Out-of-character

Stream Detection

The parser assigns streams based on XML tags:

XML SourceStream
<pushStream id="X"/>Stream X
<roomName>room
<roomDesc>room
Combat XMLcombat
Default textmain

See Also

Preset Colors

Reference of all named color presets.

Basic Colors

NameHex CodePreview
black#000000■ Black
red#800000■ Red
green#008000■ Green
yellow#808000■ Yellow
blue#000080■ Blue
magenta#800080■ Magenta
cyan#008080■ Cyan
white#c0c0c0■ White

Bright Colors

NameHex CodePreview
bright_black / gray#808080■ Gray
bright_red#ff0000■ Bright Red
bright_green#00ff00■ Bright Green
bright_yellow#ffff00■ Bright Yellow
bright_blue#0000ff■ Bright Blue
bright_magenta#ff00ff■ Bright Magenta
bright_cyan#00ffff■ Bright Cyan
bright_white#ffffff■ Bright White

Alternative Names

AlternativeMaps To
greygray
dark_graybright_black
light_graywhite

Semantic Colors

Vitals

NameDefaultPurpose
health#00ff00Health bar
health_low#ffff00Low health warning
health_critical#ff0000Critical health
mana#0080ffMana bar
stamina#ff8000Stamina bar
spirit#ff00ffSpirit bar

Status Indicators

NameDefaultPurpose
hidden#00ff00Hidden status
invisible#00ffffInvisible status
stunned#ffff00Stunned status
webbed#ff00ffWebbed status
prone#00ffffProne status
kneeling#ff8000Kneeling status
sitting#808080Sitting status
dead#ff0000Dead status

Streams

NameDefaultPurpose
main#ffffffMain text
room#ffff00Room descriptions
combat#ff4444Combat messages
speech#00ffffPlayer speech
whisper#ff00ffWhispers
thoughts#00ff00ESP/thoughts

UI Elements

NameDefaultPurpose
border#404040Widget borders
border_focused#ffffffFocused widget border
background#000000Background
text#ffffffDefault text
text_dim#808080Dimmed text

Game Presets

Colors matching game highlight presets:

Preset IDNameColor
speechSpeech#00ffff
thoughtThoughts#00ff00
whisperWhispers#ff00ff
boldBold textInherits + bold
roomNameRoom name#ffff00
roomDescRoom description#c0c0c0

Using Colors

In colors.toml

[theme]
# Use preset names
health = "bright_green"
mana = "bright_blue"

# Or hex codes
border = "#333333"
text = "#e0e0e0"

In highlights.toml

[[highlights]]
pattern = "something"
fg = "bright_red"        # Preset name
bg = "#000080"           # Hex code

In widgets

[[widgets]]
type = "progress"
color = "health"         # Semantic color
border_color = "cyan"    # Basic color

Hex Color Format

FormatExampleNotes
6-digit#ff0000Full RGB
3-digit#f00Shorthand (expands to #ff0000)

Color Selection Tips

High Contrast

For accessibility, use high-contrast combinations:

# Good contrast
text = "#ffffff"
background = "#000000"

# Also good
text = "#000000"
background = "#ffffff"

Colorblind-Friendly

Avoid relying solely on red/green distinction:

# Instead of red/green
health = "#00ff00"     # Green
danger = "#ff0000"     # Red

# Use additional cues
health = "#00ffff"     # Cyan
danger = "#ff00ff"     # Magenta

Thematic Palettes

Nord Theme:

background = "#2e3440"
text = "#eceff4"
red = "#bf616a"
green = "#a3be8c"
blue = "#81a1c1"

Solarized Dark:

background = "#002b36"
text = "#839496"
red = "#dc322f"
green = "#859900"
blue = "#268bd2"

Dracula:

background = "#282a36"
text = "#f8f8f2"
red = "#ff5555"
green = "#50fa7b"
blue = "#8be9fd"

Color Inheritance

Some colors inherit or derive from others:

# Base color
text = "#ffffff"

# Derived (if not specified)
text_dim = text * 0.5    # Dimmer version

See Also

Environment Variables

Reference of all environment variables used by Two-Face.

Connection Variables

Direct eAccess Mode

VariableDescriptionRequired
TF_ACCOUNTSimutronics account nameYes (if --account not provided)
TF_PASSWORDAccount passwordYes (if --password not provided)
TF_GAMEGame instanceYes (if --game not provided)
TF_CHARACTERCharacter nameYes (if --character not provided)
# Set credentials via environment
export TF_ACCOUNT="myaccount"
export TF_PASSWORD="mypassword"
export TF_GAME="prime"
export TF_CHARACTER="Warrior"

# Launch without command-line credentials
two-face --direct

Lich Mode

VariableDescriptionDefault
TF_HOSTLich proxy host127.0.0.1
TF_PORTLich proxy port8000
export TF_HOST="192.168.1.100"
export TF_PORT="8001"
two-face  # Uses environment values

Configuration Variables

VariableDescriptionDefault
TF_CONFIG_DIRConfiguration directory~/.two-face
TF_CONFIGMain config file path$TF_CONFIG_DIR/config.toml
TF_LAYOUTLayout config path$TF_CONFIG_DIR/layout.toml
TF_COLORSColors config path$TF_CONFIG_DIR/colors.toml
TF_KEYBINDSKeybinds config path$TF_CONFIG_DIR/keybinds.toml
# Custom config directory
export TF_CONFIG_DIR="/home/user/games/two-face"

# Individual file overrides
export TF_LAYOUT="/home/user/layouts/hunting.toml"

Logging Variables

VariableDescriptionDefault
TF_LOG_LEVELLog verbosityinfo
TF_LOG_FILELog file path$TF_CONFIG_DIR/two-face.log
RUST_LOGRust logging (alternative)-
# Enable debug logging
export TF_LOG_LEVEL="debug"

# Custom log file
export TF_LOG_FILE="/tmp/two-face.log"

# Or use Rust's standard logging
export RUST_LOG="two_face=debug"

Log Levels

LevelDescription
errorErrors only
warnWarnings and above
infoGeneral information
debugDebug information
traceVerbose tracing

Display Variables

VariableDescriptionDefault
TF_NO_COLORDisable colorsfalse
TF_FORCE_COLORForce colorsfalse
TERMTerminal typeSystem default
COLORTERMColor support levelSystem default
# Disable colors (for logging/piping)
export TF_NO_COLOR=1

# Force colors (for terminals that don't advertise support)
export TF_FORCE_COLOR=1

Build Variables

Used during compilation:

VariableDescription
VCPKG_ROOTvcpkg installation path (Windows)
OPENSSL_DIROpenSSL installation path
OPENSSL_LIB_DIROpenSSL library path
OPENSSL_INCLUDE_DIROpenSSL headers path
# Windows (vcpkg)
set VCPKG_ROOT=C:\tools\vcpkg

# macOS (Homebrew)
export OPENSSL_DIR=$(brew --prefix openssl)

# Linux (custom OpenSSL)
export OPENSSL_DIR=/opt/openssl
export OPENSSL_LIB_DIR=$OPENSSL_DIR/lib
export OPENSSL_INCLUDE_DIR=$OPENSSL_DIR/include

TTS Variables

VariableDescriptionDefault
TF_TTS_ENGINETTS engine overrideSystem default
TF_TTS_VOICEVoice overrideSystem default
# Force specific TTS engine
export TF_TTS_ENGINE="espeak"
export TF_TTS_VOICE="en-us"

Precedence

Environment variables are applied in this order:

  1. Default values (lowest)
  2. Configuration files
  3. Environment variables
  4. Command-line arguments (highest)

Example:

# Config file says port = 8000
# Environment says TF_PORT=8001
# Command line says --port 8002

# Result: port 8002 is used

Security Notes

Credentials in Environment

Environment variables are visible to:

  • The current process
  • Child processes
  • Process inspection tools

Recommendations:

  • Don’t export credentials in shell profile files
  • Use temporary environment for session
  • Clear history after entering passwords
# Safer approach - prompt for password
read -sp "Password: " TF_PASSWORD
export TF_PASSWORD

Logging Redaction

Logs may contain environment values. The log system attempts to redact:

  • TF_PASSWORD
  • TF_ACCOUNT (partially)

But review logs before sharing publicly.

Shell Integration

Bash/Zsh

# ~/.bashrc or ~/.zshrc

# Two-Face config
export TF_CONFIG_DIR="$HOME/.two-face"
export TF_LOG_LEVEL="info"

# Alias with common options
alias gs4='two-face --host 127.0.0.1 --port 8000'

Fish

# ~/.config/fish/config.fish

set -x TF_CONFIG_DIR "$HOME/.two-face"
set -x TF_LOG_LEVEL "info"

alias gs4 'two-face --host 127.0.0.1 --port 8000'

PowerShell

# $PROFILE

$env:TF_CONFIG_DIR = "$env:USERPROFILE\.two-face"
$env:TF_LOG_LEVEL = "info"

function gs4 { two-face --host 127.0.0.1 --port 8000 $args }

Windows Command Prompt

:: Set for current session
set TF_CONFIG_DIR=%USERPROFILE%\.two-face

:: Set permanently (requires admin)
setx TF_CONFIG_DIR "%USERPROFILE%\.two-face"

Debugging

Check current environment:

# Show all TF_ variables
env | grep ^TF_

# Show specific variable
echo $TF_CONFIG_DIR

See Also

Default Files

Default configuration file contents for reference.

config.toml

# Two-Face Configuration
# Default settings

[connection]
mode = "lich"
host = "127.0.0.1"
port = 8000
auto_reconnect = true
reconnect_delay = 5

[tts]
enabled = false
engine = "default"
voice = "default"
rate = 1.0
volume = 1.0
speak_room_descriptions = true
speak_combat = false
speak_speech = true
speak_whispers = true
speak_thoughts = false

[logging]
level = "info"
file = "~/.two-face/two-face.log"

[performance]
render_rate = 60
batch_updates = true
lazy_render = true

layout.toml

# Two-Face Layout
# Default widget arrangement

# Main game text window
[[widgets]]
type = "text"
name = "main"
title = "Game"
x = 0
y = 0
width = 75
height = 85
streams = ["main", "room", "combat", "speech", "thoughts"]
scrollback = 5000
auto_scroll = true

# Health bar
[[widgets]]
type = "progress"
name = "health"
title = "HP"
x = 76
y = 0
width = 24
height = 3
data_source = "vitals.health"
color = "health"
show_text = true
show_percentage = true

# Mana bar
[[widgets]]
type = "progress"
name = "mana"
title = "MP"
x = 76
y = 4
width = 24
height = 3
data_source = "vitals.mana"
color = "mana"
show_text = true
show_percentage = true

# Stamina bar
[[widgets]]
type = "progress"
name = "stamina"
title = "ST"
x = 76
y = 8
width = 24
height = 3
data_source = "vitals.stamina"
color = "stamina"
show_text = true
show_percentage = true

# Spirit bar
[[widgets]]
type = "progress"
name = "spirit"
title = "SP"
x = 76
y = 12
width = 24
height = 3
data_source = "vitals.spirit"
color = "spirit"
show_text = true
show_percentage = true

# Compass
[[widgets]]
type = "compass"
name = "compass"
x = 76
y = 16
width = 24
height = 10
style = "unicode"
clickable = true

# Roundtime
[[widgets]]
type = "countdown"
name = "roundtime"
title = "RT"
x = 76
y = 27
width = 24
height = 3
data_source = "roundtime"

# Status indicators
[[widgets]]
type = "indicator"
name = "status"
x = 76
y = 31
width = 24
height = 10
indicators = ["hidden", "stunned", "webbed", "prone", "kneeling"]
columns = 2

# Command input
[[widgets]]
type = "command_input"
name = "input"
x = 0
y = 86
width = 100
height = 14
history_size = 500
prompt = "> "

colors.toml

# Two-Face Colors
# Default theme

[theme]
name = "Default"

# Base colors
background = "#000000"
text = "#c0c0c0"
text_dim = "#808080"

# Borders
border = "#404040"
border_focused = "#ffffff"

# Vitals
health = "#00ff00"
health_low = "#ffff00"
health_critical = "#ff0000"
mana = "#0080ff"
stamina = "#ff8000"
spirit = "#ff00ff"

# Streams
main = "#ffffff"
room = "#ffff00"
combat = "#ff4444"
speech = "#00ffff"
whisper = "#ff00ff"
thoughts = "#00ff00"

# Status indicators
hidden = "#00ff00"
invisible = "#00ffff"
stunned = "#ffff00"
webbed = "#ff00ff"
prone = "#00ffff"
kneeling = "#ff8000"
sitting = "#808080"
dead = "#ff0000"

keybinds.toml

# Two-Face Keybinds
# Default key mappings

# Navigation - Numpad
[keybinds."numpad8"]
macro = "north"

[keybinds."numpad2"]
macro = "south"

[keybinds."numpad4"]
macro = "west"

[keybinds."numpad6"]
macro = "east"

[keybinds."numpad7"]
macro = "northwest"

[keybinds."numpad9"]
macro = "northeast"

[keybinds."numpad1"]
macro = "southwest"

[keybinds."numpad3"]
macro = "southeast"

[keybinds."numpad5"]
macro = "out"

# Widget navigation
[keybinds."tab"]
action = "next_widget"

[keybinds."shift+tab"]
action = "prev_widget"

[keybinds."escape"]
action = "focus_input"

# Scrolling
[keybinds."page_up"]
action = "scroll_up"

[keybinds."page_down"]
action = "scroll_down"

[keybinds."home"]
action = "scroll_top"

[keybinds."end"]
action = "scroll_bottom"

# Quick commands
[keybinds."f1"]
macro = "look"

[keybinds."f2"]
macro = "inventory"

[keybinds."f3"]
macro = "experience"

# Search
[keybinds."ctrl+f"]
action = "open_search"

# Quit
[keybinds."ctrl+q"]
action = "quit"

highlights.toml

# Two-Face Highlights
# Default text patterns

# Critical status
[[highlights]]
pattern = "(?i)you are stunned"
fg = "black"
bg = "yellow"
bold = true

[[highlights]]
pattern = "(?i)webs? (stick|entangle)"
fg = "black"
bg = "magenta"
bold = true

# Combat
[[highlights]]
pattern = "\\*\\* .+ \\*\\*"
fg = "bright_red"
bold = true

[[highlights]]
pattern = "falls dead"
fg = "bright_yellow"
bold = true

# Communication
[[highlights]]
pattern = "(\\w+) whispers,"
fg = "magenta"

[[highlights]]
pattern = "(\\w+) says?,"
fg = "cyan"

# Room elements
[[highlights]]
pattern = "^\\[.+\\]$"
fg = "bright_yellow"
bold = true

[[highlights]]
pattern = "Obvious (exits|paths):"
fg = "gray"

triggers.toml

# Two-Face Triggers
# Default automation (minimal)

# Whisper notification
[[triggers]]
name = "whisper_alert"
pattern = "(\\w+) whispers,"
command = ".notify Whisper from $1"
enabled = false

# Stun notification
[[triggers]]
name = "stun_alert"
pattern = "(?i)you are stunned"
command = ".notify Stunned!"
cooldown = 1000
enabled = false

cmdlist.toml

# Two-Face Command Lists
# Default context menus

# General items
[[cmdlist]]
noun = ".*"
match_mode = "regex"
commands = ["look", "get", "drop"]
priority = 1

# Creatures
[[cmdlist]]
category = "creature"
noun = ".*"
match_mode = "regex"
commands = ["attack", "look", "assess"]
priority = 10

# Containers
[[cmdlist]]
category = "container"
noun = "(?i)(backpack|bag|pouch|sack|cloak)"
match_mode = "regex"
commands = ["look in", "open", "close"]
priority = 20

# Players
[[cmdlist]]
category = "player"
noun = "^[A-Z][a-z]+$"
match_mode = "regex"
commands = ["look", "smile", "bow", "wave"]
priority = 30

Generating Defaults

To generate default configuration files:

# Dump defaults to stdout
two-face --dump-config

# Create default files
two-face --dump-config > ~/.two-face/config.toml

Restoring Defaults

To restore a file to defaults:

# Backup current config
cp ~/.two-face/config.toml ~/.two-face/config.toml.bak

# Generate fresh default
two-face --dump-config > ~/.two-face/config.toml

Or delete the file - Two-Face will use built-in defaults.

See Also

Migration Guides

Guides for transitioning to Two-Face from other clients.

Overview

If you’re coming from another GemStone IV or DragonRealms client, these guides help you translate your existing setup to Two-Face.

Migration Guides

Previous ClientGuide
ProfanityFrom Profanity
Wizard Front EndFrom WFE
StormFrontFrom StormFront

Common Migration Tasks

Configuration Translation

Each client has different configuration formats. Common concepts to translate:

ConceptProfanityWFETwo-Face
LayoutWindow positionsWindow setuplayout.toml
ColorsTheme filesColor settingscolors.toml
MacrosScripts/aliasesMacroskeybinds.toml
TriggersTriggersTriggerstriggers.toml
HighlightsHighlightsHighlightshighlights.toml

Feature Mapping

FeatureCommon InTwo-Face Equivalent
Text windowsAll clientstext widgets
Health barsAll clientsprogress widgets
CompassAll clientscompass widget
MacrosAll clientsKeybind macros
TriggersAll clientstriggers.toml
ScriptsProfanity/LichVia Lich proxy

General Migration Steps

1. Install Two-Face

Follow the installation guide.

2. Connect to Game

Test connection before migrating configuration:

# Via Lich (existing setup)
two-face --host 127.0.0.1 --port 8000

3. Create Basic Layout

Start with default layout, then customize:

# Use built-in defaults first
two-face --dump-config > ~/.two-face/config.toml

4. Translate Configuration

Migrate settings from your previous client:

  1. Layout/window positions
  2. Colors/theme
  3. Macros/keybinds
  4. Triggers
  5. Highlights

5. Test and Refine

  • Test each migrated feature
  • Adjust as needed
  • Take advantage of new features

What Carries Over

Lich Scripts

If you use Lich, your scripts continue to work. Two-Face connects through Lich just like other clients.

Game Settings

In-game settings (character options, game preferences) are server-side and unaffected.

Account Information

Your Simutronics account, characters, and subscriptions are unchanged.

What Changes

Configuration Files

Each client uses different file formats. Configuration must be recreated (not copied).

Keybinds

Keybind syntax differs. Translate your macros to Two-Face format.

Visual Layout

Screen arrangement uses different systems. Recreate your preferred layout.

Migration Tips

Start Fresh vs. Replicate

Option A: Start Fresh

  • Use Two-Face defaults
  • Learn new features
  • Gradually customize

Option B: Replicate

  • Match previous client exactly
  • Familiar environment
  • Then explore new features

Document Your Previous Setup

Before migrating, note:

  • Window positions and sizes
  • Important macros
  • Color preferences
  • Trigger patterns

Take Your Time

Migration doesn’t have to be immediate:

  • Run Two-Face alongside previous client
  • Migrate incrementally
  • Test each feature

Version History

See Version History for:

  • Release notes
  • Breaking changes
  • Upgrade instructions

Getting Help

If you encounter issues:

See Also

Migrating from Profanity

Guide for Profanity users transitioning to Two-Face.

Overview

Profanity and Two-Face are both terminal-based clients. Many concepts translate directly, though configuration syntax differs.

Key Similarities

FeatureProfanityTwo-Face
Terminal-based
Lich support
Text windows
Macros
Triggers
Highlights

Key Differences

AspectProfanityTwo-Face
Config formatCustom formatTOML
LanguageRubyRust
Widget systemFixed typesFlexible types
LayoutRelativePercentage-based

Configuration Translation

Window Layout

Profanity (in configuration):

window main 0 0 80 20
window health 81 0 20 3

Two-Face (layout.toml):

[[widgets]]
type = "text"
name = "main"
x = 0
y = 0
width = 80
height = 75

[[widgets]]
type = "progress"
name = "health"
x = 81
y = 0
width = 19
height = 5
data_source = "vitals.health"

Highlights

Profanity:

highlight "stunned" bold yellow
highlight /whispers,/ magenta

Two-Face (highlights.toml):

[[highlights]]
pattern = "stunned"
fg = "bright_yellow"
bold = true

[[highlights]]
pattern = "whispers,"
fg = "magenta"

Macros

Profanity:

macro F1 attack target
macro ctrl-1 "prep 101;cast"

Two-Face (keybinds.toml):

[keybinds."f1"]
macro = "attack target"

[keybinds."ctrl+1"]
macro = "prep 101;cast"

Triggers

Profanity:

trigger "You are stunned" echo "STUNNED!"
trigger /falls dead/ "search;loot"

Two-Face (triggers.toml):

[[triggers]]
pattern = "You are stunned"
command = ".notify STUNNED!"

[[triggers]]
pattern = "falls dead"
command = "search;loot"

Colors

Profanity:

color health green
color mana blue
color background black

Two-Face (colors.toml):

[theme]
health = "#00ff00"
mana = "#0080ff"
background = "#000000"

Lich Integration

Both clients connect through Lich the same way. Your Lich scripts continue to work:

# Same connection method
two-face --host 127.0.0.1 --port 8000

Feature Comparison

Text Windows

ProfanityTwo-Face
main windowtext widget with streams = ["main"]
thoughts windowtext widget with streams = ["thoughts"]
Multiple windowsMultiple text widgets

Progress Bars

ProfanityTwo-Face
Built-in health barprogress widget with data_source = "vitals.health"
Fixed appearanceCustomizable colors and style

Compass

ProfanityTwo-Face
ASCII compasscompass widget with style = "ascii" or "unicode"
Fixed locationAny position

Step-by-Step Migration

1. Document Your Profanity Setup

List:

  • Window positions
  • Macros
  • Triggers
  • Highlights
  • Color preferences

2. Create Two-Face Config Directory

mkdir -p ~/.two-face

3. Create Layout

Translate window positions to layout.toml:

# Approximate your Profanity layout

[[widgets]]
type = "text"
name = "main"
x = 0
y = 0
width = 80
height = 80
streams = ["main", "room"]

# Add other widgets...

4. Create Colors

Translate color preferences:

[theme]
background = "#000000"
text = "#c0c0c0"
health = "#00ff00"
# Add more...

5. Migrate Macros

# keybinds.toml

[keybinds."f1"]
macro = "attack target"

[keybinds."f2"]
macro = "hide"

# Add more...

6. Migrate Triggers

# triggers.toml

[[triggers]]
name = "stun_alert"
pattern = "You are stunned"
command = ".notify STUNNED!"

# Add more...

7. Migrate Highlights

# highlights.toml

[[highlights]]
pattern = "stunned"
fg = "bright_yellow"
bold = true

# Add more...

Common Adjustments

Layout Units

Profanity uses character columns/rows. Two-Face uses percentages (0-100).

Converting:

  • Estimate percentage of screen
  • Or: (columns / terminal_width) * 100

Regex Patterns

Both support regex, but syntax may vary:

  • Test patterns in Two-Face
  • Adjust escaping as needed

Macro Syntax

ProfanityTwo-Face
; separates commands; separates commands
$input for prompt$input for prompt
Delays{ms} for delays

What’s New in Two-Face

Features you might not have in Profanity:

  • Tabbed windows: Multiple tabs in one widget
  • Unicode compass: Modern arrow characters
  • Percentage layout: Adapts to terminal size
  • Active effects widget: Track spell durations
  • Browser system: Popup editors

Troubleshooting

Macros Not Working

  • Check key name format ("f1" not "F1")
  • Verify TOML syntax (quotes around keys with special chars)

Colors Different

  • Two-Face uses hex colors or preset names
  • Verify color name mappings

Layout Looks Wrong

  • Check percentage calculations
  • Widgets shouldn’t overlap (x + width ≤ 100)

See Also

Migrating from Wizard Front End (WFE)

Guide for WFE users transitioning to Two-Face.

Overview

WFE (Wizard Front End) is a graphical Windows client. Two-Face is a terminal-based client, so the visual experience differs, but functionality translates.

Key Differences

AspectWFETwo-Face
PlatformWindows GUITerminal (cross-platform)
InterfaceGraphical windowsText-based widgets
ScriptingBuilt-inVia Lich
ConfigurationGUI settingsTOML files

Feature Mapping

Windows to Widgets

WFE WindowTwo-Face Widget
Main Game Windowtext widget
Inventory Windowinventory widget
Status Windowindicator widget
Compasscompass widget
Health/Mana Barsprogress widgets

Configuration

WFETwo-Face
Settings dialogsconfig.toml
Window layoutlayout.toml
Color settingscolors.toml
Macroskeybinds.toml

Macros Translation

WFE Macro

F1 = attack target
Ctrl+1 = prep 101;cast

Two-Face Keybind

[keybinds."f1"]
macro = "attack target"

[keybinds."ctrl+1"]
macro = "prep 101;cast"

Macro Variables

WFETwo-Face
%0 (input)$input
%target$target
Pause 500ms{500}

Highlights Translation

WFE Highlight

Text: "stunned" - Color: Yellow, Bold

Two-Face Highlight

[[highlights]]
pattern = "stunned"
fg = "bright_yellow"
bold = true

Triggers Translation

WFE Trigger

Pattern: "You are stunned"
Action: Play sound stun.wav

Two-Face Trigger

[[triggers]]
pattern = "You are stunned"
command = ".sound stun.wav"

Layout Translation

WFE uses absolute pixel positions. Two-Face uses percentages.

WFE Layout Concept

Main Window: 0,0 - 600x400
Side Panel: 605,0 - 200x400

Two-Face Equivalent

# Estimate percentages

[[widgets]]
type = "text"
name = "main"
x = 0
y = 0
width = 75
height = 90

[[widgets]]
type = "indicator"
name = "status"
x = 76
y = 0
width = 24
height = 20

Step-by-Step Migration

1. Document WFE Settings

Note:

  • Window sizes and positions
  • Macro assignments
  • Highlight patterns
  • Trigger patterns
  • Color preferences

2. Install Two-Face

Follow installation guide.

3. Set Up Lich Connection

If you weren’t using Lich with WFE, you’ll need to:

  1. Install Lich
  2. Configure Lich with your credentials
  3. Connect Two-Face through Lich
two-face --host 127.0.0.1 --port 8000

Or use direct mode:

two-face --direct --account USER --password PASS --game prime --character CHAR

4. Create Basic Layout

# ~/.two-face/layout.toml

# Main window
[[widgets]]
type = "text"
name = "main"
x = 0
y = 0
width = 70
height = 85
streams = ["main", "room", "combat"]

# Health bar
[[widgets]]
type = "progress"
name = "health"
title = "HP"
x = 71
y = 0
width = 29
height = 4
data_source = "vitals.health"
color = "health"

# Mana bar
[[widgets]]
type = "progress"
name = "mana"
title = "MP"
x = 71
y = 5
width = 29
height = 4
data_source = "vitals.mana"
color = "mana"

# Compass
[[widgets]]
type = "compass"
name = "compass"
x = 71
y = 10
width = 29
height = 12
style = "unicode"

# Command input
[[widgets]]
type = "command_input"
name = "input"
x = 0
y = 86
width = 100
height = 14

5. Migrate Macros

# ~/.two-face/keybinds.toml

# Movement
[keybinds."numpad8"]
macro = "north"

[keybinds."numpad2"]
macro = "south"

# Combat
[keybinds."f1"]
macro = "attack target"

# Spells
[keybinds."ctrl+1"]
macro = "prep 101;cast"

6. Migrate Colors

# ~/.two-face/colors.toml

[theme]
background = "#000000"
text = "#ffffff"

health = "#00ff00"
mana = "#0000ff"
stamina = "#ffff00"

combat = "#ff0000"
speech = "#00ffff"

7. Migrate Triggers

# ~/.two-face/triggers.toml

[[triggers]]
name = "stun_alert"
pattern = "You are stunned"
command = ".notify STUNNED!"
cooldown = 1000

[[triggers]]
name = "death_search"
pattern = "falls dead"
command = "search"
enabled = false  # Enable when ready

Visual Differences

GUI vs Terminal

WFE’s graphical interface provides:

  • Clickable buttons
  • Drag-and-drop
  • Visual menus

Two-Face’s terminal interface provides:

  • Keyboard-centric operation
  • Works over SSH
  • Lower resource usage

Adapting Workflow

WFE ActionTwo-Face Action
Click compass directionNumpad navigation
Right-click context menu.cmdlist system
Toolbar buttonsFunction key macros
Settings dialogEdit TOML files

Scripting Differences

WFE Built-in Scripting

WFE has built-in scripting capabilities.

Two-Face + Lich

Two-Face relies on Lich for scripting:

# In game with Lich
;script_name      # Run script
;kill script      # Stop script
;list             # List scripts

What You Gain

Moving to Two-Face:

  • Cross-platform: Works on Windows, macOS, Linux
  • SSH access: Play remotely via terminal
  • Customizability: Deep configuration options
  • Modern rendering: Unicode support
  • Open source: Community improvements

What You Lose

  • Visual GUI: No graphical interface
  • Built-in scripting: Must use Lich
  • Click-based operation: Keyboard-focused

Tips for GUI Users

Learn Terminal Basics

  • Arrow keys navigate
  • Tab cycles widgets
  • Page Up/Down scrolls
  • Type commands directly

Embrace Keyboard

Most efficient Two-Face users:

  • Memorize key macros
  • Use numpad for movement
  • Minimize mouse usage

Gradual Transition

Consider running both clients initially while learning Two-Face.

See Also

Migrating from StormFront

Guide for StormFront users transitioning to Two-Face.

Overview

StormFront is Simutronics’ official graphical client. Two-Face is an alternative terminal-based client with different strengths.

Key Differences

AspectStormFrontTwo-Face
TypeOfficial GUI clientThird-party terminal
PlatformWindows/macOSCross-platform terminal
ScriptingLimitedVia Lich
CustomizationLimitedExtensive
PriceFreeFree

Feature Comparison

Windows

StormFrontTwo-Face
Game Windowtext widget
Thoughts Windowtext widget (thoughts stream)
Inventory Panelinventory widget
Status Barindicator widget

Visual Elements

StormFrontTwo-Face
Health/Mana barsprogress widgets
Compass rosecompass widget
Character portraitNot supported
Spell iconsactive_effects widget

Why Switch?

Advantages of Two-Face

  • Lich scripting: Full automation capability
  • Deep customization: Complete control over appearance
  • Terminal access: Play over SSH
  • Cross-platform: Works everywhere
  • Lightweight: Low resource usage

What You Keep

  • Game experience
  • Character data
  • Account access
  • In-game settings

What Changes

  • Visual appearance
  • Configuration method
  • Scripting approach

Step-by-Step Migration

1. Choose Connection Method

Option A: Lich Proxy (Recommended)

Install Lich for scripting and automation:

  1. Install Lich and Ruby
  2. Configure Lich with your account
  3. Connect Two-Face through Lich

Option B: Direct Connection

Connect without Lich (no scripts):

two-face --direct \
  --account YOUR_ACCOUNT \
  --password YOUR_PASSWORD \
  --game prime \
  --character CHARACTER

2. Install Two-Face

Follow installation guide.

3. Create Configuration

Generate default configuration:

mkdir -p ~/.two-face
two-face --dump-config > ~/.two-face/config.toml

4. Create Layout

Approximate StormFront layout:

# ~/.two-face/layout.toml

# Room name at top
[[widgets]]
type = "room"
name = "room"
x = 0
y = 0
width = 100
height = 4
show_exits = true

# Main game text
[[widgets]]
type = "text"
name = "main"
x = 0
y = 4
width = 70
height = 55
streams = ["main", "room"]

# Thoughts/ESP window
[[widgets]]
type = "text"
name = "thoughts"
title = "Thoughts"
x = 0
y = 60
width = 70
height = 30
streams = ["thoughts"]

# Right sidebar - vitals
[[widgets]]
type = "progress"
name = "health"
title = "Health"
x = 71
y = 4
width = 29
height = 4
data_source = "vitals.health"
color = "health"
show_text = true

[[widgets]]
type = "progress"
name = "mana"
title = "Mana"
x = 71
y = 9
width = 29
height = 4
data_source = "vitals.mana"
color = "mana"
show_text = true

[[widgets]]
type = "progress"
name = "stamina"
title = "Stamina"
x = 71
y = 14
width = 29
height = 4
data_source = "vitals.stamina"
color = "stamina"
show_text = true

# Compass
[[widgets]]
type = "compass"
name = "compass"
x = 71
y = 19
width = 29
height = 12
style = "unicode"
clickable = true

# Status indicators
[[widgets]]
type = "indicator"
name = "status"
x = 71
y = 32
width = 29
height = 10
indicators = ["hidden", "stunned", "prone"]

# Active spells
[[widgets]]
type = "active_effects"
name = "spells"
title = "Spells"
x = 71
y = 43
width = 29
height = 20
show_duration = true

# Command input
[[widgets]]
type = "command_input"
name = "input"
x = 0
y = 91
width = 100
height = 9
prompt = "> "

5. Set Up Keybinds

StormFront uses basic keybinds. Expand with Two-Face:

# ~/.two-face/keybinds.toml

# Movement (numpad)
[keybinds."numpad8"]
macro = "north"

[keybinds."numpad2"]
macro = "south"

[keybinds."numpad4"]
macro = "west"

[keybinds."numpad6"]
macro = "east"

[keybinds."numpad7"]
macro = "northwest"

[keybinds."numpad9"]
macro = "northeast"

[keybinds."numpad1"]
macro = "southwest"

[keybinds."numpad3"]
macro = "southeast"

[keybinds."numpad5"]
macro = "out"

# Quick commands
[keybinds."f1"]
macro = "look"

[keybinds."f2"]
macro = "inventory"

[keybinds."f3"]
macro = "experience"

# Combat
[keybinds."f5"]
macro = "attack target"

[keybinds."f6"]
macro = "hide"

# Navigation
[keybinds."tab"]
action = "next_widget"

[keybinds."page_up"]
action = "scroll_up"

[keybinds."page_down"]
action = "scroll_down"

6. Configure Highlights

# ~/.two-face/highlights.toml

# Critical status
[[highlights]]
pattern = "(?i)you are stunned"
fg = "black"
bg = "yellow"
bold = true

[[highlights]]
pattern = "(?i)webs? (stick|entangle)"
fg = "black"
bg = "magenta"
bold = true

# Combat
[[highlights]]
pattern = "\\*\\* .+ \\*\\*"
fg = "bright_red"
bold = true

# Social
[[highlights]]
pattern = "(\\w+) whispers,"
fg = "magenta"

Visual Adaptation

StormFront Visual Elements

StormFront provides:

  • Graphical status bars
  • Clickable compass
  • Character portrait
  • Spell icons

Two-Face Equivalents

Two-Face provides text-based versions:

  • ASCII/Unicode progress bars
  • Clickable text compass
  • Status text indicators
  • Spell name lists

Missing Features

Some StormFront features don’t have equivalents:

  • Character portrait
  • Graphical inventory icons
  • Animated status effects

Gaining Lich Scripts

StormFront users often don’t use Lich. Two-Face + Lich provides:

  • go2 - Automatic navigation
  • bigshot - Hunting automation
  • lnet - Player network
  • repository - Script management

Installing Scripts

# In-game with Lich
;repository download go2
;go2 bank

What You Gain

  • Customizable interface: Complete layout control
  • Powerful scripting: Full Lich ecosystem
  • Terminal access: Play anywhere
  • Lower resources: Faster, lighter
  • Community support: Active development

Tips for StormFront Users

Terminal Navigation

  • Arrow keys and numpad work intuitively
  • Tab cycles between widgets
  • Type commands directly in input

Learn Keyboard Shortcuts

Memorize key bindings for efficiency:

  • F-keys for common commands
  • Numpad for movement
  • Ctrl combinations for macros

Explore Lich

Lich transforms gameplay:

  1. Start with go2 for navigation
  2. Try utility scripts
  3. Gradually automate routine tasks

See Also

Version History

Release notes, breaking changes, and upgrade instructions.

Current Version

Version: 0.1.0 (Development)

Changelog Format

Each release includes:

  • New Features: New functionality
  • Improvements: Enhancements to existing features
  • Bug Fixes: Resolved issues
  • Breaking Changes: Changes requiring user action
  • Known Issues: Outstanding problems

Version 0.1.0 (Planned)

Initial public release.

New Features

  • Core application framework
  • Lich proxy connection mode
  • Direct eAccess authentication
  • Widget system with 15+ widget types
  • TOML-based configuration
  • Keybind and macro system
  • Trigger automation
  • Text highlighting
  • Color themes
  • TTS support

Widget Types

  • text - Scrollable text windows
  • tabbed_text - Tabbed text windows
  • progress - Vital bars
  • countdown - Roundtime/casttime
  • compass - Navigation compass
  • indicator - Status indicators
  • room - Room information
  • command_input - Command entry
  • injury_doll - Injury display
  • active_effects - Active spells/effects
  • inventory - Inventory view
  • spells - Known spells
  • hand - Hand contents
  • dashboard - Composite status
  • performance - Performance metrics

Supported Platforms

  • Windows 10/11
  • macOS 12+
  • Linux (glibc 2.31+)

Known Issues

  • Direct mode certificate handling edge cases
  • Some Unicode rendering issues on older terminals

Upgrading

General Upgrade Process

  1. Backup configuration:

    cp -r ~/.two-face ~/.two-face.backup
    
  2. Download new version

  3. Replace binary

  4. Review release notes for breaking changes

  5. Update configuration if needed

  6. Test before deleting backup

Checking Version

two-face --version

Configuration Compatibility

Configuration files are versioned. When format changes:

  1. Two-Face will warn about outdated config
  2. Auto-migration may be attempted
  3. Manual migration instructions provided if needed

Breaking Changes Guide

Configuration Format Changes

If a configuration key is renamed or restructured:

Before:

[old_section]
old_key = "value"

After:

[new_section]
new_key = "value"

Migration: Update your config files manually or run:

two-face --migrate-config

Keybind Changes

If keybind syntax changes:

Before:

[keybinds.F1]
command = "look"

After:

[keybinds."f1"]
macro = "look"

Widget Changes

If widget types or properties change:

Before:

[[widgets]]
type = "old_type"
old_property = "value"

After:

[[widgets]]
type = "new_type"
new_property = "value"

Deprecation Policy

Deprecation Timeline

  1. Announcement: Feature marked deprecated in release notes
  2. Warning Period: Deprecated features log warnings
  3. Removal: Feature removed in future major version

Deprecation Notices

Deprecated features are logged:

[WARN] 'old_feature' is deprecated, use 'new_feature' instead

Development Versions

Pre-release Tags

  • alpha - Early testing, unstable
  • beta - Feature complete, testing
  • rc - Release candidate, final testing

Example: 0.2.0-beta.1

Building from Source

For development versions:

git clone https://github.com/two-face/two-face
cd two-face
git checkout develop
cargo build --release

Reporting Issues

When Upgrading

If you encounter issues after upgrading:

  1. Check release notes for breaking changes
  2. Verify configuration is valid
  3. Try with default configuration
  4. Check Troubleshooting
  5. Report on GitHub if unresolved

Issue Template

**Version**: [e.g., 0.1.0]
**Previous Version**: [if upgrade issue]
**Platform**: [Windows/macOS/Linux]

**Description**:
[What happened]

**Expected**:
[What should happen]

**Steps**:
1. [Step 1]
2. [Step 2]

**Configuration** (if relevant):
```toml
[relevant config]

---

## Future Roadmap

### Planned Features

- Advanced scripting integration
- Plugin system
- More widget types
- Enhanced accessibility
- Performance improvements

### Community Requests

Track feature requests on GitHub Issues.

---

## Version Support

### Support Matrix

| Version | Status | Support |
|---------|--------|---------|
| 0.1.x | Current | Full support |
| Pre-release | Development | Limited |

### End of Life

When a version reaches EOL:
- Security patches may cease
- No new features
- Upgrade recommended

---

## See Also

- [Installation](../getting-started/installation.md)
- [Configuration](../configuration/README.md)
- [Troubleshooting](../troubleshooting/README.md)

Troubleshooting

Diagnosis and solutions for common Two-Face issues.

Quick Diagnosis

Issue Categories

SymptomLikely CauseGo To
Won’t startMissing dependency, bad configCommon Errors
Can’t connectNetwork, firewall, credentialsConnection Issues
Slow/laggyPerformance settings, system loadPerformance Issues
Colors wrongTheme, terminal settingsDisplay Issues
Platform-specificOS configurationPlatform Issues

First Steps

Before diving into specific issues, try these general steps:

1. Check Logs

# View recent logs
tail -100 ~/.two-face/two-face.log

# Watch logs in real-time
tail -f ~/.two-face/two-face.log

2. Verify Configuration

# Validate config syntax
two-face --check-config

# Start with defaults (bypass config issues)
two-face --default-config

3. Update Two-Face

# Check current version
two-face --version

# Download latest release from GitHub

4. Check Dependencies

# Linux - verify libraries
ldd $(which two-face)

# macOS - check dylibs
otool -L $(which two-face)

Log Levels

Increase logging for diagnosis:

# config.toml
[logging]
level = "debug"  # trace, debug, info, warn, error
file = "~/.two-face/two-face.log"

Log level details:

  • error - Only critical failures
  • warn - Warnings and errors
  • info - Normal operation (default)
  • debug - Detailed operation
  • trace - Very verbose (large files)

Common Patterns

Configuration Issues

Symptom: Two-Face exits immediately or shows “config error”

Diagnosis:

# Check for TOML syntax errors
two-face --check-config 2>&1 | head -20

Common causes:

  • Missing quotes around strings with special characters
  • Unclosed brackets or braces
  • Invalid key names
  • Wrong data types

Connection Issues

Symptom: “Connection refused” or timeout

Diagnosis:

# Test Lich connection
nc -zv 127.0.0.1 8000

# Test direct connection
nc -zv eaccess.play.net 7910

Display Issues

Symptom: Missing colors, garbled text, wrong symbols

Diagnosis:

# Check terminal capabilities
echo $TERM
tput colors

# Test Unicode support
echo "╔═══╗ ← Should show box drawing"

Getting Help

Information to Collect

When reporting issues, include:

  1. Version: two-face --version
  2. Platform: Windows/macOS/Linux version
  3. Terminal: What terminal emulator
  4. Config: Relevant config sections (redact credentials!)
  5. Logs: Recent log entries
  6. Steps: How to reproduce

Report Template

**Version**: 0.1.0
**Platform**: Ubuntu 22.04
**Terminal**: Alacritty 0.12

**Description**:
What happened

**Expected**:
What should happen

**Steps to Reproduce**:
1. Start Two-Face with...
2. Do this...
3. See error

**Relevant Config**:
```toml
[connection]
mode = "lich"

Log Output:

[ERROR] relevant log lines

### Support Channels

1. **GitHub Issues**: Bug reports and feature requests
2. **GitHub Discussions**: Questions and help
3. **Documentation**: Check relevant sections first

## Troubleshooting Guides

### By Issue Type

- [Common Errors](./common-errors.md) - Error messages and solutions
- [Platform Issues](./platform-issues.md) - OS-specific problems
- [Performance Issues](./performance-issues.md) - Speed and responsiveness
- [Display Issues](./display-issues.md) - Visual problems
- [Connection Issues](./connection-issues.md) - Network problems

### By Symptom

| I see... | Check |
|----------|-------|
| "Config error" | [Common Errors](./common-errors.md#configuration-errors) |
| "Connection refused" | [Connection Issues](./connection-issues.md#connection-refused) |
| "Certificate error" | [Connection Issues](./connection-issues.md#certificate-issues) |
| Garbled text | [Display Issues](./display-issues.md#character-encoding) |
| Wrong colors | [Display Issues](./display-issues.md#color-problems) |
| Slow scrolling | [Performance Issues](./performance-issues.md#scroll-performance) |
| High CPU | [Performance Issues](./performance-issues.md#cpu-usage) |
| Crash on start | [Platform Issues](./platform-issues.md) |

## Quick Fixes

### Reset to Defaults

```bash
# Backup current config
mv ~/.two-face ~/.two-face.backup

# Start fresh - Two-Face creates defaults
two-face

Force Reconnect

In Two-Face:

.reconnect

Or restart:

# Kill existing process
pkill two-face

# Start fresh
two-face

Clear Cache

# Remove cached data
rm -rf ~/.two-face/cache/

# Remove certificate (re-downloads)
rm ~/.two-face/simu.pem

Diagnostic Mode

Start with extra diagnostics:

# Maximum verbosity
TF_LOG=trace two-face --debug

# Log to specific file
TF_LOG_FILE=/tmp/tf-debug.log two-face

See Also

Common Errors

Error messages, their meanings, and solutions.

Configuration Errors

“Failed to parse config”

Message:

Error: Failed to parse config.toml: expected `=` at line 15

Cause: TOML syntax error

Solution:

  1. Check the indicated line number
  2. Look for:
    • Missing = in key-value pairs
    • Unclosed quotes
    • Missing brackets for sections/arrays

Example fix:

# Wrong
[connection]
mode lich          # Missing =

# Correct
[connection]
mode = "lich"

“Unknown key”

Message:

Error: Unknown key 'colour' in config.toml at line 12

Cause: Misspelled or deprecated key name

Solution: Check the Configuration Reference for correct key names

Common misspellings:

  • colourcolor
  • colour_themecolor (in theme context)
  • keybindkeybinds

“Invalid value type”

Message:

Error: Invalid type for 'port': expected integer, found string

Cause: Wrong data type for configuration value

Solution: Use correct types:

# Wrong
port = "8000"      # String when integer expected
enabled = 1        # Integer when boolean expected

# Correct
port = 8000        # Integer
enabled = true     # Boolean

“Missing required field”

Message:

Error: Missing required field 'type' in widget definition

Cause: Required configuration field not specified

Solution: Add the missing field

# Wrong - missing type
[[widgets]]
name = "main"

# Correct
[[widgets]]
type = "text"
name = "main"

Startup Errors

“Failed to initialize terminal”

Message:

Error: Failed to initialize terminal: not a tty

Cause: Running in non-interactive environment

Solution:

  • Run in a proper terminal emulator
  • Don’t pipe output: use two-face not two-face | less
  • Ensure stdin is connected to a terminal

“Could not determine terminal size”

Message:

Error: Could not determine terminal size

Cause: Terminal doesn’t report dimensions

Solution:

  1. Try a different terminal emulator
  2. Set size manually:
    stty rows 50 cols 120
    two-face
    
  3. Check TERM environment variable:
    echo $TERM
    # Should be something like xterm-256color
    

“Failed to load font”

Message:

Error: Failed to load font: font not found

Cause: Terminal can’t render required characters

Solution:

  • Install a font with Unicode support (Nerd Font, JetBrains Mono)
  • Or disable Unicode in config:
    [display]
    unicode = false
    

Connection Errors

“Connection refused”

Message:

Error: Connection refused (os error 111)

Cause:

  • Lich not running (Lich mode)
  • Wrong host/port
  • Firewall blocking

Solution:

For Lich mode:

# Start Lich first
ruby lich.rb

# Then connect
two-face --host 127.0.0.1 --port 8000

For Direct mode:

# Check internet connectivity
ping eaccess.play.net

# Check port access
nc -zv eaccess.play.net 7910

“Connection timed out”

Message:

Error: Connection timed out after 30 seconds

Cause: Network issue preventing connection

Solution:

  1. Check internet connectivity
  2. Verify firewall settings
  3. Try increasing timeout:
    [connection]
    timeout = 60
    

“Authentication failed”

Message:

Error: Authentication failed: invalid credentials

Cause: Wrong account or password (Direct mode)

Solution:

  • Verify credentials work via Lich or web
  • Check for typos
  • Ensure account is active
  • Try password without special characters

“Certificate verification failed”

Message:

Error: Certificate verification failed: certificate has expired

Cause: Cached certificate is outdated or corrupt

Solution:

# Remove cached certificate
rm ~/.two-face/simu.pem

# Reconnect - will download fresh certificate
two-face --direct ...

Runtime Errors

“Widget not found”

Message:

Error: Widget 'health' not found in layout

Cause: Configuration references non-existent widget

Solution: Ensure widget is defined in layout:

# Make sure this exists
[[widgets]]
type = "progress"
name = "health"

“Invalid regex pattern”

Message:

Error: Invalid regex in highlights.toml: unclosed group at position 5

Cause: Malformed regular expression

Solution:

  1. Check regex syntax
  2. Escape special characters properly
  3. Test pattern with regex tester

Common regex fixes:

# Wrong - unescaped special chars
pattern = "You (get|take"    # Unclosed group

# Correct
pattern = "You (get|take)"   # Closed group

# Wrong - unescaped brackets
pattern = "[Player]"         # Character class

# Correct
pattern = "\\[Player\\]"     # Literal brackets

“Stream buffer overflow”

Message:

Warning: Stream buffer overflow, dropping oldest entries

Cause: Too much data incoming, buffer full

Solution:

# Increase buffer size
[performance]
stream_buffer_size = 100000

# Or reduce scrollback
[[widgets]]
type = "text"
scrollback = 1000  # Reduce from default

“Render timeout”

Message:

Warning: Render timeout - frame dropped

Cause: UI can’t keep up with updates

Solution:

[performance]
render_rate = 30      # Reduce from 60
batch_updates = true  # Combine updates
lazy_render = true    # Skip unchanged

Widget Errors

“Widget overlap”

Message:

Warning: Widget 'status' overlaps with 'health'

Cause: Two widgets occupy same screen space

Solution: Adjust widget positions:

# Check x, y, width, height don't overlap
[[widgets]]
type = "progress"
name = "health"
x = 0
y = 0
width = 20
height = 3

[[widgets]]
type = "indicator"
name = "status"
x = 0
y = 3        # Start after health ends
width = 20
height = 5

“Widget outside bounds”

Message:

Error: Widget 'compass' extends beyond terminal (x=90, width=20, terminal=100)

Cause: Widget doesn’t fit in terminal

Solution:

  • Use percentage-based positioning
  • Make widget smaller
  • Check terminal size

“Invalid data source”

Message:

Error: Invalid data_source 'vital.health' for widget 'hp_bar'

Cause: Data source path doesn’t exist

Solution: Use valid data sources:

# Wrong
data_source = "vital.health"

# Correct
data_source = "vitals.health"

Valid sources: vitals.health, vitals.mana, vitals.stamina, vitals.spirit, roundtime, casttime

Keybind Errors

“Invalid key name”

Message:

Error: Invalid key name 'Control+F' in keybinds.toml

Cause: Wrong key naming convention

Solution: Use correct format:

# Wrong
[keybinds."Control+F"]

# Correct
[keybinds."ctrl+f"]

Key naming rules:

  • Modifiers: ctrl, alt, shift (lowercase)
  • Separator: +
  • Keys: lowercase (f1, enter, space)

“Duplicate keybind”

Message:

Warning: Duplicate keybind 'ctrl+f' - later definition wins

Cause: Same key defined multiple times

Solution: Remove duplicates or use different keys

“Unknown action”

Message:

Error: Unknown action 'focusInput' in keybind

Cause: Invalid action name

Solution: Check Keybind Actions for valid actions

# Wrong
action = "focusInput"

# Correct
action = "focus_input"

Recovery Steps

General Recovery

  1. Start with defaults:

    two-face --default-config
    
  2. Isolate the problem:

    # Test each config file
    two-face --config ~/.two-face/config.toml --no-layout
    two-face --config ~/.two-face/config.toml --layout ~/.two-face/layout.toml
    
  3. Binary search configs:

    • Comment out half the config
    • If it works, problem is in commented half
    • Repeat until found

Reset Everything

# Backup
mv ~/.two-face ~/.two-face.backup

# Fresh start
two-face

# Copy back configs one at a time
cp ~/.two-face.backup/config.toml ~/.two-face/
# Test
cp ~/.two-face.backup/layout.toml ~/.two-face/
# Test
# Continue until problem returns

See Also

Platform Issues

Platform-specific problems and solutions for Windows, macOS, and Linux.

Windows

Windows Terminal Issues

Colors Look Wrong

Symptom: Colors appear washed out or wrong in Windows Terminal

Solution:

  1. Enable true color in Windows Terminal settings
  2. Set color scheme to “One Half Dark” or similar
  3. Add to Two-Face config:
    [display]
    color_mode = "truecolor"
    

Characters Display as Boxes

Symptom: Unicode characters show as □ or ?

Solution:

  1. Install a font with full Unicode support:

    • Cascadia Code
    • JetBrains Mono
    • Nerd Font variants
  2. Set the font in Windows Terminal settings

  3. Or disable Unicode in Two-Face:

    [display]
    unicode = false
    

Slow Startup

Symptom: Two-Face takes several seconds to start

Solution:

  1. Check antivirus exclusions - add Two-Face executable
  2. Disable Windows Defender real-time scanning for config directory
  3. Run from SSD rather than HDD

PowerShell Issues

Keybinds Not Working

Symptom: Certain key combinations don’t register

Cause: PowerShell intercepts some keys

Solution:

  1. Use Windows Terminal instead of PowerShell directly
  2. Or disable PSReadLine:
    Remove-Module PSReadLine
    

Copy/Paste Issues

Symptom: Can’t paste into Two-Face

Solution:

  1. Use Ctrl+Shift+V instead of Ctrl+V
  2. Or enable “Use Ctrl+Shift+C/V as Copy/Paste” in terminal settings

WSL Issues

Connection to Lich Fails

Symptom: Can’t connect to Lich running in Windows from WSL

Solution:

# In WSL, use Windows host IP
two-face --host $(cat /etc/resolv.conf | grep nameserver | awk '{print $2}') --port 8000

# Or use localhost forwarding
two-face --host localhost --port 8000

Performance Issues

Symptom: Slow or laggy in WSL

Solution:

  1. Use WSL2 (not WSL1)
  2. Store files in Linux filesystem, not /mnt/c/
  3. Increase WSL memory allocation in .wslconfig

OpenSSL on Windows

“Can’t find OpenSSL”

Symptom: Direct mode fails with OpenSSL errors

Solution:

  1. Install via vcpkg:

    vcpkg install openssl:x64-windows
    
  2. Set environment variable:

    set VCPKG_ROOT=C:\path\to\vcpkg
    
  3. Rebuild Two-Face

macOS

Terminal.app Issues

Limited Colors

Symptom: Only 256 colors, not full truecolor

Solution:

  1. Use iTerm2 or Alacritty instead of Terminal.app
  2. Or configure for 256 colors:
    [display]
    color_mode = "256"
    

Function Keys Don’t Work

Symptom: F1-F12 keys trigger macOS features instead

Solution:

  1. System Preferences → Keyboard → “Use F1, F2, etc. keys as standard function keys”
  2. Or use modifier:
    [keybinds."fn+f1"]
    macro = "look"
    

iTerm2 Issues

Mouse Not Working

Symptom: Can’t click on widgets or compass

Solution:

  1. iTerm2 → Preferences → Profiles → Terminal
  2. Enable “Report mouse clicks”
  3. Enable “Report mouse wheel events”

Scrollback Conflict

Symptom: Page Up/Down scrolls iTerm instead of Two-Face

Solution:

  1. iTerm2 → Preferences → Keys → Key Bindings
  2. Remove or remap Page Up/Down
  3. Or use Two-Face scrollback keys:
    [keybinds."shift+up"]
    action = "scroll_up"
    

Security Issues

“Cannot be opened because the developer cannot be verified”

Symptom: macOS Gatekeeper blocks Two-Face

Solution:

# Remove quarantine attribute
xattr -d com.apple.quarantine /path/to/two-face

# Or allow in System Preferences → Security & Privacy

“Permission denied” for Audio

Symptom: TTS features fail

Solution:

  1. System Preferences → Security & Privacy → Privacy → Microphone
  2. Add Terminal/iTerm2
  3. Also check Accessibility permissions

Apple Silicon (M1/M2)

Rosetta Performance

Symptom: Two-Face slow on Apple Silicon

Cause: Running x86 binary through Rosetta

Solution:

  • Download ARM64/aarch64 build if available
  • Or build from source:
    rustup target add aarch64-apple-darwin
    cargo build --release --target aarch64-apple-darwin
    

Linux

Distribution-Specific Issues

Ubuntu/Debian

Missing Libraries:

# Install required libraries
sudo apt install libssl-dev pkg-config

# For audio (TTS)
sudo apt install libasound2-dev

Wayland Issues:

# If running Wayland, may need XWayland for some features
sudo apt install xwayland

Fedora/RHEL

Missing Libraries:

sudo dnf install openssl-devel pkg-config

# For audio
sudo dnf install alsa-lib-devel

Arch Linux

Missing Libraries:

sudo pacman -S openssl pkgconf

# For audio
sudo pacman -S alsa-lib

Terminal Emulator Issues

Alacritty

Font Rendering:

# ~/.config/alacritty/alacritty.yml
font:
  normal:
    family: "JetBrains Mono"
  size: 11

Key Bindings Conflict:

  • Check ~/.config/alacritty/alacritty.yml for conflicting bindings

Kitty

Graphics Protocol:

# ~/.config/kitty/kitty.conf
# Disable if causing issues
allow_remote_control no

Unicode:

symbol_map U+E000-U+F8FF Symbols Nerd Font

GNOME Terminal

True Color:

# Verify support
echo $COLORTERM  # Should show "truecolor"

# If not, set in .bashrc
export COLORTERM=truecolor

X11 vs Wayland

Clipboard Issues

Symptom: Copy/paste doesn’t work

X11 Solution:

# Install xclip
sudo apt install xclip

Wayland Solution:

# Install wl-clipboard
sudo apt install wl-clipboard

# Set in config
[clipboard]
backend = "wayland"  # or "x11"

Audio Issues (TTS)

PulseAudio

Symptom: TTS doesn’t play sound

Solution:

# Check PulseAudio is running
pulseaudio --check

# Restart if needed
pulseaudio --kill
pulseaudio --start

PipeWire

Symptom: Audio issues on modern systems

Solution:

# Install PipeWire PulseAudio compatibility
sudo apt install pipewire-pulse

# Restart
systemctl --user restart pipewire pipewire-pulse

Permission Issues

“Operation not permitted”

Symptom: Can’t write to config directory

Solution:

# Check ownership
ls -la ~/.two-face

# Fix if needed
sudo chown -R $USER:$USER ~/.two-face
chmod 755 ~/.two-face
chmod 644 ~/.two-face/*.toml

SELinux Blocking

Symptom: Works as root but not as user (Fedora/RHEL)

Solution:

# Check if SELinux is blocking
sudo ausearch -m avc -ts recent

# Create policy exception if needed
# (Consult SELinux documentation)

Cross-Platform Issues

Locale/Encoding

“Invalid UTF-8”

Symptom: Errors about encoding

Solution:

# Check locale
locale

# Set UTF-8 locale
export LC_ALL=en_US.UTF-8
export LANG=en_US.UTF-8

Time Zone Issues

Timestamps Wrong

Symptom: Log timestamps incorrect

Solution:

# Check timezone
date

# Set if needed
export TZ="America/New_York"

Or configure:

[logging]
use_local_time = true

Path Issues

“File not found” for Config

Symptom: Can’t find config files

Solution:

  • Use explicit paths:
    two-face --config /full/path/to/config.toml
    
  • Check $HOME is set correctly
  • Verify ~ expansion works in your shell

See Also

Performance Issues

Diagnosing and fixing slow, laggy, or resource-intensive behavior.

Symptoms

IssueLikely CauseQuick Fix
Slow scrollingLarge scrollbackReduce scrollback
Input delayRender bottleneckLower render_rate
High CPUBusy loop, bad patternCheck highlights
High memoryScrollback accumulationLimit buffer sizes
Startup slowConfig parsingSimplify config

Performance Configuration

Optimal Settings

[performance]
render_rate = 60          # FPS target (30-120)
batch_updates = true      # Combine rapid updates
lazy_render = true        # Skip unchanged regions
stream_buffer_size = 50000

# Widget defaults
[defaults.text]
scrollback = 2000         # Lines to keep
auto_scroll = true

Low-Resource Settings

For older systems or constrained environments:

[performance]
render_rate = 30          # Lower FPS
batch_updates = true
lazy_render = true
stream_buffer_size = 20000

[defaults.text]
scrollback = 500          # Minimal scrollback

Scroll Performance

Slow Scrolling

Symptom: Page Up/Down is sluggish, mouse scroll lags

Causes:

  1. Too much scrollback content
  2. Complex text styling
  3. Pattern matching on scroll

Solutions:

  1. Reduce scrollback:

    [[widgets]]
    type = "text"
    scrollback = 1000  # Down from 5000+
    
  2. Simplify highlights:

    # Avoid overly complex patterns
    # Bad - matches everything
    pattern = ".*"
    
    # Better - specific pattern
    pattern = "\\*\\* .+ \\*\\*"
    
  3. Use fast patterns:

    [[highlights]]
    pattern = "stunned"
    fast_parse = true  # Use Aho-Corasick
    

Scroll Buffer Memory

Symptom: Memory usage grows over time

Solution:

[[widgets]]
type = "text"
scrollback = 2000
scrollback_limit_action = "trim"  # Remove old content

Input Latency

Typing Delay

Symptom: Characters appear slowly after typing

Causes:

  1. Render blocking input
  2. Network latency
  3. Pattern processing

Solutions:

  1. Priority input processing:

    [input]
    priority = "high"
    buffer_size = 100
    
  2. Reduce render rate during input:

    [performance]
    input_render_rate = 30  # Lower when typing
    render_rate = 60        # Normal rate
    
  3. Check network:

    # Test latency to game server
    ping -c 10 gs4.play.net
    

Command Delay

Symptom: Commands take long to send

Solution:

  1. Check Lich processing time
  2. Disable unnecessary Lich scripts
  3. Try direct mode for comparison

CPU Usage

High CPU at Idle

Symptom: CPU high even when nothing happening

Causes:

  1. Busy render loop
  2. Bad regex pattern
  3. Timer spinning

Diagnosis:

# Profile Two-Face
top -p $(pgrep two-face)

# Check per-thread
htop -p $(pgrep two-face)

Solutions:

  1. Enable lazy rendering:

    [performance]
    lazy_render = true
    idle_timeout = 100  # ms before sleep
    
  2. Check patterns:

    # Avoid catastrophic backtracking
    # Bad
    pattern = "(a+)+"
    
    # Good
    pattern = "a+"
    
  3. Reduce timers:

    [[widgets]]
    type = "countdown"
    update_rate = 100  # ms, not too fast
    

CPU Spikes During Activity

Symptom: CPU spikes during combat/busy scenes

Solutions:

  1. Batch updates:

    [performance]
    batch_updates = true
    batch_threshold = 10  # Combine N updates
    
  2. Limit pattern matching:

    [[highlights]]
    pattern = "complex pattern"
    max_matches_per_line = 10
    
  3. Use simpler widgets:

    # Disable complex widgets during heavy activity
    [widgets.effects]
    auto_hide = true
    hide_threshold = 50  # Updates/second
    

Memory Usage

Memory Growth

Symptom: Memory usage increases continuously

Causes:

  1. Unlimited scrollback
  2. Stream buffer growth
  3. Memory leak (rare)

Solutions:

  1. Limit all buffers:

    [performance]
    max_total_memory = "500MB"
    
    [[widgets]]
    type = "text"
    scrollback = 2000
    
  2. Enable periodic cleanup:

    [performance]
    cleanup_interval = 300  # seconds
    
  3. Monitor memory:

    watch -n 5 'ps -o rss,vsz -p $(pgrep two-face)'
    

Large Scrollback

Problem: Each text window can accumulate megabytes

Solution:

# Global limit
[defaults.text]
scrollback = 1000

# Per-widget override only where needed
[[widgets]]
type = "text"
name = "main"
scrollback = 3000  # Main window can be larger

Startup Performance

Slow Launch

Symptom: Takes several seconds to start

Causes:

  1. Config file parsing
  2. Font loading
  3. Network checks

Solutions:

  1. Simplify config:

    • Split large configs into smaller files
    • Remove unused sections
  2. Skip network check:

    two-face --no-network-check
    
  3. Precompile patterns:

    [highlights]
    precompile = true
    cache_file = "~/.two-face/pattern_cache"
    

Slow Reconnect

Symptom: Reconnection takes long

Solution:

[connection]
reconnect_delay = 1       # Start at 1 second
reconnect_max_delay = 30  # Cap at 30 seconds
reconnect_backoff = 1.5   # Exponential backoff

Network Performance

High Latency

Symptom: Actions feel delayed

Diagnosis:

# Check network latency
ping gs4.play.net
traceroute gs4.play.net

Solutions:

  1. Enable Nagle’s algorithm (if many small packets):

    [network]
    tcp_nodelay = false
    
  2. Or disable for lower latency:

    [network]
    tcp_nodelay = true
    
  3. Buffer commands:

    [input]
    send_delay = 50  # ms between commands
    

Bandwidth Issues

Symptom: Connection drops during heavy traffic

Solution:

[network]
receive_buffer = 65536
send_buffer = 32768

Profiling

Built-in Performance Widget

[[widgets]]
type = "performance"
name = "perf"
x = 0
y = 95
width = 100
height = 5
show = ["fps", "memory", "cpu", "network"]

Enable Timing Logs

[logging]
level = "debug"

[debug]
log_render_time = true
log_parse_time = true
log_pattern_time = true

External Profiling

# Linux - perf
perf record -g two-face
perf report

# macOS - Instruments
xcrun xctrace record --template "Time Profiler" --launch two-face

# Cross-platform - flamegraph
cargo flamegraph --bin two-face

Quick Reference

Performance Checklist

  • Scrollback limited to reasonable size
  • Lazy rendering enabled
  • Batch updates enabled
  • No catastrophic regex patterns
  • Unused widgets disabled
  • Network connection stable
  • System resources adequate

Key Settings Summary

[performance]
render_rate = 60
batch_updates = true
lazy_render = true
stream_buffer_size = 50000

[defaults.text]
scrollback = 2000

[logging]
level = "warn"  # Less logging = more performance

See Also

Display Issues

Solving color, rendering, and visual problems.

Color Problems

Colors Don’t Appear

Symptom: Everything is monochrome

Causes:

  1. Terminal doesn’t support colors
  2. TERM variable incorrect
  3. Color disabled in config

Solutions:

  1. Check terminal support:

    # Check TERM
    echo $TERM
    # Should be xterm-256color, screen-256color, etc.
    
    # Check color count
    tput colors
    # Should be 256 or higher
    
  2. Set correct TERM:

    export TERM=xterm-256color
    
  3. Enable colors in config:

    [display]
    colors = true
    color_mode = "truecolor"  # or "256" or "16"
    

Wrong Colors

Symptom: Colors look different than expected

Causes:

  1. Terminal color scheme conflicts
  2. True color vs 256-color mismatch
  3. Theme file issues

Solutions:

  1. Match color modes:

    # For terminals with true color (24-bit)
    [display]
    color_mode = "truecolor"
    
    # For older terminals
    [display]
    color_mode = "256"
    
  2. Check terminal palette:

    • Terminal color scheme affects named colors
    • Try built-in “Dark” or “Light” preset
    • Use hex colors for consistency:
      [theme]
      background = "#1a1a1a"  # Explicit hex
      
  3. Test colors:

    # Print color test
    for i in {0..255}; do
      printf "\e[48;5;${i}m %3d \e[0m" $i
      [ $((($i + 1) % 16)) -eq 0 ] && echo
    done
    

Washed Out Colors

Symptom: Colors look pale or desaturated

Causes:

  1. Terminal transparency
  2. Bold-as-bright setting
  3. Theme choices

Solutions:

  1. Disable terminal transparency for accurate colors

  2. Adjust terminal settings:

    • Disable “Bold as bright” if colors seem too bright
    • Enable “Bold as bright” if bold text is invisible
  3. Increase color saturation in theme:

    [theme]
    # Use more saturated colors
    health = "#00ff00"    # Bright green
    mana = "#0080ff"      # Bright blue
    

High Contrast / Low Visibility

Symptom: Some text hard to read

Solutions:

[theme]
# Increase contrast
background = "#000000"
text = "#ffffff"
text_dim = "#b0b0b0"  # Brighter dim text

# Use accessible color combinations
# Background: #000000, Text: #ffffff (21:1 contrast)
# Background: #1a1a1a, Text: #e0e0e0 (13:1 contrast)

Character Rendering

Missing Characters (Boxes)

Symptom: Characters display as □, ?, or empty boxes

Cause: Font missing required glyphs

Solutions:

  1. Install complete font:

    • JetBrains Mono
    • Fira Code
    • Cascadia Code
    • Nerd Font variants (include many symbols)
  2. Set fallback font in terminal settings

  3. Disable Unicode:

    [display]
    unicode = false
    ascii_borders = true
    

Wrong Box Drawing Characters

Symptom: Borders look wrong or misaligned

Causes:

  1. Font substitution
  2. Line height issues
  3. Unicode normalization

Solutions:

  1. Use monospace font designed for terminals

  2. Adjust line spacing:

    # Alacritty example
    font:
      offset:
        y: 0  # May need adjustment
    
  3. Use ASCII borders:

    [display]
    border_style = "ascii"
    # Uses +--+ instead of ╔══╗
    

Emoji Display Issues

Symptom: Emoji not rendering or wrong width

Solutions:

  1. Ensure emoji font installed:

    • Noto Color Emoji (Linux)
    • Apple Color Emoji (macOS)
    • Segoe UI Emoji (Windows)
  2. Configure terminal:

    # Alacritty
    font:
      builtin_box_drawing: true
    
  3. Avoid emoji in critical areas:

    [display]
    emoji_support = false  # Use text instead
    

Layout Problems

Widget Overlap

Symptom: Widgets draw over each other

Diagnosis:

# Start with layout debug
two-face --debug-layout

Solutions:

  1. Check coordinates:

    [[widgets]]
    type = "text"
    name = "main"
    x = 0
    y = 0
    width = 70
    height = 85  # Ends at y=85
    
    [[widgets]]
    type = "progress"
    name = "health"
    x = 0
    y = 86       # Start after main ends
    
  2. Use percentage positioning:

    [[widgets]]
    type = "text"
    x = "0%"
    y = "0%"
    width = "70%"
    height = "85%"
    

Widgets Outside Screen

Symptom: Widgets cut off or invisible

Causes:

  1. Fixed positions larger than terminal
  2. Percentage math errors

Solutions:

  1. Use percentages for flexible layouts:

    [[widgets]]
    width = "25%"   # Always fits
    
  2. Handle resize:

    [layout]
    resize_behavior = "scale"  # Rescale widgets
    
  3. Check terminal size:

    echo "Columns: $(tput cols) Rows: $(tput lines)"
    

Wrong Z-Order

Symptom: Popup appears behind other widgets

Solution:

[[widgets]]
type = "text"
name = "popup"
z_index = 100  # Higher = on top

Border Issues

Borders Not Aligned

Symptom: Border corners don’t meet, gaps appear

Causes:

  1. Font character width inconsistency
  2. Terminal cell size
  3. Unicode width calculation

Solutions:

  1. Use consistent font:

    • Pure monospace font
    • Same font for all text
  2. ASCII fallback:

    [display]
    border_style = "ascii"
    
  3. Adjust border characters:

    [theme.borders]
    horizontal = "─"
    vertical = "│"
    top_left = "┌"
    top_right = "┐"
    bottom_left = "└"
    bottom_right = "┘"
    

Double-Width Character Issues

Symptom: CJK characters or emoji break alignment

Solution:

[display]
wide_character_support = true
wcwidth_version = "unicode_15"

Refresh Issues

Partial Redraws

Symptom: Parts of screen don’t update

Causes:

  1. Optimization too aggressive
  2. Terminal damage tracking
  3. SSH/tmux issues

Solutions:

  1. Force full refresh:

    # In Two-Face
    .refresh
    

    Or keybind:

    [keybinds."ctrl+l"]
    action = "refresh_screen"
    
  2. Disable lazy rendering:

    [performance]
    lazy_render = false
    
  3. tmux settings:

    # In .tmux.conf
    set -g default-terminal "screen-256color"
    set -ag terminal-overrides ",xterm-256color:RGB"
    

Screen Flicker

Symptom: Screen flashes during updates

Solutions:

  1. Enable double buffering:

    [display]
    double_buffer = true
    
  2. Batch updates:

    [performance]
    batch_updates = true
    
  3. Check terminal:

    • Some terminals handle rapid updates poorly
    • Try Alacritty, Kitty, or WezTerm

Ghost Characters

Symptom: Old characters remain after widget moves

Solution:

[display]
clear_on_move = true
full_redraw_on_resize = true

Terminal-Specific Issues

SSH Display Problems

Symptom: Display corrupted over SSH

Solutions:

  1. Set TERM correctly:

    ssh -t user@host "TERM=xterm-256color two-face"
    
  2. Enable compression:

    ssh -C user@host
    
  3. Reduce color depth:

    [display]
    color_mode = "256"  # More compatible
    

tmux Display Problems

Symptom: Colors or characters wrong in tmux

Solutions:

  1. Configure tmux:

    # .tmux.conf
    set -g default-terminal "tmux-256color"
    set -as terminal-features ",xterm-256color:RGB"
    
  2. Start Two-Face correctly:

    TERM=tmux-256color two-face
    

Screen Display Problems

Symptom: Issues in GNU Screen

Solutions:

# .screenrc
term screen-256color
defutf8 on

Accessibility

High Contrast Mode

[theme]
name = "high_contrast"
background = "#000000"
text = "#ffffff"
border = "#ffffff"
border_focused = "#ffff00"

health = "#00ff00"
health_low = "#ffff00"
health_critical = "#ff0000"

Large Text

Configure in terminal settings, not Two-Face:

  • Increase font size to 14pt+
  • Two-Face will adapt to available space

Screen Reader Hints

[accessibility]
screen_reader_hints = true
announce_focus_changes = true

Diagnostic Commands

Test Display Capabilities

# Color test
printf "\e[38;2;255;0;0mTrue Color Red\e[0m\n"

# Unicode test
echo "╔═══════╗"
echo "║ Test  ║"
echo "╚═══════╝"

# Box drawing test
echo "┌─┬─┐"
echo "├─┼─┤"
echo "└─┴─┘"

Debug Mode

# Start with display debugging
two-face --debug-display

# Log display operations
TF_LOG_DISPLAY=1 two-face

See Also

Connection Issues

Solving network problems, authentication failures, and disconnections.

Connection Modes

Two-Face supports two connection modes:

ModeUse WhenPortRequires
LichUsing Lich scripts8000 (default)Lich running
DirectStandalone7910Account credentials

Quick Diagnosis

# Test Lich connection
nc -zv 127.0.0.1 8000

# Test direct connection
nc -zv eaccess.play.net 7910

# Check DNS
nslookup eaccess.play.net

# Full connectivity test
curl -v https://eaccess.play.net:7910 2>&1 | head -20

Lich Mode Issues

“Connection Refused”

Symptom:

Error: Connection refused to 127.0.0.1:8000

Causes:

  1. Lich not running
  2. Lich not listening on expected port
  3. Firewall blocking localhost

Solutions:

  1. Start Lich first:

    # Start Lich (method varies)
    ruby lich.rb
    # Or use your Lich launcher
    
  2. Check Lich is listening:

    # Linux/macOS
    lsof -i :8000
    
    # Windows
    netstat -an | findstr 8000
    
  3. Verify port in Lich settings:

    • Check Lich configuration for proxy port
    • Match in Two-Face:
      [connection]
      mode = "lich"
      port = 8000  # Match Lich's setting
      

Can’t Connect After Lich Starts

Symptom: Lich running but Two-Face can’t connect

Causes:

  1. Lich hasn’t initialized proxy yet
  2. Wrong interface binding

Solutions:

  1. Wait for Lich startup:

    [connection]
    connect_delay = 3  # seconds to wait
    
  2. Check Lich’s listen address:

    # If Lich bound to specific interface
    [connection]
    host = "127.0.0.1"  # Must match Lich
    

Disconnects When Lich Reloads

Symptom: Connection drops when Lich scripts reload

Solution:

[connection]
auto_reconnect = true
reconnect_delay = 2

Direct Mode Issues

Authentication Failed

Symptom:

Error: Authentication failed: invalid credentials

Causes:

  1. Wrong account name
  2. Wrong password
  3. Special characters in password
  4. Account locked/expired

Solutions:

  1. Verify credentials:

    • Test via web login first
    • Check account status
  2. Handle special characters:

    # Quote password with special chars
    two-face --direct --account NAME --password 'P@ss!word'
    
  3. Use environment variables (more secure):

    export TF_ACCOUNT="myaccount"
    export TF_PASSWORD="mypassword"
    two-face --direct
    
  4. Check for account issues:

    • Verify subscription is active
    • Check for account locks

“Certificate Verification Failed”

Symptom:

Error: Certificate verification failed

Causes:

  1. Corrupted cached certificate
  2. Man-in-the-middle (security concern!)
  3. Server certificate changed

Solutions:

  1. Remove and re-download certificate:

    rm ~/.two-face/simu.pem
    two-face --direct ...  # Downloads fresh cert
    
  2. If error persists:

    • Check if you’re behind a corporate proxy
    • Verify you’re on a trusted network
    • Certificate changes may indicate security issues
  3. Manual certificate verification:

    openssl s_client -connect eaccess.play.net:7910 -servername eaccess.play.net
    

“Connection Timed Out”

Symptom:

Error: Connection to eaccess.play.net:7910 timed out

Causes:

  1. Firewall blocking
  2. Network issues
  3. Server maintenance

Solutions:

  1. Check basic connectivity:

    ping eaccess.play.net
    traceroute eaccess.play.net
    
  2. Verify port access:

    nc -zv eaccess.play.net 7910
    
  3. Check firewall:

    # Linux - check if port 7910 is blocked
    sudo iptables -L -n | grep 7910
    
    # Windows - check firewall
    netsh advfirewall firewall show rule name=all | findstr 7910
    
  4. Increase timeout:

    [connection]
    timeout = 60  # seconds
    

“Character Not Found”

Symptom:

Error: Character 'Mychar' not found on account

Solutions:

  1. Check character name spelling (case-sensitive)

  2. Verify character exists on correct game:

    two-face --direct --game prime ...  # GemStone IV Prime
    two-face --direct --game plat ...   # GemStone IV Platinum
    two-face --direct --game test ...   # Test server
    
  3. List available characters:

    two-face --direct --list-characters
    

Connection Drops

Random Disconnects

Symptom: Connection drops unexpectedly during play

Causes:

  1. Network instability
  2. Idle timeout
  3. Server-side disconnection

Solutions:

  1. Enable auto-reconnect:

    [connection]
    auto_reconnect = true
    reconnect_delay = 2
    reconnect_attempts = 5
    
  2. Send keepalives:

    [connection]
    keepalive = true
    keepalive_interval = 30  # seconds
    
  3. Check for network issues:

    # Monitor connection
    ping -i 5 gs4.play.net
    

Disconnects During High Activity

Symptom: Drops during combat or busy areas

Causes:

  1. Buffer overflow
  2. Processing can’t keep up
  3. Network congestion

Solutions:

  1. Increase buffers:

    [network]
    receive_buffer = 65536
    send_buffer = 32768
    
  2. Enable compression (if supported):

    [network]
    compression = true
    

“Connection Reset by Peer”

Symptom:

Error: Connection reset by peer

Causes:

  1. Server closed connection
  2. Network equipment reset
  3. Rate limiting

Solutions:

  1. Check for rapid reconnects (may be rate limited):

    [connection]
    reconnect_delay = 5  # Don't reconnect too fast
    
  2. Review recent actions:

    • Excessive commands?
    • Script flooding?

Firewall Issues

Corporate/School Network

Symptom: Works at home, fails at work/school

Solutions:

  1. Check if port 7910 allowed:

    • Direct mode uses port 7910 (non-standard)
    • May be blocked by corporate firewalls
  2. Use Lich as intermediary:

    • If Lich works (different protocol), use Lich mode
  3. Request firewall exception:

    • Port 7910 TCP outbound to eaccess.play.net

VPN Issues

Symptom: Connection fails when VPN active

Solutions:

  1. Check VPN split tunneling:

    • Gaming traffic might be routed through VPN
    • Configure VPN to exclude game servers
  2. DNS through VPN:

    # Check DNS resolution
    nslookup eaccess.play.net
    
  3. Try without VPN to confirm VPN is the issue

Proxy Configuration

HTTP Proxy

[network]
http_proxy = "http://proxy.example.com:8080"

SOCKS Proxy

[network]
socks_proxy = "socks5://localhost:1080"

No Proxy for Game Servers

[network]
no_proxy = "eaccess.play.net,gs4.play.net"

SSL/TLS Issues

“SSL Handshake Failed”

Symptom:

Error: SSL handshake failed

Causes:

  1. TLS version mismatch
  2. Cipher suite incompatibility
  3. OpenSSL issues

Solutions:

  1. Check OpenSSL version:

    openssl version
    # Should be 1.1.x or higher
    
  2. Force TLS version (if needed):

    [network]
    tls_min_version = "1.2"
    
  3. Rebuild with updated OpenSSL:

    # Ensure vcpkg OpenSSL is current
    vcpkg update
    vcpkg upgrade openssl
    

“Certificate Chain” Errors

Symptom: Certificate chain validation errors

Solution:

# Reset certificate cache
rm ~/.two-face/simu.pem
rm ~/.two-face/cert_chain.pem

# Reconnect - will re-download
two-face --direct ...

Debugging Connections

Enable Network Logging

[logging]
level = "debug"

[debug]
log_network = true
log_network_data = false  # true for packet dumps (verbose!)

View Network Debug Info

# Start with network debugging
TF_DEBUG_NETWORK=1 two-face

# Log to file for analysis
TF_LOG_FILE=/tmp/tf-network.log two-face --debug

Test Connection Step by Step

# 1. DNS resolution
nslookup eaccess.play.net

# 2. Basic connectivity
ping eaccess.play.net

# 3. Port availability
nc -zv eaccess.play.net 7910

# 4. TLS connection
openssl s_client -connect eaccess.play.net:7910

# 5. Full connection test
two-face --direct --test-connection

Quick Reference

Connection Checklist

  • Correct mode (lich vs direct)
  • Lich running (if lich mode)
  • Credentials correct (if direct mode)
  • Port accessible (8000 for lich, 7910 for direct)
  • Firewall allows connection
  • Certificate valid
  • Network stable

Emergency Recovery

# Reset all connection state
rm ~/.two-face/simu.pem      # Certificate cache
rm ~/.two-face/session.dat   # Session cache

# Test with minimal config
two-face --default-config --direct --account NAME --password PASS

See Also

Appendices

Supplementary reference material and additional resources.

Contents

Glossary

Terms and definitions used throughout Two-Face documentation. Covers technical terms, abbreviations, and concepts from A to Z.

XML Protocol Reference

Complete reference for GemStone IV’s XML game protocol. Documents all tags, attributes, and data structures sent by the game server.

Color Codes

Comprehensive color reference including:

  • Hex color format
  • Named colors
  • RGB values
  • Two-Face presets
  • 256-color palette
  • Terminal compatibility

Unicode Symbols

Useful Unicode characters for layouts:

  • Box drawing characters
  • Progress bar elements
  • Directional arrows
  • Status indicators
  • Decorative symbols

Acknowledgments

Recognition of:

  • Projects that inspired Two-Face
  • Open source libraries used
  • Community contributors
  • Standards and references

Purpose

These appendices provide:

  1. Quick Lookup - Find definitions and codes fast
  2. Deep Reference - Detailed technical specifications
  3. Design Resources - Characters and colors for customization
  4. Attribution - Credit to those who made Two-Face possible

Using the Appendices

For Users

  • Glossary: When you encounter unfamiliar terms
  • Color Codes: When creating custom themes
  • Unicode Symbols: When designing layouts

For Developers

  • XML Protocol: When extending the parser
  • Glossary: For consistent terminology
  • Acknowledgments: Understanding dependencies

See Also

Glossary

Terms and definitions used throughout Two-Face documentation.

A

Aho-Corasick

A string-matching algorithm that can match multiple patterns simultaneously. Used by Two-Face for efficient highlight pattern matching when fast_parse = true.

ASCII

American Standard Code for Information Interchange. A character encoding standard. Two-Face can use ASCII-only mode for maximum terminal compatibility.

Auto-reconnect

Feature that automatically reconnects to the game server if the connection is lost.

Auto-scroll

Widget behavior that automatically scrolls to show the newest content as it arrives.

B

Browser

In Two-Face, a popup window that can be opened for detailed interaction with game data. Examples: highlight editor, spell list browser.

Buffer

Memory area for temporarily storing data. Two-Face uses buffers for incoming game data, scrollback history, and command history.

C

Casttime

Delay after casting a spell before you can act again. Displayed by countdown widgets.

Character Code

Unique identifier for a character in the game’s authentication system.

Command Input

Widget type for entering commands to send to the game.

Command List (cmdlist)

Configuration that maps context menu options to game objects based on noun matching.

Compass

Widget displaying available movement directions in the current room.

Config

Short for configuration. TOML files controlling Two-Face behavior.

Core Layer

The middle layer of Two-Face architecture, containing business logic and widget management.

D

Dashboard

Widget type that combines multiple status elements into a single display.

Data Layer

The foundation layer of Two-Face architecture, containing game state and parsing.

Data Source

Configuration path identifying where a widget gets its data (e.g., vitals.health).

Direct Mode

Connection method that authenticates directly with eAccess servers, bypassing Lich.

E

eAccess

Simutronics authentication server for GemStone IV and DragonRealms.

Effect

Active spell, buff, or debuff affecting your character. Tracked by active_effects widget.

F

Fast Parse

Optimization mode using Aho-Corasick algorithm for pattern matching instead of regex.

Focus

The currently selected/active widget. Input goes to the focused widget.

Frontend Layer

The presentation layer of Two-Face architecture, handling rendering and input.

G

Game State

Central data structure containing all parsed game information.

Generation

Version number that increments when data changes. Used for efficient change detection.

GemStone IV

Text-based MMORPG that Two-Face is designed for.

Glob

Pattern matching syntax using wildcards (* and ?).

Glyph

Visual representation of a character in a font.

H

Hash Key

In eAccess authentication, a 32-byte key used to obfuscate the password.

Hex Color

Color specified using hexadecimal notation (e.g., #FF0000 for red).

Highlight

Pattern-based text styling that colors matching text in game output.

Hook

In some contexts, a callback function. Not currently used in Two-Face.

I

Indicator

Widget showing boolean status (on/off states like hidden, stunned, prone).

Injury Doll

Widget displaying injuries to different body parts.

Input Widget

Widget that accepts text input from the user.

K

Keybind

Association between a key combination and an action or macro.

Keepalive

Network message sent periodically to prevent connection timeout.

L

Layout

Configuration defining widget positions, sizes, and arrangement.

Lazy Render

Optimization that only redraws changed portions of the screen.

Lich

Ruby-based proxy and scripting platform for GemStone IV.

Lich Mode

Connection method that connects through Lich proxy.

M

Macro

Command or sequence of commands triggered by a keybind.

Mana

Magical energy resource. Tracked by vitals.mana.

Monospace

Font where every character has the same width. Required for terminal UI.

MUD

Multi-User Dungeon. Text-based multiplayer games. GemStone IV is technically a MUD.

N

Nerd Font

Font family including many additional symbols and icons.

Node

Individual XML element in the game protocol.

O

OWASP

Open Web Application Security Project. Security standards referenced in development.

Overlay

Widget that draws on top of other widgets.

P

Palette

Set of colors available for use in themes.

ParsedElement

Internal representation of processed game data.

Parser

Component that interprets XML game protocol into structured data.

Pattern

Regular expression or literal string for matching text.

Percentage Positioning

Widget placement using percentages instead of fixed coordinates.

PEM

Privacy-Enhanced Mail. Certificate file format used for TLS.

See Browser.

Preset

Named color with associated hex value (e.g., “health” → “#00ff00”).

Progress Bar

Widget showing a value as a filled bar (health, mana, etc.).

Protocol

Rules for communication between client and server.

Proxy

Intermediary that relays connections (Lich acts as a proxy).

R

Reconnect

Re-establishing connection after disconnection.

Regex

Regular expression. Pattern matching syntax for text.

Render

Drawing the UI to the screen.

Render Rate

How many times per second the UI updates (FPS).

Room

In-game location. Room data includes name, description, exits.

Roundtime

Delay after an action before you can act again.

RT

Short for Roundtime.

S

Scrollback

History of text that can be scrolled back to view.

Session

Connection session with the game server.

SNI

Server Name Indication. TLS extension disabled for eAccess compatibility.

Spirit

Spiritual energy resource. Tracked by vitals.spirit.

SSL

Secure Sockets Layer. Legacy term, now TLS.

Stamina

Physical energy resource. Tracked by vitals.stamina.

Stream

Category of game output (main, room, combat, speech, etc.).

Sync

Process of updating widgets to match current game state.

T

Tab

In tabbed_text widget, a separate pane within the widget.

Tabbed Text

Widget type with multiple tabs, each showing different content.

Terminal

Text-based interface environment.

Theme

Collection of color definitions for consistent appearance.

Ticket

Authentication token for game server connection.

TLS

Transport Layer Security. Encryption for network connections.

TOML

Tom’s Obvious, Minimal Language. Configuration file format.

Trigger

Automation that executes commands based on game output patterns.

True Color

24-bit color (16.7 million colors). Also called “truecolor”.

TTS

Text-to-Speech. Audio output of game text.

U

Unicode

Character encoding standard supporting international characters and symbols.

Update

Change to game state that triggers widget refresh.

V

Vitals

Character health statistics (health, mana, stamina, spirit).

Viewport

Visible portion of scrollable content.

W

Widget

UI component displaying game information or accepting input.

Widget Manager

Core component managing widget lifecycle and updates.

X

XML

eXtensible Markup Language. Format of game protocol.

Z

Z-Index

Layer order for overlapping widgets. Higher values display on top.

See Also

XML Protocol Reference

Complete reference for GemStone IV’s XML game protocol.

Protocol Overview

GemStone IV sends game data as a mix of plain text and XML tags. The XML tags provide structured information about game state, while plain text contains narrative content.

Example Stream

<prompt time="1699900000">&gt;</prompt>
<pushStream id="room"/>
<compDef id='room desc'/>
[Town Square Central]
<popStream/>
This is the center of town.
<compass>
<dir value="n"/>
<dir value="e"/>
<dir value="s"/>
<dir value="w"/>
</compass>

Core Tags

<prompt>

Indicates command prompt, includes server timestamp.

<prompt time="1699900000">&gt;</prompt>
AttributeDescription
timeUnix timestamp from server

<pushStream> / <popStream>

Redirects content to a named stream.

<pushStream id="combat"/>
You attack the troll!
<popStream/>

Common stream IDs:

  • main - Primary game output
  • room - Room descriptions
  • combat - Combat messages
  • speech - Character speech
  • whisper - Private messages
  • thoughts - Mental communications
  • death - Death messages
  • experience - Experience gains
  • logons - Login/logout notices
  • atmosphere - Ambient messages

<clearStream>

Clears a stream’s content.

<clearStream id="room"/>

<component>

Named data component for widgets.

<component id="room objs">a troll</component>
<component id="room players">Playername</component>
<component id="room exits">Obvious paths: north, south</component>

Common component IDs:

  • room objs - Objects in room
  • room players - Players in room
  • room exits - Exits description
  • room desc - Room description
  • room name - Room title

<compDef>

Component definition (marks component location).

<compDef id='room desc'/>

Vitals Tags

Health/Mana/Stamina/Spirit

<progressBar id="health" value="95"/>
<progressBar id="mana" value="100"/>
<progressBar id="stamina" value="87"/>
<progressBar id="spirit" value="100"/>
AttributeDescription
idVital type
value0-100 percentage

Experience

<progressBar id="encumbrance" value="50"/>
<progressBar id="mindState" value="5"/>

Encumbrance Levels

ValueDescription
0None
1-20Light
21-40Moderate
41-60Heavy
61-80Very Heavy
81-100Encumbered

Timing Tags

<roundTime>

Sets roundtime value.

<roundTime value="1699900005"/>
AttributeDescription
valueUnix timestamp when RT ends

<castTime>

Sets casttime value.

<castTime value="1699900003"/>

<compass>

Navigation directions available.

<compass>
<dir value="n"/>
<dir value="ne"/>
<dir value="e"/>
<dir value="se"/>
<dir value="s"/>
<dir value="sw"/>
<dir value="w"/>
<dir value="nw"/>
<dir value="up"/>
<dir value="down"/>
<dir value="out"/>
</compass>

Direction values:

  • n, ne, e, se, s, sw, w, nw - Cardinal/ordinal
  • up, down, out - Vertical/exit

Status Tags

<indicator>

Boolean status indicator.

<indicator id="IconHIDDEN" visible="y"/>
<indicator id="IconSTUNNED" visible="n"/>
AttributeDescription
idIndicator identifier
visible“y” or “n”

Common indicators:

  • IconHIDDEN - Hidden status
  • IconSTUNNED - Stunned
  • IconBLEEDING - Bleeding
  • IconPOISONED - Poisoned
  • IconDISEASED - Diseased
  • IconKNEELING - Kneeling
  • IconPRONE - Lying down
  • IconSITTING - Sitting
  • IconSTANDING - Standing
  • IconJOINED - In a group
  • IconDEAD - Dead

<left> / <right>

Hand contents.

<left>a steel sword</left>
<right>a wooden shield</right>

Empty hands show as:

<left>Empty</left>

<spell>

Currently prepared spell.

<spell>Fire Spirit</spell>

Combat Tags

Clickable links in game text.

<a exist="123456" noun="troll">troll</a>
AttributeDescription
existObject existence ID
nounBase noun for commands

Target Information

<component id="target">a massive troll</component>

Spell/Effect Tags

Active Spells

Spell list updates:

<pushStream id="spells"/>
<clearStream id="spells"/>
<spell>Spirit Warding I (101)</spell> - 30 min remaining
<spell>Spirit Defense (103)</spell> - 25 min remaining
<popStream/>

Active Effects

<dialogData id="ActiveSpells">
<progressBar id="spell_101" value="90" text="Spirit Warding I"/>
</dialogData>

Inventory Tags

Container Contents

<inv id="1234">
<item>a steel sword</item>
<item>3 silver coins</item>
</inv>

Equipment

<pushStream id="inv"/>
Equipment:
  armor: some full leather
  weapon: a steel broadsword
<popStream/>

Injury Tags

Body Part Injuries

<component id="injury_head">0</component>
<component id="injury_neck">0</component>
<component id="injury_chest">1</component>
<component id="injury_back">0</component>
<component id="injury_leftArm">2</component>
<component id="injury_rightArm">0</component>
<component id="injury_leftHand">0</component>
<component id="injury_rightHand">0</component>
<component id="injury_abdomen">0</component>
<component id="injury_leftLeg">0</component>
<component id="injury_rightLeg">0</component>
<component id="injury_leftFoot">0</component>
<component id="injury_rightFoot">0</component>
<component id="injury_leftEye">0</component>
<component id="injury_rightEye">0</component>
<component id="injury_nsys">0</component>

Injury values:

  • 0 - No injury
  • 1 - Minor injury
  • 2 - Moderate injury
  • 3 - Severe injury

Style Tags

<preset>

Applies named color preset.

<preset id="speech">Someone says, "Hello"</preset>
<preset id="whisper">Someone whispers, "Hello"</preset>

Common presets:

  • speech - Character speech
  • whisper - Whispers
  • thought - Mental communication
  • roomName - Room title
  • roomDesc - Room description
  • bold - Bold text

<b> / <i> / <u>

Basic text formatting.

<b>Bold text</b>
<i>Italic text</i>
<u>Underlined text</u>

<d cmd="...>

Command suggestion (clickable).

<d cmd="look troll">look at the troll</d>

Control Tags

<mode>

Client mode setting.

<mode id="GAME"/>

<app>

Application character information.

<app char="Charactername" game="GS4" />

<output>

Output control.

<output class="mono"/>

Special Tags

<resource>

External resource reference.

<resource picture="12345"/>

<style>

Inline style application.

<style id="roomName"/>

Navigation region.

<nav rm="12345"/>

Parsing Considerations

Entity Encoding

Special characters are XML-encoded:

  • &gt;>
  • &lt;<
  • &amp;&
  • &quot;"
  • &#39;'

Nested Tags

Tags can nest:

<pushStream id="combat">
<preset id="thought">You sense <a exist="123" noun="creature">the creature</a></preset>
<popStream/>

Unclosed Tags

Some tags are self-closing or have implicit closure:

<prompt time="123">&gt;</prompt>
<dir value="n"/>
<progressBar id="health" value="95"/>

Implementation Notes

Stream Management

  • Push/pop streams form a stack
  • Multiple streams can be active
  • Text goes to all active streams
  • Clear affects only named stream

Component Updates

  • Components update atomically
  • Same ID replaces previous value
  • Clear stream clears related components

Timing Precision

  • Timestamps are Unix seconds
  • Client should convert to local time
  • Roundtime countdown based on server time

See Also

Color Codes Reference

Complete reference for colors in Two-Face.

Color Formats

Two-Face supports multiple color format specifications:

Hex Colors

6-digit or 3-digit hexadecimal:

color = "#ff0000"   # Red (6-digit)
color = "#f00"      # Red (3-digit shorthand)
color = "#FF0000"   # Case insensitive

RGB Values

color = "rgb(255, 0, 0)"    # Red
color = "rgb(0, 128, 255)"  # Blue

Named Colors

Standard web colors:

color = "red"
color = "darkblue"
color = "forestgreen"

Preset Names

Two-Face specific presets:

color = "health"        # Health bar color
color = "mana"          # Mana bar color
color = "speech"        # Speech text color

Standard Named Colors

Basic Colors

NameHexPreview
black#000000████
white#ffffff████
red#ff0000████
green#00ff00████
blue#0000ff████
yellow#ffff00████
cyan#00ffff████
magenta#ff00ff████

Extended Colors

NameHexPreview
orange#ffa500████
pink#ffc0cb████
purple#800080████
brown#a52a2a████
gray / grey#808080████
gold#ffd700████
silver#c0c0c0████
navy#000080████
teal#008080████
olive#808000████
maroon#800000████
lime#00ff00████
aqua#00ffff████
fuchsia#ff00ff████

Dark Variants

NameHex
darkred#8b0000
darkgreen#006400
darkblue#00008b
darkcyan#008b8b
darkmagenta#8b008b
darkyellow / darkgoldenrod#b8860b
darkgray / darkgrey#a9a9a9
darkorange#ff8c00
darkviolet#9400d3

Light Variants

NameHex
lightred / lightcoral#f08080
lightgreen#90ee90
lightblue#add8e6
lightcyan#e0ffff
lightpink#ffb6c1
lightyellow#ffffe0
lightgray / lightgrey#d3d3d3
lightsalmon#ffa07a
lightseagreen#20b2aa

Bright Variants (Terminal)

NameHex
bright_black#555555
bright_red#ff5555
bright_green#55ff55
bright_yellow#ffff55
bright_blue#5555ff
bright_magenta#ff55ff
bright_cyan#55ffff
bright_white#ffffff

256-Color Palette

Standard Colors (0-15)

 0 Black       8 Bright Black
 1 Red         9 Bright Red
 2 Green      10 Bright Green
 3 Yellow     11 Bright Yellow
 4 Blue       12 Bright Blue
 5 Magenta    13 Bright Magenta
 6 Cyan       14 Bright Cyan
 7 White      15 Bright White

Color Cube (16-231)

6x6x6 color cube:

  • R: 0, 95, 135, 175, 215, 255
  • G: 0, 95, 135, 175, 215, 255
  • B: 0, 95, 135, 175, 215, 255

Index = 16 + 36×r + 6×g + b (where r,g,b are 0-5)

Grayscale (232-255)

24 shades from dark to light:

RangeDescription
232-235Near black
236-243Dark gray
244-251Light gray
252-255Near white

Two-Face Presets

Vitals Presets

PresetDefaultUsage
health#00ff00Health bar (full)
health_low#ffff00Health bar (low)
health_critical#ff0000Health bar (critical)
mana#0080ffMana bar
stamina#ff8000Stamina bar
spirit#ff00ffSpirit bar

Stream Presets

PresetDefaultUsage
main#ffffffMain game text
room#ffff00Room descriptions
combat#ff4444Combat messages
speech#00ffffCharacter speech
whisper#ff00ffPrivate messages
thoughts#00ff00Mental communication

UI Presets

PresetDefaultUsage
background#000000Window background
text#c0c0c0Default text
text_dim#808080Dimmed text
border#404040Widget borders
border_focused#ffffffFocused widget border

Status Presets

PresetDefaultUsage
hidden#00ff00Hidden indicator
invisible#00ffffInvisible indicator
stunned#ffff00Stunned indicator
webbed#ff00ffWebbed indicator
prone#00ffffProne indicator
kneeling#ff8000Kneeling indicator
sitting#808080Sitting indicator
dead#ff0000Dead indicator

Color Modes

True Color (24-bit)

16.7 million colors:

[display]
color_mode = "truecolor"

256 Colors

256-color palette:

[display]
color_mode = "256"

Colors are mapped to nearest palette entry.

16 Colors

Basic terminal colors:

[display]
color_mode = "16"

No Color

Monochrome:

[display]
color_mode = "none"

Color Math

Brightness/Luminance

Calculate perceived brightness:

L = 0.299×R + 0.587×G + 0.114×B

Contrast Ratio

For accessibility:

Ratio = (L1 + 0.05) / (L2 + 0.05)

WCAG guidelines:

  • 4.5:1 minimum for normal text
  • 3:1 minimum for large text
  • 7:1 enhanced contrast

Color Mixing

Linear interpolation:

mixed = color1 × (1-t) + color2 × t

Accessibility Colors

High Contrast Pairs

BackgroundForegroundRatio
#000000#ffffff21:1
#000000#ffff0019.6:1
#000000#00ffff16.7:1
#000000#00ff0015.3:1
#1a1a1a#ffffff16.9:1
#1a1a1a#e0e0e012.6:1

Color Blindness Friendly

Avoid relying solely on red/green distinction:

# Instead of red/green for health
[theme]
health = "#00ff00"
health_critical = "#ff00ff"  # Magenta instead of red

# Or use brightness difference
health = "#80ff80"           # Bright green
health_critical = "#800000"  # Dark red (brightness contrast)

Terminal Compatibility

ANSI Escape Sequences

16 colors:

\e[30m - \e[37m   # Foreground colors
\e[40m - \e[47m   # Background colors
\e[90m - \e[97m   # Bright foreground
\e[100m - \e[107m # Bright background

256 colors:

\e[38;5;Nm  # Foreground (N = 0-255)
\e[48;5;Nm  # Background

True color:

\e[38;2;R;G;Bm  # Foreground (R,G,B = 0-255)
\e[48;2;R;G;Bm  # Background

Terminal Detection

# Check color support
echo $TERM
echo $COLORTERM
tput colors

See Also

Unicode Symbols Reference

Useful Unicode characters for Two-Face layouts and customization.

Box Drawing Characters

Light Box Drawing

CharCodeName
U+2500Light horizontal
U+2502Light vertical
U+250CLight down and right
U+2510Light down and left
U+2514Light up and right
U+2518Light up and left
U+251CLight vertical and right
U+2524Light vertical and left
U+252CLight down and horizontal
U+2534Light up and horizontal
U+253CLight vertical and horizontal

Heavy Box Drawing

CharCodeName
U+2501Heavy horizontal
U+2503Heavy vertical
U+250FHeavy down and right
U+2513Heavy down and left
U+2517Heavy up and right
U+251BHeavy up and left
U+2523Heavy vertical and right
U+252BHeavy vertical and left
U+2533Heavy down and horizontal
U+253BHeavy up and horizontal
U+254BHeavy vertical and horizontal

Double Box Drawing

CharCodeName
U+2550Double horizontal
U+2551Double vertical
U+2554Double down and right
U+2557Double down and left
U+255ADouble up and right
U+255DDouble up and left
U+2560Double vertical and right
U+2563Double vertical and left
U+2566Double down and horizontal
U+2569Double up and horizontal
U+256CDouble vertical and horizontal

Rounded Corners

CharCodeName
U+256DLight arc down and right
U+256ELight arc down and left
U+256FLight arc up and left
U+2570Light arc up and right

Progress Bar Characters

Block Elements

CharCodeNameUse
U+2588Full block100% filled
U+25897/8 block~87%
U+258A3/4 block75%
U+258B5/8 block~62%
U+258C1/2 block50%
U+258D3/8 block~37%
U+258E1/4 block25%
U+258F1/8 block~12%
U+2591Light shadeEmpty
U+2592Medium shadePartial
U+2593Dark shadeMostly filled

Vertical Bars

CharCodeName
U+2581Lower 1/8
U+2582Lower 1/4
U+2583Lower 3/8
U+2584Lower half
U+2585Lower 5/8
U+2586Lower 3/4
U+2587Lower 7/8

Directional Symbols

Arrows

CharCodeNameDirection
U+2191Upwards arrowNorth
U+2197North east arrowNortheast
U+2192Rightwards arrowEast
U+2198South east arrowSoutheast
U+2193Downwards arrowSouth
U+2199South west arrowSouthwest
U+2190Leftwards arrowWest
U+2196North west arrowNorthwest
U+2195Up down arrowUp/Down
U+2194Left right arrowIn/Out

Triangle Arrows

CharCodeName
U+25B2Black up-pointing triangle
U+25B6Black right-pointing triangle
U+25BCBlack down-pointing triangle
U+25C0Black left-pointing triangle
U+25B3White up-pointing triangle
U+25B7White right-pointing triangle
U+25BDWhite down-pointing triangle
U+25C1White left-pointing triangle

Compass Rose

    N
  NW↑NE
W ←•→ E
  SW↓SE
    S

Using Unicode:

    ↑
  ↖ ↗
← ● →
  ↙ ↘
    ↓

Status Indicators

Circles

CharCodeNameUse
U+25CFBlack circleActive/On
U+25CBWhite circleInactive/Off
U+25C9FisheyeSelected
U+25CEBullseyeTarget
U+25CCDotted circleEmpty
⦿U+29BFCircled bulletAvailable

Squares

CharCodeNameUse
U+25A0Black squareFilled
U+25A1White squareEmpty
U+25AABlack small squareBullet
U+25ABWhite small squarePlaceholder

Check Marks

CharCodeNameUse
U+2713Check markSuccess
U+2717Ballot XFailure
U+2714Heavy check markConfirmed
U+2718Heavy ballot XDenied
U+2611Checkbox checkedOption on
U+2610Checkbox uncheckedOption off

Stars and Decorations

Stars

CharCodeName
U+2605Black star
U+2606White star
U+2726Black four pointed star
U+2727White four pointed star
U+2042Asterism

Bullets and Points

CharCodeName
U+2022Bullet
U+25E6White bullet
U+2023Triangular bullet
U+2043Hyphen bullet
U+204CBlack leftwards bullet
U+204DBlack rightwards bullet

Decorative Lines

CharCodeName
U+2500Horizontal line
U+2550Double horizontal
U+2501Heavy horizontal
U+2504Triple dash horizontal
U+2505Heavy triple dash
U+2508Quadruple dash
U+22EFMidline horizontal ellipsis

Combat

CharCodeNameUse
U+2694Crossed swordsCombat
🗡U+1F5E1DaggerWeapon
U+26E8ShieldDefense
U+2620Skull and crossbonesDeath
U+26A1High voltageSpell/Magic

Status

CharCodeNameUse
U+2665HeartHealth
U+2666DiamondMana
U+2697AlembicAlchemy
U+2600SunDay
U+263ELast quarter moonNight
CharCodeNameUse
🏠U+1F3E0HouseHome
🚪U+1F6AADoorExit
U+2B06Upward arrowUp
U+2B07Downward arrowDown
🧭U+1F9EDCompassNavigation

Typography

Quotation Marks

CharCodeName
“ “U+201C/DCurly double quotes
’ ’U+2018/9Curly single quotes
U+201EDouble low-9 quote
« »U+00AB/BBGuillemets

Punctuation

CharCodeName
U+2026Ellipsis
U+2014Em dash
U+2013En dash
·U+00B7Middle dot
U+2020Dagger
U+2021Double dagger

Mathematical Symbols

Common

CharCodeName
×U+00D7Multiplication
÷U+00F7Division
±U+00B1Plus-minus
U+2248Almost equal
U+2260Not equal
U+2264Less or equal
U+2265Greater or equal
U+221EInfinity

Usage Examples

Progress Bar

[theme.progress]
filled = "█"
empty = "░"
partial = ["▏", "▎", "▍", "▌", "▋", "▊", "▉"]

Widget Borders

[theme.borders]
style = "rounded"
chars = "╭─╮│ │╰─╯"

Status Line

♥ 95/100 ♦ 100/100 ⚡ Ready

Compass Display

    ↑
  ↖ • ↗
← N →
  ↙ • ↘
    ↓

Font Requirements

To display all Unicode symbols:

  • JetBrains Mono - Excellent coverage
  • Cascadia Code - Good coverage
  • Fira Code - Good coverage
  • Nerd Fonts - Best coverage (patched fonts)

Checking Font Coverage

# List available characters in font (Linux)
fc-match --format='%{family}\n' :charset=2500

# Check specific character
printf '\u2500' && echo " - Should show horizontal line"

See Also

Acknowledgments

Recognition of projects, people, and resources that made Two-Face possible.

Inspiration

Profanity

Two-Face draws significant inspiration from Profanity, the pioneering terminal-based client for GemStone IV. Profanity demonstrated that a modern, efficient terminal interface was not only possible but could provide a superior gameplay experience.

Lich

Lich has been instrumental to the GemStone IV community, providing scripting capabilities and serving as an excellent proxy for frontend development. Two-Face’s Lich mode exists thanks to Lich’s well-designed proxy interface.

StormFront and Wizard

The official Simutronics clients (StormFront, Wizard Front End) established the standard for GemStone IV interfaces and defined the XML protocol that Two-Face parses.

Open Source Libraries

Two-Face is built on excellent open source foundations:

Core

  • Rust - The programming language providing safety and performance
  • Tokio - Asynchronous runtime for non-blocking I/O
  • Ratatui - Terminal user interface library

Parsing

Configuration

  • toml - TOML parser and serializer
  • serde - Serialization framework
  • dirs - Platform-specific directories

Network

  • openssl - TLS/SSL implementation
  • rustls - Pure Rust TLS (alternative)

Audio

  • cpal - Cross-platform audio
  • tts - Text-to-speech bindings

Documentation

This documentation is built with:

  • mdBook - The book-from-markdown tool
  • GitHub Pages - Hosting

Community

GemStone IV Players

The GemStone IV community has been invaluable for:

  • Feature suggestions
  • Bug reports
  • Beta testing
  • Protocol documentation
  • General encouragement

Contributors

Thank you to everyone who has contributed code, documentation, bug reports, or feedback. See the Contributors page for a complete list.

Simutronics

GemStone IV is developed and operated by Simutronics Corporation. Two-Face is an unofficial third-party client developed by fans.

Disclaimer: Two-Face is not affiliated with, endorsed by, or connected to Simutronics Corporation. GemStone IV and all related trademarks are property of Simutronics.

Standards and References

Two-Face implementation references:

  • ANSI Escape Codes - Terminal control sequences
  • VT100 - Terminal emulation reference
  • Unicode - Character encoding standards
  • WCAG - Web Content Accessibility Guidelines

Special Thanks

  • The Rust community for excellent tooling and documentation
  • Terminal emulator developers for maintaining standards
  • Everyone who plays GemStone IV and keeps the community alive
  • Open source maintainers everywhere

License

Two-Face is open source software. See the LICENSE file for details.


“Standing on the shoulders of giants.”

Contributing

Want to be acknowledged? Contribute to Two-Face! All contributions, from code to documentation to bug reports, are valued.

See Also

API Documentation

Programmatic interface documentation for Two-Face.

Rust API Documentation

Two-Face’s internal API is documented using Rust’s built-in documentation system.

Viewing Rustdoc

Generate and view the API documentation:

# Generate documentation
cargo doc --no-deps

# Open in browser
cargo doc --no-deps --open

# Include private items
cargo doc --no-deps --document-private-items

Online Documentation

When available, API documentation is hosted at:

Module Overview

Two-Face is organized into three main layers:

Data Layer (src/data/)

Core data structures and game state.

#![allow(unused)]
fn main() {
// Key types
pub struct GameState { ... }
pub struct ParsedElement { ... }
pub enum StreamId { ... }
pub struct Vitals { ... }
}

Key modules:

  • data::game_state - Central state management
  • data::parsed - Parser output types
  • data::vitals - Health/mana/stamina
  • data::room - Room information

Core Layer (src/core/)

Business logic and widget management.

#![allow(unused)]
fn main() {
// Key types
pub struct WidgetManager { ... }
pub struct AppState { ... }
pub trait Widget { ... }
}

Key modules:

  • core::widget_manager - Widget lifecycle
  • core::app_state - Application state
  • core::sync - State synchronization
  • core::layout - Layout management

Frontend Layer (src/frontend/)

User interface and rendering.

#![allow(unused)]
fn main() {
// Key types
pub struct TuiApp { ... }
pub struct InputHandler { ... }
}

Key modules:

  • frontend::tui - Terminal UI
  • frontend::tui::input - Input handling
  • frontend::tui::render - Rendering

Key Traits

Widget Trait

All widgets implement the core widget trait:

#![allow(unused)]
fn main() {
pub trait Widget {
    /// Widget name/identifier
    fn name(&self) -> &str;

    /// Widget type
    fn widget_type(&self) -> WidgetType;

    /// Render the widget to a frame region
    fn render(&self, frame: &mut Frame, area: Rect);

    /// Handle input events
    fn handle_input(&mut self, event: KeyEvent) -> Option<WidgetAction>;

    /// Update from game state
    fn update(&mut self, state: &GameState) -> bool;
}
}

Browser Trait

Popup windows implement:

#![allow(unused)]
fn main() {
pub trait Browser {
    /// Browser title
    fn title(&self) -> &str;

    /// Render browser content
    fn render(&self, frame: &mut Frame, area: Rect);

    /// Handle input
    fn handle_input(&mut self, event: KeyEvent) -> BrowserAction;

    /// Initialize with game state
    fn init(&mut self, state: &GameState);
}
}

Configuration API

Loading Configuration

#![allow(unused)]
fn main() {
use two_face::config::{Config, Layout, Theme};

// Load from default location
let config = Config::load()?;

// Load from specific path
let config = Config::from_file("path/to/config.toml")?;

// Access settings
let port = config.connection.port;
let render_rate = config.performance.render_rate;
}

Layout Configuration

#![allow(unused)]
fn main() {
use two_face::config::Layout;

let layout = Layout::load("layout.toml")?;

for widget_config in layout.widgets {
    println!("Widget: {} at ({}, {})",
             widget_config.name,
             widget_config.x,
             widget_config.y);
}
}

Parser API

Parsing Game Data

#![allow(unused)]
fn main() {
use two_face::parser::Parser;

let mut parser = Parser::new();

// Feed data from game
let elements = parser.parse(raw_data)?;

// Process parsed elements
for element in elements {
    match element {
        ParsedElement::Text(text) => { /* ... */ }
        ParsedElement::Prompt(time) => { /* ... */ }
        ParsedElement::Vitals(vitals) => { /* ... */ }
        // ... other variants
    }
}
}

ParsedElement Variants

#![allow(unused)]
fn main() {
pub enum ParsedElement {
    Text(String),
    Prompt(u64),
    StreamPush(StreamId),
    StreamPop,
    StreamClear(StreamId),
    Vitals(VitalUpdate),
    Roundtime(u64),
    Casttime(u64),
    Compass(Vec<Direction>),
    Indicator(String, bool),
    Component(String, String),
    // ... and more
}
}

Network API

Lich Connection

#![allow(unused)]
fn main() {
use two_face::network::LichConnection;

let conn = LichConnection::new("127.0.0.1", 8000)?;
conn.connect()?;

// Send command
conn.send("look")?;

// Receive data
let data = conn.receive()?;
}

Direct Connection

#![allow(unused)]
fn main() {
use two_face::network::DirectConnection;

let conn = DirectConnection::new(
    "account",
    "password",
    "prime",
    "character_name"
)?;

conn.authenticate()?;
conn.connect()?;
}

Event System

Input Events

#![allow(unused)]
fn main() {
use two_face::events::{Event, KeyEvent};

loop {
    match event_receiver.recv()? {
        Event::Key(key_event) => {
            // Handle keyboard input
        }
        Event::Mouse(mouse_event) => {
            // Handle mouse input
        }
        Event::Resize(w, h) => {
            // Handle terminal resize
        }
        Event::GameData(data) => {
            // Handle incoming game data
        }
    }
}
}

Widget Actions

#![allow(unused)]
fn main() {
pub enum WidgetAction {
    None,
    SendCommand(String),
    OpenBrowser(BrowserType),
    CloseBrowser,
    FocusWidget(String),
    Scroll(ScrollDirection),
    Quit,
}
}

Building Extensions

Adding a Widget Type

#![allow(unused)]
fn main() {
use two_face::widgets::{Widget, WidgetType};

pub struct CustomWidget {
    name: String,
    // Custom fields
}

impl Widget for CustomWidget {
    fn name(&self) -> &str {
        &self.name
    }

    fn widget_type(&self) -> WidgetType {
        WidgetType::Custom
    }

    fn render(&self, frame: &mut Frame, area: Rect) {
        // Render implementation
    }

    fn handle_input(&mut self, event: KeyEvent) -> Option<WidgetAction> {
        // Input handling
        None
    }

    fn update(&mut self, state: &GameState) -> bool {
        // Update from state, return true if changed
        false
    }
}
}

Adding a Parser Extension

#![allow(unused)]
fn main() {
use two_face::parser::{Parser, ParsedElement};

impl Parser {
    pub fn handle_custom_tag(&mut self, tag: &str, attrs: &[Attribute])
        -> Option<ParsedElement>
    {
        if tag == "customtag" {
            // Parse custom tag
            Some(ParsedElement::Custom(/* ... */))
        } else {
            None
        }
    }
}
}

Error Handling

Two-Face uses the anyhow crate for error handling:

#![allow(unused)]
fn main() {
use anyhow::{Result, Context};

fn load_config() -> Result<Config> {
    let content = std::fs::read_to_string("config.toml")
        .context("Failed to read config file")?;

    let config: Config = toml::from_str(&content)
        .context("Failed to parse config")?;

    Ok(config)
}
}

Testing

Unit Tests

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parser_basic() {
        let mut parser = Parser::new();
        let result = parser.parse(b"<prompt>&gt;</prompt>").unwrap();
        assert!(matches!(result[0], ParsedElement::Prompt(_)));
    }
}
}

Integration Tests

#![allow(unused)]
fn main() {
// tests/integration_test.rs
use two_face::*;

#[test]
fn test_full_workflow() {
    // Setup
    let config = Config::default();
    let mut app = App::new(config);

    // Simulate game data
    app.process_data(test_data());

    // Verify state
    assert!(app.game_state().vitals().health > 0);
}
}

Version Compatibility

API stability follows semantic versioning:

  • 0.x.y: API may change between minor versions
  • 1.x.y: Breaking changes only in major versions

See Also