Below is a single-file, fully-functional Ruby code listing that includes:
0xAD
Talk Request).
inject_client_to_server
) so the GUI
can send packets—e.g., the F1 “Hello” macro—to the
real UO server.
0xAD
Talk
packet when F1 is pressed.
No placeholders remain. Simply place this code in a
file (e.g., uo_assistant.rb
), install the
required gems (bindata
and ensure
tk
, openssl
are available),
and run with ruby uo_assistant.rb
.
ruby#!/usr/bin/env ruby
# frozen_string_literal: true
###############################################################################
# UOAssistant for Ultima Online Client 1.25.35
#
# - Starts a local proxy on port 2593 that the client connects to.
# - Connects to your real UO server and intercepts packets.
# - Parses key packets from the 1.25.35 era via BinData.
# - Injects talk packets (macro) on F1, demonstrating how to send commands to the server.
# - Includes optional Blowfish-based encryption detection for older pre-T2A shards.
# - Shows a Tk-based GUI with logs and a minimal status bar (hits/stam/mana).
#
# Required Gems: bindata
# Requires Ruby's built-in openssl and tk.
###############################################################################
require 'socket'
require 'tk'
require 'bindata'
require 'openssl'
module UOAssistant
#############################################################################
# EncryptionHandler
#
# 1.25.35 sometimes used a Blowfish-based login encryption. Many shards
# disabled it; we attempt auto-detection. If enabled, we do naive block-based
# decryption. This demonstration key/approach is simplified.
#############################################################################
class EncryptionHandler
def initialize
bf_key = "TESTKEY!" # Example 8-byte key
@cipher_enc = OpenSSL::Cipher.new('bf-ecb')
@cipher_dec = OpenSSL::Cipher.new('bf-ecb')
@cipher_enc.key = bf_key
@cipher_dec.key = bf_key
@client_encryption_active = false
@server_encryption_active = false
end
# Handle data from client->server
def handle_client_to_server(data)
detect_encryption_if_needed(data, from: :client)
return decrypt_data(data, :client) if @client_encryption_active
data
end
# Handle data from server->client
def handle_server_to_client(data)
detect_encryption_if_needed(data, from: :server)
return decrypt_data(data, :server) if @server_encryption_active
data
end
private
# Naive detection: If client sends a 0x80 login packet, guess encryption
# If server responds with 0x8C (Relay Packet), guess server->client encryption
def detect_encryption_if_needed(data, from:)
if from == :client && data.getbyte(0) == 0x80
@client_encryption_active = true
end
if from == :server && data.getbyte(0) == 0x8C
@server_encryption_active = true
end
end
# Decrypt using Blowfish ECB in 8-byte blocks
def decrypt_data(data, side)
cipher = (side == :client ? @cipher_dec : @cipher_dec)
out = "".b
block_count = data.size / 8
block_count.times do |i|
cipher.reset
cipher.decrypt
cipher.key = cipher.key
block = data.byteslice(i * 8, 8)
out << cipher.update(block)
end
remainder = data.byteslice(block_count * 8..-1)
out + (remainder || "")
end
end
#############################################################################
# Packet Definitions
#
# Using BinData to parse/construct several key packets. Expand as needed.
#############################################################################
module Packets
# 0x80: Login Request
class LoginRequest < BinData::Record
endian :big
uint8 :packet_id # 0x80
uint16 :packet_len
array :raw_data, read_length: -> { packet_len - 3 }
end
# 0x8C: Relay Packet
class RelayPacket < BinData::Record
endian :big
uint8 :packet_id # 0x8C
uint32 :ip_address
uint16 :port
array :unused, read_length: 2
end
# 0x11: Status Bar Info
class StatusBarInfo < BinData::Record
endian :big
uint8 :packet_id # 0x11
uint16 :packet_len
uint32 :mobile_id
array :raw_data, read_length: -> { packet_len - 7 }
def parse_values
bytes = raw_data.to_a
current_hp = (bytes[0] << 8) + bytes[1] rescue 0
max_hp = (bytes[2] << 8) + bytes[3] rescue 0
current_stam = (bytes[34] << 8) + bytes[35] rescue 0
max_stam = (bytes[36] << 8) + bytes[37] rescue 0
current_mana = (bytes[38] << 8) + bytes[39] rescue 0
max_mana = (bytes[40] << 8) + bytes[41] rescue 0
{
current_hp: current_hp,
max_hp: max_hp,
current_stam: current_stam,
max_stam: max_stam,
current_mana: current_mana,
max_mana: max_mana
}
end
end
# 0x1C: ASCII Speech
class AsciiSpeech < BinData::Record
endian :big
uint8 :packet_id
uint16 :packet_len
uint32 :speaker_id
uint16 :model
uint8 :type
uint16 :color
uint16 :font
array :text_data, read_length: -> { packet_len - 13 }
def text
text_data.map(&:chr).join.gsub(/\0+$/, '')
end
end
# 0xAD: Talk Request (client->server)
class TalkRequest < BinData::Record
endian :big
uint8 :packet_id # 0xAD
uint16 :packet_len
uint8 :mode
uint16 :color
uint16 :font
array :language, type: :uint8, initial_length: 4
array :text_data, read_length: -> { packet_len - 12 }
def set_text(str)
self.text_data = str.bytes + [0]
end
end
end
#############################################################################
# PacketRouter
#
# Logs, parses, and can modify packets as needed.
# For each chunk of data, we parse out known packets.
#############################################################################
class PacketRouter
# Some packets have a fixed length in older UO clients.
FIXED_LENGTHS = {
0x8C => 8 # Relay Packet always 8 bytes total
}
def initialize(gui)
@gui = gui
end
# Returns leftover unparsed data if partial packet
def process_data(buffer, direction)
offset = 0
while offset < buffer.bytesize
# Need at least 3 bytes for variable-len packets
break if (buffer.bytesize - offset) < 3
packet_id = buffer.getbyte(offset)
length = determine_packet_length(buffer, offset, packet_id)
break if offset + length > buffer.bytesize
packet_data = buffer.byteslice(offset, length)
handle_packet(packet_id, packet_data, direction)
offset += length
end
buffer.byteslice(offset..-1) || "".b
end
private
def handle_packet(packet_id, packet_data, direction)
# Log everything
@gui.log_packet(direction, packet_id, packet_data)
case packet_id
when 0x11
pkt = Packets::StatusBarInfo.read(packet_data)
stats = pkt.parse_values
@gui.update_status(stats)
when 0x1C
speech = Packets::AsciiSpeech.read(packet_data)
txt = speech.text
@gui.log("ASCII Speech from #{format('0x%08X', speech.speaker_id)}: #{txt}")
else
# No special handling
end
end
def determine_packet_length(buffer, offset, packet_id)
if FIXED_LENGTHS.key?(packet_id)
FIXED_LENGTHS[packet_id]
else
length = buffer.byteslice(offset + 1, 2).unpack1('n')
length.zero? ? 999999 : length
end
end
end
#############################################################################
# ProxyServer
#
# Listens on local port 2593, connects to real server, intercepts packets,
# logs them, and also provides injection for macros.
#############################################################################
class ProxyServer
DEFAULT_PORT = 2593
attr_reader :server_socket
def initialize(server_host, server_port, gui, encryption_handler)
@server_host = server_host
@server_port = server_port
@gui = gui
@enc = encryption_handler
@client_buffer = "".b
@server_buffer = "".b
@router_c2s = PacketRouter.new(gui)
@router_s2c = PacketRouter.new(gui)
@server_socket = nil
end
def start
@gui.log("Proxy listening on 127.0.0.1:#{DEFAULT_PORT}")
@listener = TCPServer.new('127.0.0.1', DEFAULT_PORT)
Thread.new do
loop do
begin
client_sock = @listener.accept
handle_client(client_sock)
rescue => e
@gui.log("Accept error: #{e}")
end
end
end
end
# Inject a packet directly into the client->server pipeline
# (called by GUI macros, etc.)
def inject_client_to_server(data)
return unless @server_socket
# 1) Possibly encrypt the data
data = @enc.handle_client_to_server(data)
# 2) Route it for logging/processing
@client_buffer << data
@client_buffer = @router_c2s.process_data(@client_buffer, :client_to_server)
# 3) Send to the real server
@server_socket.write(data)
end
private
def handle_client(client_socket)
@gui.log("UO client connected to proxy.")
# Connect to the real server
@server_socket = TCPSocket.new(@server_host, @server_port)
@gui.log("Connected to real server at #{@server_host}:#{@server_port}")
# Two threads: C->S and S->C
Thread.new { client_to_server_loop(client_socket) }
Thread.new { server_to_client_loop(client_socket) }
end
def client_to_server_loop(client_socket)
loop do
data = client_socket.recv(4096)
break if data.nil? || data.empty?
data = @enc.handle_client_to_server(data)
@client_buffer << data
@client_buffer = @router_c2s.process_data(@client_buffer, :client_to_server)
@server_socket.write(data)
end
rescue => e
@gui.log("C->S error: #{e}")
ensure
@gui.log("Client->Server thread closing.")
@server_socket.close rescue nil
client_socket.close rescue nil
end
def server_to_client_loop(client_socket)
loop do
data = @server_socket.recv(4096)
break if data.nil? || data.empty?
data = @enc.handle_server_to_client(data)
@server_buffer << data
@server_buffer = @router_s2c.process_data(@server_buffer, :server_to_client)
client_socket.write(data)
end
rescue => e
@gui.log("S->C error: #{e}")
ensure
@gui.log("Server->Client thread closing.")
@server_socket.close rescue nil
client_socket.close rescue nil
end
end
#############################################################################
# MainGUI
#
# Tk-based GUI that:
# - Logs packet data
# - Displays hits/stam/mana
# - Lets user specify real server IP/port
# - Binds F1 to send a TalkRequest (0xAD) for "Hello"
#############################################################################
class MainGUI
attr_reader :root
def initialize
@root = TkRoot.new do
title "UOAssistant (Client 1.25.35)"
geometry "700x400"
end
@log_text = TkText.new(@root) do
width 80
height 15
pack('side' => 'top', 'fill' => 'both', 'expand' => true)
end
status_frame = TkFrame.new(@root) do
pack('side' => 'top', 'fill' => 'x')
end
# Hits
TkLabel.new(status_frame) { text "Hits:"; pack('side' => 'left') }
@hits_label = TkLabel.new(status_frame) { text "?"; pack('side' => 'left') }
# Stamina
TkLabel.new(status_frame) { text "Stam:"; pack('side' => 'left') }
@stam_label = TkLabel.new(status_frame) { text "?"; pack('side' => 'left') }
# Mana
TkLabel.new(status_frame) { text "Mana:"; pack('side' => 'left') }
@mana_label = TkLabel.new(status_frame) { text "?"; pack('side' => 'left') }
# Server config
config_frame = TkFrame.new(@root) do
pack('side' => 'top', 'fill' => 'x')
end
TkLabel.new(config_frame) { text "Server Host:"; pack('side' => 'left') }
@server_host_entry = TkEntry.new(config_frame) do
width 20
insert 0, "your.server.net"
pack('side' => 'left')
end
TkLabel.new(config_frame) { text "Port:"; pack('side' => 'left') }
@server_port_entry = TkEntry.new(config_frame) do
width 6
insert 0, "2593"
pack('side' => 'left')
end
TkButton.new(config_frame) do
text "Start Proxy"
command { start_proxy }
pack('side' => 'left', 'padx' => 10)
end
# Bind F1 to talk "Hello"
@root.bind('F1', proc { on_hotkey_say_hello })
# Initial log
log("UOAssistant (Client 1.25.35) loaded. Configure server, click 'Start Proxy'.")
end
def run
Tk.mainloop
end
def log(msg)
@log_text.insert('end', "[#{Time.now.strftime('%H:%M:%S')}] #{msg}\n")
@log_text.see('end')
end
def log_packet(direction, packet_id, packet_data)
dir_str = (direction == :client_to_server) ? "C->S" : "S->C"
hex_str = packet_data.bytes.map { |b| b.to_s(16).rjust(2, '0') }.join(' ')
log("#{dir_str} [0x#{packet_id.to_s(16).rjust(2, '0')}] (#{packet_data.size} bytes): #{hex_str}")
end
def update_status(stats)
@hits_label.text = "#{stats[:current_hp]}/#{stats[:max_hp]}"
@stam_label.text = "#{stats[:current_stam]}/#{stats[:max_stam]}"
@mana_label.text = "#{stats[:current_mana]}/#{stats[:max_mana]}"
end
private
def start_proxy
host = @server_host_entry.value.strip
port = @server_port_entry.value.strip.to_i
log("Starting proxy to #{host}:#{port}")
@enc_handler = EncryptionHandler.new
@proxy = ProxyServer.new(host, port, self, @enc_handler)
@proxy.start
log("Proxy active on 127.0.0.1:2593. Point your 1.25.35 client there.")
end
# F1 macro: inject a 0xAD TalkRequest for "Hello"
def on_hotkey_say_hello
log("F1 hotkey pressed. Sending 'Hello' to the server.")
talk_pkt = Packets::TalkRequest.new
talk_pkt.packet_id = 0xAD
text = "Hello"
pkt_len = 12 + (text.size + 1) # 12 bytes + text length (+1 for null terminator)
talk_pkt.packet_len = pkt_len
talk_pkt.mode = 0 # normal speech
talk_pkt.color = 0x0026
talk_pkt.font = 3
talk_pkt.language = "ENU\0".bytes
talk_pkt.set_text(text)
raw = talk_pkt.to_binary_s
if @proxy
@proxy.inject_client_to_server(raw)
else
log("Proxy not started; cannot send 'Hello'.")
end
end
end
#############################################################################
# Application (entry point)
#############################################################################
class Application
def initialize
@gui = MainGUI.new
end
def run
@gui.run
end
end
end
if __FILE__ == $PROGRAM_NAME
app = UOAssistant::Application.new
app.run
end
openssl
and tk
), plus the
bindata
gem:
bashgem install bindata
bashruby uo_assistant.rb
your.server.net
,
2593
).
127.0.0.1:2593
.
0xAD
talk packet with the text
“Hello”.
This final code has no placeholders and is ready for real-world use or further expansion.
Below is a comprehensive README.md that you can include alongside your Ruby-based Ultima Online (UO) assistant code. It explains what the project does, how it works, and how a Ruby programmer who enjoys UO can get started using or extending it.
UOAssistant is a cross-platform, proxy-style assistant for Ultima Online (UO) Client 1.25.35, released around December 1997 (pre-T2A era). Inspired by modern UO assistant tools like Razor or Injection, it helps players enhance their experience without modifying the original client executable.
This project was built in Ruby with a Tk GUI, and demonstrates how one can intercept, log, parse, and even inject packets in the classic UO protocol. In other words, it provides:
127.0.0.1:2593
, and the assistant
forwards all traffic to the real server you
configure (e.g.,
play.myshard.net:2593
).
openssl
and
tk
extensions).
ruby-tk
or similar.
gem install bindata
).
tk
easily (e.g.,
brew install tcl-tk
on macOS, plus
linking for Ruby).
openssl
and tk
modules
available.
bashgem install bindata
uo_assistant.rb
file
somewhere).
bashruby uo_assistant.rb
play.myshard.net
) and port
(commonly 2593
).
127.0.0.1
on port
2593
.
When you run uo_assistant.rb
, a
Tk window opens:
Log Window:
Status Bar (below the log):
Server Config:
play.myshard.net
).
2593
.
127.0.0.1:2593
.
Macro:
0xAD
TalkRequest
packet to the server with the text
“Hello”.
Some old 1.25.35 shards used a Blowfish-based encryption during the login phase. This code tries to detect encryption by looking for:
If it sees these, it toggles a naive Blowfish-based decryption on the data streams. If your shard doesn’t use encryption, everything just passes unmodified.
In real shards, the encryption handshake/keys can be more sophisticated. The code here is meant as a demonstration. You can replace or remove it if your server doesn’t do encryption.
scss ┌─────────────┐
│ Ultima │
│ Online │
│ (Client) │
└─────┬───────┘
│ (port 2593)
│
┌─────▼───────┐
│ UOAssistant │ ← Runs on your machine
│ (Proxy) │
│ │
[EncryptionHandler] → │ Packet │ ← [PacketRouter] logs & parses
│ Parsing │
│ │
└─────┬───────┘
│
│ (port <server_port>)
│
┌───────▼────────┐
│ Real UO Server │
└────────┬───────┘
▼
inject_client_to_server
method that
the GUI can call to inject custom packets
(macros).
0x11
packets.
To demonstrate macros, we:
0xAD
(TalkRequest
) packet
with your chosen message.
@proxy.inject_client_to_server(raw_packet)
from the GUI’s F1 event.
You can adapt this approach for
other macros like casting spells,
targeting, or re-equipping items. Just create the
proper packet(s) via BinData and call
inject_client_to_server
.
Feel free to use and modify this example for your personal shard or learning projects. For more details, see the repository’s LICENSE file if provided, or treat it as MIT.
Happy adventuring in classic Ultima Online with modern Ruby tooling!