Introduction
daRPC, short for Dark Ages Remote Procedure Call, lets other programs observe and control a running Dark Ages client.
It can read the character and map state the client already knows, follow live changes, and ask the client to perform actions such as walking, using a skill, or casting a spell. One daemon can bring several clients together behind a local web API.
The project is in active development and supports one exact 7.41 client build. It is intended for education, research, interoperability, and user-controlled automation.
Why work inside the client?
Many Dark Ages tools watch the game through a network proxy. A proxy is very useful for studying packets, but it sees only what travels between the game and the server. It may need to rebuild state from packet history, and it cannot always see client-only details such as an open dialog, an active path, or a local action in progress.
daRPC attaches a small Dynamic Link Library (DLL) to the game client instead. This gives it a direct view of the state the client is using right now. It can also call the same native client methods used by the game interface, rather than creating network packets from scratch.
That matters for actions. The client still performs its usual checks, updates its interface, and keeps its normal timing. Native pathfinding can build a route, and ordinary player input can interrupt that route without fighting a separate movement loop. An external planner can install an exact native route, observe movement results, and decide when to cancel or replan it.
The DLL can attach to an already running client and detach without closing it. A packet analyzer can still run alongside daRPC when both views are useful.
Ways to use daRPC
loader.exe launches a client or attaches and detaches the DLL.
darpc.exe talks directly to one injected client. It is useful for quick
checks, scripts, and setups that do not need a central daemon.
darpcd.exe discovers several clients and exposes them through:
- REST for current state and actions
- Server-Sent Events (SSE) for live changes
- OpenAPI and Swagger UI for exploring the API
REST and SSE are the complete supported web transport model: REST handles bounded commands and state reads, and SSE streams live changes. WebSockets are intentionally unsupported because they would add a second command and connection lifecycle without a demonstrated need.
Where to begin
Choose the path that matches what you are building:
| Goal | Start here |
|---|---|
| Read client state or submit actions | Web API |
| React to changes as they happen | Live events |
| Understand character and world fields | Game data |
| Compare the three command-line programs | Executable components |
| Work directly with one DLL without the daemon | darpc.exe |
| Launch, attach, or automatically load clients | darpcd.exe and loader.exe |
| Learn how the pieces fit together | How it works |
| Understand hooks and main-thread safety | Runtime hooks |
The Swagger UI at http://127.0.0.1:2626/docs is also a useful place to
explore REST routes and try requests against a running daemon.
Web API
darpcd.exe turns Windows-specific client integration into ordinary local web
interfaces. A script or application can use REST and Server-Sent Events (SSE)
without implementing DLL injection, named pipes, or Dark Ages client layouts.
REST and SSE are the supported web interfaces.
If you are building a consumer, start here for routes and actions, then use Live events for the complete streaming reference. The chapters under Game data explain the meaning of character and world fields.
Starting the server
The daemon listens on 127.0.0.1:2626 by default. Use --port <port> to change
only the loopback port, or --listen <ipv4[:port]> to select an explicit IPv4
interface. Startup fails if the address is already in use instead of silently
selecting a different port.
With the default port:
http://127.0.0.1:2626/docsopens the self-hosted Swagger UI.http://127.0.0.1:2626/openapi.jsonreturns the OpenAPI document.http://127.0.0.1:2626/healthreports daemon availability.
The API has no URL version prefix. daRPC maintains one current API while it is in active development.
Choosing a client
Every {client} path accepts either:
- A decimal process ID, such as
6076 - A current character name, such as
ZiLo, matched without case sensitivity
Process ID addressing is always available for a discovered client. Character
name addressing is available only when a connected snapshot is in_game.
Title, transition, disconnected, and stale clients use their process ID. If two
active clients have the same character name, the name is rejected as ambiguous
rather than choosing one.
Use GET /clients to discover the current path value and connection state for
every tracked client.
Observation-backed routes return 503 Service Unavailable when no complete
snapshot is available or when the daemon rejects an event batch during
reduction. The daemon keeps the last valid snapshot internally but does not
serve it as current data. A fresh snapshot restores the routes and establishes
the boundary for new Server-Sent Events streams.
Client registry
curl "http://127.0.0.1:2626/clients"
The registry reports each process ID, usable name, connection status, and any
available identity or compatibility details. Common statuses include
not_loaded, initializing, connecting, connected, busy,
disconnected, and incompatible.
instance_id identifies one loaded DLL lifetime. It changes after unload and
reload. Process creation time is encoded as a decimal string so JavaScript does
not lose 64-bit precision. Instance IDs and executable fingerprints use
lowercase hexadecimal text.
Current data routes
The current game data is split by domain so consumers can request only what they need:
| Route | Guide |
|---|---|
GET /clients/{client}/status | Character status |
GET /clients/{client}/items | Inventory |
GET /clients/{client}/equipment | Equipment |
GET /clients/{client}/skills | Skills |
GET /clients/{client}/spells | Spells |
GET /clients/{client}/effects | Effects |
GET /clients/{client}/objects | World |
GET /clients/{client}/messages | Messages |
GET /clients/{client}/dialog | NPC dialogs |
GET /clients/{client}/message-dialogs | Message dialogs |
GET /clients/{client}/field-map | Field maps |
GET /clients/{client}/bulletin | Bulletin boards and player mail |
GET /clients/{client}/group | Groups |
GET /clients/{client}/exchange | Exchange |
GET /clients/{client}/who | Online players |
GET /clients/{client}/legend | Legend |
GET /clients/{client}/players/{player} | Read one case-insensitive visible player from retained state; see World. |
POST /clients/{client}/players/{player}/inspect | Refresh one visible player; see World. |
GET /maps/{map_id}/download | Download one locally available raw map file. |
Most routes read the daemon’s retained state and do not ask the DLL to scan the game client for every HTTP request. The Who, Legend, and player-inspection routes are bounded requests to the game server; Legend coalesces refreshes for one second. See Game data for baseline capture, revisions, and unavailable values, Online players for Who timing and filters, and Legend for self-look refresh behavior.
The complete field list and JSON schema are generated from the Rust API models and are available in Swagger. The domain guides explain what the fields mean in the game and how they change.
Map file downloads
The daemon automatically uses the Maps directory beside the first discovered
local Darkages.exe. Use --maps-path <path> only to override that directory.
A remote consumer can request GET /maps/{map_id}/download; map ID 3001, for
example, reads lod3001.map from the selected directory.
A successful response is the exact file bytes with media type
application/octet-stream and an attachment filename. HTTP already transports
arbitrary bytes without corruption, so the endpoint does not add base64
encoding or JSON overhead. Missing files and the period before a client map
directory is discovered return 404 with the normal structured
map_not_found error. Files larger
than 4 MiB are rejected with 413; the daemon does not read an unbounded file
into memory. Numeric 16-bit map IDs are the only accepted path input, so a
request cannot select another file in or outside the configured directory.
Action routes
| Route | Purpose |
|---|---|
POST /clients/{client}/turn | Face a cardinal direction. |
POST /clients/{client}/look | Look at the tile directly ahead and emit the result asynchronously. |
POST /clients/{client}/far-look | Look at one tile on the current map and emit the result asynchronously. |
POST /clients/{client}/walk | Take one step, pathfind to a tile, or install an exact route. |
DELETE /clients/{client}/walk | Cancel the active route. |
POST /clients/{client}/resync | Request the same server refresh as the F5 key. |
POST /clients/{client}/skills/use | Use a skill by slot or name. |
POST /clients/{client}/skills/swap | Swap skills using slot-or-name selectors. |
POST /clients/{client}/spells/cast | Cast a spell by slot or name. |
POST /clients/{client}/spells/swap | Swap spells using slot-or-name selectors. |
POST /clients/{client}/items/use | Use an inventory item by slot or name. |
POST /clients/{client}/items/drop | Drop an item at a ground tile. |
POST /clients/{client}/items/give | Give an item to a visible human, monster, or NPC. |
POST /clients/{client}/items/swap | Swap inventory slots using slot-or-name selectors. |
POST /clients/{client}/items/pickup | Pick up the top ground item at a tile. |
POST /clients/{client}/chant | Send verbatim text as a spell chant. |
POST /clients/{client}/messages/send | Send say, shout, guild, group, or whisper chat. |
POST /messages/send | Send a daemon-only internal payload to one named client or all connected clients. |
POST /clients/{client}/items/sell | Ask an NPC to buy one named item. |
POST /clients/{client}/items/sell-all | Ask an NPC to buy all matching named items. |
POST /clients/{client}/items/deposit | Deposit a named item with an NPC. |
POST /clients/{client}/items/withdraw | Withdraw a named item from an NPC. |
POST /clients/{client}/items/repair | Repair one named item through an NPC. |
POST /clients/{client}/items/repair-all | Ask an NPC to repair all items. |
POST /clients/{client}/gold/drop | Drop gold at a ground tile. |
POST /clients/{client}/gold/give | Give gold to a visible human, monster, or NPC. |
POST /clients/{client}/equipment/unequip | Unequip one readable equipment slot. |
POST /clients/{client}/emote | Perform an emote by confirmed name or client code. |
POST /clients/{client}/raw/send | Send a bounded custom client packet or dispatch a synthetic server packet. |
POST /clients/{client}/assail | Submit the client’s native basic-attack packet. |
POST /clients/{client}/stats/{stat} | Spend one available point on strength/str, dexterity/dex, intelligence/int, wisdom/wis, or constitution/con. |
POST /clients/{client}/interact | Start a conversation with a visible Mundane. |
POST /clients/{client}/dialog/select | Select a row in the current NPC dialog. |
POST /clients/{client}/dialog/input | Answer the current text prompt. |
POST /clients/{client}/dialog/previous | Move to the previous pursuit page. |
POST /clients/{client}/dialog/next | Move to the next pursuit page. |
POST /clients/{client}/dialog/close | Close the current NPC dialog. |
POST /clients/{client}/message-dialogs/dismiss | Dismiss one current message dialog. |
POST /clients/{client}/field-map/select | Select one destination from the active field map. |
POST /clients/{client}/bulletin/actions | Open, navigate, scroll, compose, or mutate bulletin boards and player mail. |
POST /clients/{client}/group/toggle | Toggle invitations, or leave the current group. |
POST /clients/{client}/group/invite | Invite a visible player. |
POST /clients/{client}/group/invitations/{id}/accept | Accept a pending invitation. |
POST /clients/{client}/group/invitations/{id}/decline | Decline a pending invitation. |
POST /clients/{client}/exchange/items | Add an inventory item to the current exchange. |
POST /clients/{client}/exchange/gold | Set the local exchange gold once. |
POST /clients/{client}/exchange/accept | Accept the current exchange. |
POST /clients/{client}/exchange/cancel | Cancel the current exchange. |
POST /clients/{client}/players/{player}/inspect | Refresh one case-insensitive visible player profile. The cache-only GET route is listed under current data routes. |
POST /clients/{client}/commands/diagnostic | Run a no-op main-thread command for testing. |
GET /clients/{client}/diagnostics/hooks | Query the current hook timing mode and counters. |
PUT /clients/{client}/diagnostics | Set mode to disabled or hook_timing, optionally with reset: true. |
GET /clients/{client}/commands/{command_id} | Read retained command status. |
DELETE /clients/{client}/commands/{command_id} | Cancel a command that has not started. |
Movement request bodies, route injection, cancellation, and stop reasons are documented in Movement. Emote names and codes are documented in Emotes. Look request bodies, response correlation, popup suppression, and result events are documented in Looking at tiles. Item, gold, pickup, chant, and NPC item-action bodies are documented in Inventory. Outbound chat and internal inter-client message fields are documented in Messages. Equipment, skill, and spell arguments are documented in their respective chapters. NPC interaction, revision checks, and dialog responses are documented in NPC dialogs. Stat-point spending is documented with Character status. Group state, invitations, and roster confirmation are documented in Groups. Player offers, constraints, and exchange completion are documented in Exchange. Raw packet syntax and crash risks are documented in Raw packets.
Runtime hook diagnostics require DLL component 1.5.2 or later. The mode is
disabled by default. A successful query returns the stage budget, call count,
total, average, maximum, over-budget count, and last duration in microseconds.
Reset clears counters but the request’s mode remains authoritative for the
resulting runtime state.
Resynchronizing a client
POST /clients/{client}/resync takes no request body. It schedules the same
opcode-only refresh as pressing F5 in the game client. Use it when the client
appears out of sync with the server, such as after movement is rejected or the
character appears stuck against a wall.
The response describes the one active refresh:
{
pid: u32,
instance_id: string,
resync_id: u32,
coalesced: bool,
resync: {
phase: idle | waiting_to_send | awaiting_response,
active_resync_id: u32?,
pending_count: u32,
},
}
coalesced: true means another F5 or HTTP request already owns the returned
resync_id; daRPC did not send a second packet. pending_count is always zero
in 1.7.0. The HTTP response does not mean the server redraw is finished. Follow
client.resync and client.resync_completed on the event stream.
See Refresh and resynchronization for movement safety, the one-second fallback, object reconciliation, error codes, and the complete consumer sequence.
Basic attacks require no request body:
curl --request POST "http://127.0.0.1:2626/clients/ZiLo/assail"
The action submits client packet 0x13 on the game thread. Observe
player.animated and sound.played on the client’s event stream for the
server-confirmed animation and sound cues.
Native command results
Native actions are queued for the client main thread. A response contains a
command_id, command kind, current state, timing information, and an optional
failure reason.
Failed exact-route replacements can also contain diagnostics. It reports the
packet-confirmed and native committed positions, an active staged destination,
map IDs, transition state, route mode, and current destination. The field is
absent from other command results.
Command states are:
accepted, executed, failed, cancelled, timed_out
200 OK means the command reached a final state during the request’s bounded
wait. 202 Accepted means it is still queued and can be checked later. A full
queue returns 429 Too Many Requests; an unavailable client returns
503 Service Unavailable.
An executed state means the client accepted and ran the local native call. A later game event is the better proof of the resulting state or server submission. daRPC does not automatically retry actions.
Command IDs belong to one DLL instance_id. Do not apply a retained result to
a different DLL lifetime. A command can be cancelled or expire before it
starts. Once native execution has begun, it completes normally.
The DLL executes at most one queued command per client tick. Web handling, named-pipe input/output, allocation, serialization, and logging stay off the game thread. See Runtime hooks for the reason.
Server-Sent Events
curl --no-buffer --header "Accept: text/event-stream" \
"http://127.0.0.1:2626/clients/ZiLo/events"
SSE is a one-way live stream. Use it for changing vitals, inventory updates, walking, spell activity, nearby objects, messages, and action observations.
The endpoint requires a connected client with current state. The first event
is a stream.ready boundary:
id: 38
event: stream.ready
data: {"type":"stream_ready","data":{"pid":6076,"instance_id":"...","revision":42,"event_sequence":38}}
After stream.ready, read the REST resources needed by the consumer, then
apply later events in their delivered order. The daemon begins listening before
it reads the ready boundary, so a change cannot slip between those two steps.
Listening from the command line
curl --no-buffer --header "Accept: text/event-stream" \
"http://127.0.0.1:2626/clients/ZiLo/events"
Swagger UI shows the SSE response schemas but is not a live stream viewer.
Browser applications can use EventSource; other clients need streaming
response support.
The Live events chapter contains the complete event catalog, payload structures, ordering rules, collection batching, browser examples, and recovery procedure. Read it before relying on a long-running stream.
Managed client lifecycle
The daemon can launch a client or load and unload the DLL:
| Route | Purpose |
|---|---|
POST /clients/launch | Launch the client and initialize the DLL. |
POST /clients/{client}/load | Load the DLL into a discovered client. |
POST /clients/{client}/unload | Shut down and unload the DLL. |
Load and unload have no request body. The daemon’s --loader-path and
--dll-path settings choose the trusted tools.
The smallest launch request is:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"client_path":"C:\\Games\\Dark Ages\\Darkages.exe"}' \
"http://127.0.0.1:2626/clients/launch"
Available launch options are:
{
client_path,
allow_multiple: false,
show_items_with_alt: false,
skip_exchange_alerts: false,
skip_intro: false,
skip_notice: false,
server: null,
}
client_path must be a fully qualified Windows drive or Universal Naming
Convention (UNC) path. Its parent directory becomes the client’s working
directory. server accepts host or host:port, with port 2610 as the
default. skip_exchange_alerts replaces the one-button result shown after a
completed or cancelled player exchange with the same text in the floating
game-message bar without changing the exchange itself.
show_items_with_alt applies the launch-only ground-item patch and
reveals up to 255 items while either Alt key is held.
The API does not accept arbitrary game arguments or request-selected loader and
DLL paths.
Load reports whether it actually changed an unloaded client. Unload reports
whether it actually changed a loaded client. Launch returns the new process ID
after the loader initializes and resumes it. Daemon discovery is asynchronous,
so poll /clients until the process becomes connected or reports an error.
Errors
Validation errors use the applicable HTTP 4xx response. Environmental and client availability failures use a suitable 5xx response. Managed lifecycle errors use this general shape:
{
"error": {
"code": "...",
"message": "...",
"pid": 6076
}
}
Unknown fields are rejected. Current request-size limits and exact response models are included in OpenAPI.
OpenAPI and Swagger UI
The OpenAPI document is generated from the Rust HTTP models. It can be imported into Postman, Apidog, client generators, or another OpenAPI consumer.
/docshosts the interactive Swagger UI./openapi.jsonserves the canonical OpenAPI 3.1 JSON for the running binary.- Release bundles include the same document as
openapi.jsonfor offline use. darpcd.exe --print-openapiprints the document and exits without starting the server.
Swagger UI uses vendored assets and an Ayu-inspired dark theme, so it works without an internet connection. A Swagger rendering problem cannot affect the registry, JSON routes, or DLL connections.
The OpenAPI document is the interface contract; Swagger UI is only one viewer. This separation allows the documentation frontend to change later without changing API routes or generated clients.
OpenAPI describes the JSON event envelopes but cannot fully express the surrounding SSE lines, stream ordering, lag, and reconnect behavior. This chapter is the source of truth for those transport rules.
Network access
The listener defaults to 127.0.0.1:2626. --listen <ipv4[:port]> can bind a
specific IPv4 interface or 0.0.0.0 for access from a host, another virtual
machine, or a trusted local network. The option changes the entire API
listener, not only Swagger UI.
The API has no authentication, authorization, or TLS. Any host that can reach a
non-loopback listener can read game state and submit actions. Prefer a specific
interface over 0.0.0.0, restrict the selected port with Windows Firewall, and
do not expose the listener to an untrusted network or the public internet. A
generally available remote mode still requires authentication, authorization,
request limits, and transport security.
WebSockets are intentionally unsupported. REST maps commands and state reads to bounded requests with ordinary HTTP status and error handling. SSE provides a persistent server-to-consumer event stream with straightforward reconnection. This split is adequate for real-time bot interaction and avoids duplicating validation, backpressure, ordering, and connection lifecycle behavior across a second bidirectional API. The decision can be revisited if a measured use case cannot be expressed cleanly with REST and SSE.
Refresh and resynchronization
Use refresh when the game client looks out of step with the server. Pressing F5
in the game and calling POST /clients/{client}/resync use the same daRPC path.
The game still sends its normal opcode-only 0x38 refresh packet.
One refresh at a time
daRPC keeps at most one user refresh active for each client. More F5 presses and more HTTP requests join the active refresh instead of sending another packet. This matters because the game server can combine refresh packets that arrive close together into one redraw.
An HTTP request that joins active work returns the active resync_id and sets
coalesced to true:
{
pid: u32,
instance_id: string,
resync_id: u32,
coalesced: bool,
resync: {
phase: idle | waiting_to_send | awaiting_response,
active_resync_id: u32?,
pending_count: u32,
},
}
pending_count is always zero in 1.7.0 because daRPC does not queue a second
refresh. waiting_to_send covers movement settling and packet submission.
awaiting_response begins after daRPC observes the outgoing 0x38 packet.
client.resync always carries a nonzero resync_id. HTTP refreshes use the
returned ID, while an in-game F5 receives a DLL-local ID.
Inside the DLL, one refresh transaction owns movement gating, packet submission, object reconciliation, completion and fallback ordering, and deferred snapshot recovery. Packet hooks report observations to that module; they do not advance individual parts of the transaction themselves.
The usual response is 200 OK. A request can return 202 Accepted while work
is still active or when it joins an active refresh. A full general command
queue returns command_queue_full. A narrow race where the DLL has already
seen an in-game F5 but the daemon has not seen its outgoing event can return
409 resync_busy. Retry after the active refresh completes.
Movement safety
Refreshing during the walking animation can make the native committed tile and the tile being animated disagree. That is the source of the common one-tile position error.
Before a user refresh, daRPC clears queued route movement but lets an accepted
step finish. It waits until the staged destination becomes the committed native
position, then sends 0x38. A corrected committed position must remain stable
for one more client tick. This uses the client’s real transition state, not a
fixed animation sleep.
The HTTP response can arrive before this safe point. client.resync is the
signal that the packet was actually sent. Consumers that pause movement should
release it only after the matching client.resync_completed event.
Refresh window
The server normally redraws the user and nearby objects, then may send the
payload-free 0x22 RefreshUserOK packet. The stock 7.41 client does not use
0x22, and the packet is not guaranteed to arrive. daRPC therefore treats it
as an early end marker, not as the only proof of completion.
The refresh window closes in either of these ways:
- daRPC observes authoritative position or redraw activity followed by
RefreshUserOK. - One second passes after the outgoing
0x38packet.
Both paths publish client.resync_completed. There is no
client.resync_timed_out event. Completion means daRPC has closed the refresh
and object-reconciliation window. It does not promise that RefreshUserOK was
received. The DLL’s unload diagnostics count one-second fallback completions
as user_refresh_fallbacks.
Object reconciliation
Consumers should keep their object state and apply normal lifecycle events. They do not need to clear the world or diff a second snapshot after F5.
At packet submission, daRPC marks the currently visible stable entity IDs. As redraw packets arrive:
- An existing ID stays in place and receives its normal change events.
- A new ID publishes its normal appeared event.
- A prior ID that does not return publishes its normal disappeared event when the refresh window closes.
If no authoritative position or redraw packet arrives, daRPC preserves the last-known objects instead of guessing that they disappeared. Map changes use the normal map and object lifecycle events and remain a separate reason for the visible set to change.
Object appeared and disappeared events are ordered before
client.resync_completed. There is no objects.cleared event and no separate
object-reconciliation completion event. daRPC may recapture its internal
snapshot after that ordered boundary to refresh client-only appearance state.
This does not require a consumer snapshot reload or world clear.
Consumer sequence
- Open the Server-Sent Events stream.
- Pause movement and call
POST /clients/{client}/resync. - Remember the returned
resync_id, including whencoalescedistrue. - Apply object and location events in stream order.
- Resume movement after
client.resync_completedwith the matching ID.
If the stream disconnects before completion, reconnect, read current state, and request another refresh. Do not wait forever for an event from the old stream.
Live events
The daemon exposes a Server-Sent Events (SSE) stream for each connected game client. Use it when a tool needs to react as the character, inventory, nearby world, or messages change.
REST and SSE work together:
- REST gives you the latest complete view of one part of the client.
- SSE tells you what changed after you started listening.
This chapter explains how to subscribe, recover from interruptions, and decode every event currently published by daRPC. The domain chapters explain what the same data means in the game.
Subscribe to a client
curl --no-buffer --header "Accept: text/event-stream" \
"http://127.0.0.1:2626/clients/ZiLo/events"
client can be a process ID or the current character name, matched without
regard to case. A character name works only while that client is in game. A
process ID remains the reliable identifier at the title screen or after a
disconnect.
The client must be connected to darpcd.exe and have current state. Otherwise,
the endpoint returns the normal JSON API error instead of opening a stream.
Swagger describes the JSON event models, but its Try it out interface is not an
SSE viewer. The curl command above displays the live stream directly. Browser
applications can use EventSource, and other consumers need an SSE-capable
HTTP library.
Read an SSE frame
Every published frame contains an SSE sequence ID, a routing name, and a JSON body:
id: 38
event: vitals.changed
data: {"type":"vitals_changed","data":{"observation":{...},"health":4200,"max_health":5000,"mana":1800,"max_mana":2000}}
The two names serve different purposes:
- The SSE
eventname usesdomain.action, such asvitals.changed. - The JSON
typediscriminator usessnake_case, such asvitals_changed.
Use the SSE name with addEventListener. Use the JSON discriminator when one
handler decodes several event types. Do not derive one name mechanically from
the other. Most correspond naturally, but character.turned uses turned,
character.emoted uses emoted, and every message channel uses message.
The endpoint sends every event for the selected client. It does not currently accept server-side event filters. One connection is normally enough: register listeners only for the SSE names your tool uses. Open one stream per game client when a tool follows several clients.
An SSE ID marks stream order, not a globally unique record. One observed client update can produce several frames with the same ID. Process frames in delivery order and do not discard a frame only because its ID matches the previous one.
Start with a current baseline
The first frame is always stream.ready:
id: 38
event: stream.ready
data: {"type":"stream_ready","data":{"pid":6076,"instance_id":"890b3755fccd8d45b165bed41165457a","revision":42,"event_sequence":38}}
After receiving it:
- Read the REST resources your tool needs.
- Apply later SSE frames in delivery order.
The daemon starts listening for client changes before it creates the ready boundary. A change therefore cannot slip between the boundary and the REST reads. The REST responses can already include a newer revision, which is safe because later events contain absolute replacement values.
StreamReady {
pid: u32,
instance_id: string,
revision: u32,
event_sequence: u32,
}
Common observation metadata
Most events carry the observation that produced them:
EventObservation {
pid: u32,
instance_id: string,
revision: u32,
event_sequence: u32,
tick_ms: u32,
}
pididentifies the game process.instance_ididentifies this load ofdarpc.dll. It changes after unload and reinjection.revisionidentifies the retained client-state revision.event_sequenceorders updates produced by the DLL.tick_msis the client’s wrapping Windows millisecond tick.
Message events use their normalized message record instead of an
observation. Their SSE ID still supplies stream order, and the subscription
path identifies the client. Internal message IDs use internal-N; they do not
advance or interrupt the DLL state-event sequence.
A field followed by ? in the structures below is optional and can be absent
or null. Collection types use T[]. The examples describe JSON values rather
than Rust memory layouts.
Shared values
TilePosition { x: i32, y: i32 }
Direction = north | east | south | west
Element = none | fire | water | wind | earth | light | dark | wood | metal | undead | unknown
EffectDuration = white | red | orange | yellow | green | blue
Tile coordinates are zero-based. Effect durations are the client’s visible color bands from longest to shortest, not exact timers.
Client command events
Public speech beginning with one slash is a local command. The DLL suppresses
the chat submission and publishes client.command with the JSON discriminator
client_command:
ClientCommand {
observation: EventObservation,
command: string,
args: string[],
}
The command is the nonempty text between / and the first whitespace. The
remaining text is split on commas; surrounding whitespace and empty entries are
removed. For example, /walk x, , y publishes command: "walk" and
args: ["x", "y"]. Commands are transient and have no REST recovery route.
The outgoing refresh packet publishes client.resync with the JSON
discriminator client_resync:
ClientResync {
observation: EventObservation,
resync_id: u32,
}
Closing the refresh window publishes client.resync_completed with the JSON
discriminator client_resync_completed:
ClientResyncCompleted {
observation: EventObservation,
resync_id: u32,
}
Both events are transient. The identifier correlates the completion with the
packet submission and HTTP request. client.resync_completed is published
after RefreshUserOK or the one-second fallback closes reconciliation. Object
lifecycle updates caused by the refresh appear before it. See
Refresh and resynchronization for coalescing, movement safety,
fallback behavior, and the consumer sequence.
Begin speech with // to escape interception. The DLL removes one slash before
submission, so //walk x,y is spoken as /walk x,y and does not publish a
command event. Other speech and chat channels continue normally.
Client lifecycle events
The DLL checks the client lifecycle on the game thread and emits semantic events when it enters the two states most useful to automation:
| SSE event | JSON type | Meaning |
|---|---|---|
client.logged_in | client_logged_in | The client entered the in-game state. |
client.disconnected | client_disconnected | The client displayed its reconnect dialog. |
ClientLifecycleChanged {
observation: EventObservation,
previous: unknown | title | transition | in_game | disconnected,
current: unknown | title | transition | in_game | disconnected,
}
client.disconnected describes the game client’s server connection. It is not
the same as stream.closed, which means the daemon lost its DLL connection.
Transitions to other lifecycle states update the REST snapshot without
publishing another lifecycle event.
Reconnect and recover
Events are ordered within one client stream. daRPC does not replay state events
created before a subscription. It also does not resume from the browser’s
Last-Event-ID header.
The daemon keeps a bounded 4,096-entry broadcast queue. If a subscriber falls behind, or if the daemon cannot reduce a client event batch into its retained observation, it publishes this final event and closes the connection:
stream.resync_required
StreamResyncRequired {
pid: u32,
instance_id: string,
last_event_sequence: u32,
dropped_events: u64,
}
If the DLL connection ends, the daemon publishes this final event and closes the stream:
stream.closed
StreamClosed {
pid: u32,
instance_id: string,
last_event_sequence: u32,
reason: string,
}
For either event, discard assumptions based only on the old stream. Wait for
the client observation to become available, or for the client to reconnect if
necessary, then open a new stream, wait for stream.ready, and reread the REST
resources your tool uses. A reduction failure temporarily returns 503 Service Unavailable from observation-backed REST routes and new stream requests while
the daemon obtains a fresh snapshot. Message history has its own bounded
lookback and can restore recent conversation context.
Fifteen-second SSE comments keep an idle connection observable. They do not change ordering or carry game data.
Collection batches
Inventory, skill, and spell updates can change several slots as one client operation. These events use the same shape:
SlotChanged<T> {
observation: EventObservation,
batch_index: u16,
batch_count: u16,
slot: u8,
before: T?,
after: T?,
}
batch_index starts at zero. Every frame in the same update shares
batch_count, revision, and event sequence. The daemon applies the whole batch
to REST state before publishing the first frame.
A detailed consumer can wait for every batch entry. A simpler consumer can reread the matching REST route after receiving any event in that domain.
Character status events
Read the current values from GET /clients/{client}/status. See
Character status for field meaning and lifecycle behavior.
The character.* namespace describes the local character. Nearby players use
player.* events in the world object domain. In
particular, the local Hide transition is character.hidden_changed; there is
no player.hidden_changed event.
| SSE event | JSON type | Payload after observation |
|---|---|---|
stats.changed | stats_changed | stat_points, strength, intelligence, wisdom, constitution, dexterity |
vitals.changed | vitals_changed | Nullable health, max_health, mana, max_mana |
progression.changed | progression_changed | Nullable level, ability, and experience fields |
gold.changed | gold_changed | gold |
weight.changed | weight_changed | weight, max_weight |
modifiers.changed | modifiers_changed | Armor class, damage, hit, magic resistance, and elements |
location.changed | location_changed | x, y, and optional changed map details |
blind.changed | blind_changed | is_blinded |
action_restriction.changed | action_restriction_changed | is_action_restricted |
character.appearance_changed | character_appearance_changed | Optional complete previous and current appearances |
character.hidden_changed | character_hidden_changed | Boolean previous and current hidden state |
StatsChanged {
observation: EventObservation,
stat_points: u8,
strength: u16,
intelligence: u16,
wisdom: u16,
constitution: u16,
dexterity: u16,
}
VitalsChanged {
observation: EventObservation,
health: u32?,
max_health: u32?,
mana: u32?,
max_mana: u32?,
}
ProgressionChanged {
observation: EventObservation,
level: u8?,
ability_level: u8?,
experience: u32?,
ability_points: u32?,
experience_to_next_level: u32?,
ability_to_next_level: u32?,
}
ModifiersChanged {
observation: EventObservation,
armor_class: i8,
damage: u8,
hit: u8,
magic_resistance: u16,
attack_element: Element,
defense_element: Element,
}
LocationChanged {
observation: EventObservation,
x: i32,
y: i32,
map: MapChanged?,
}
MapChanged {
id: u32,
name: string?,
width: i32,
height: i32,
}
CharacterAppearanceChanged {
observation: EventObservation,
previous: CharacterAppearance?,
current: CharacterAppearance?,
}
CharacterHiddenChanged {
observation: EventObservation,
previous: bool,
current: bool,
}
Appearance and hidden changes are emitted after an authoritative snapshot
recapture, including the 0x33 self redraw used when Hide begins or ends.
Hide and monster-form transitions
Hide and monster form are separate, mutually exclusive states. Consumers
should use is_hidden, not nullable appearance fields, to detect Hide.
| Local transition | SSE event | /status result |
|---|---|---|
| Visible human to hidden | character.hidden_changed with false to true | is_hidden: true; the last complete gender, hair, and body fields remain available |
| Hidden to visible human | character.hidden_changed with true to false | is_hidden: false; gender, hair, and body contain the current visible appearance |
| Human to monster form | character.appearance_changed with a complete human previous and current: null | gender, hair_style, hair_color, and body_sprite are null; is_hidden remains false |
| Monster form to human | character.appearance_changed with previous: null and a complete human current | The four human appearance fields repopulate; is_hidden remains false |
A monster-form transition does not publish character.hidden_changed. The
local character model currently represents the normal human appearance but
does not expose the temporary monster sprite identifier.
For another visible player, a hidden 0x33 redraw updates the player’s
is_hidden field and is published through the normal player.appeared object
event. The object retains the last known name and inspected profile by entity
ID. Its visual block distinguishes human from creature; transformed-player
events include the creature sprite and transmitted color bytes. Consumers
should use that discriminator rather than infer a remote monster form from
is_hidden or missing profile data.
map is present when the map itself changed. Position and map details are
published together so consumers never observe coordinates from one map paired
with another map.
Map download events
Map download events describe the native game client’s cache-miss transfer, not
the daemon’s GET /maps/{map_id}/download route.
| SSE event | JSON type | Meaning |
|---|---|---|
map.requested | map_requested | The native client submitted a matching 0x05 map request after cache validation failed. |
map.downloaded | map_downloaded | The native client returned from handling the final 0x3C row after daRPC observed every prepared row. |
Both payloads contain observation, map_id, width, and height. The
completion event is published after the client’s native final-row path has
recomputed the map checksum, attempted the complete cache-file write, closed
the loading pane, and applied the prepared map. The client does not report the
cache writer’s result, so map.downloaded confirms transfer completion and the
native write attempt, not durable filesystem persistence.
These events are transient and have no REST recovery route. A fresh daemon connection resumes from the DLL’s current ordered event boundary; it does not invent a request for a cache hit or for a transfer that began before injection.
Walking and character action events
Read the current flags and position from GET /clients/{client}/status. See
Movement and Emotes for the action routes.
| SSE event | JSON type |
|---|---|
walking.started | walking_started |
walking.stopped | walking_stopped |
walking.obstructed | walking_obstructed |
walking.route_changed | walking_route_changed |
character.turned | turned |
character.emoted | emoted |
walking.started {
observation: EventObservation,
source: ActionSource,
current: TilePosition,
destination: TilePosition?,
}
walking.stopped {
observation: EventObservation,
source: ActionSource,
current: TilePosition,
destination: TilePosition?,
reached_destination: bool?,
reason: completed | obstructed | replaced | cancelled | position_corrected,
}
walking.obstructed {
observation: EventObservation,
source: ActionSource,
map_id: u32,
current: TilePosition,
attempted: TilePosition,
direction: Direction,
destination: TilePosition?,
mode: direct | native_route | exact_route | pursuit,
}
walking.route_changed {
observation: EventObservation,
source: ActionSource,
generation: u32,
tiles: Vec<TilePosition>,
}
character.turned {
observation: EventObservation,
source: ActionSource,
direction: Direction,
}
character.emoted {
observation: EventObservation,
code: u8,
}
destination is available for pathfinding but can be absent for a single
step. reached_destination is known only when there was a retained destination.
The stop reason distinguishes normal completion, obstruction, replacement,
explicit cancellation, and a server position correction. An obstruction
reports the rejected edge first. daRPC does not replan; a later rejected edge
resets the executing exact route and leaves recovery to the consumer. A
replacement rejected during preflight leaves the existing route intact and
emits no replacement, route-change, or obstruction event.
Action events mean the request reached the client’s normal action boundary.
They do not promise that the server accepted the result.
The source is a tagged object with kind unknown, client, or command.
Command sources also contain command_id. See
Movement action source for its guarantees and
limitations.
Inventory and equipment events
Read inventory from GET /clients/{client}/items and equipment from
GET /clients/{client}/equipment. See Inventory and
Equipment.
| SSE event | JSON type | Meaning |
|---|---|---|
item.added | item_added | A slot gained an item or stack quantity. |
item.removed | item_removed | A slot lost an item or stack quantity. |
item.changed | item_changed | An item moved, swapped, split, merged, or changed details. |
item.used | item_used | The client submitted item use. |
item.dropped | item_dropped | The client submitted an item to a ground tile, including /items/drop. |
item.given | item_given | The client submitted an item to an entity, including /items/give. |
item.picked_up | item_picked_up | The client submitted a ground-item pickup. |
item.pickup_failed | item_pickup_failed | A ground-item pickup received correlated carry-limit feedback. |
gold.dropped | gold_dropped | The client submitted gold to a ground tile, including /gold/drop. |
gold.given | gold_given | The client submitted gold to an entity, including /gold/give. |
equipment.unequipped | equipment_unequipped | The client submitted an unequip request. |
item.added, item.removed, and item.changed use
SlotChanged<InventoryItem>. Action payloads are:
ItemUsed { observation, slot }
ItemDropped { observation, slot, quantity, destination }
ItemGiven { observation, slot, quantity, target_id }
ItemPickedUp { observation, destination_slot, position }
ItemPickupFailed { observation, destination_slot, position, item_name, limit, reason, feedback, submitted_tick_ms, elapsed_ms }
GoldDropped { observation, amount, destination }
GoldGiven { observation, amount, target_id }
EquipmentUnequipped { observation, slot }
Giving an item opens the game’s normal exchange flow. It does not mean the other character accepted the exchange. Later inventory or gold events confirm changes accepted by the server.
Skill events
Read the skillbook from GET /clients/{client}/skills. See Skills.
| SSE event | JSON type | Payload |
|---|---|---|
skill.added | skill_added | SlotChanged<Skill> |
skill.removed | skill_removed | SlotChanged<Skill> |
skill.changed | skill_changed | SlotChanged<Skill> |
skill.cooldown | skill_cooldown | A retained skill entered or restarted cooldown. |
skill.ready | skill_ready | AbilityReady |
skill.used | skill_used | observation, slot, optional name |
skill.used records an observed submission through the client’s normal skill
path. skill.cooldown confirms that the retained skill entered cooldown, while
skill.ready confirms that it left cooldown. skill.changed is reserved for
changes to the retained skill itself, such as its name, icon, or level. A single
client update can emit both skill.changed and skill.cooldown when both kinds
of state changed together.
Spell events
Read the spellbook from GET /clients/{client}/spells. See Spells
for targeting, chanting, replacement, and feedback matching.
| SSE event | JSON type | Meaning |
|---|---|---|
spell.added | spell_added | A spellbook slot gained a spell. |
spell.removed | spell_removed | A spellbook slot became empty. |
spell.changed | spell_changed | A retained spell’s own details changed. |
spell.cooldown | spell_cooldown | A retained spell entered or restarted cooldown. |
spell.ready | spell_ready | A retained spell left cooldown. |
spell.begin | spell_begin | A delayed spell began. |
spell.chant | spell_chant | One chant line was submitted. |
spell.cast | spell_cast | The final spell use was submitted. |
spell.cancelled | spell_cancelled | A delayed spell ended without a final cast. |
spell.succeeded | spell_succeeded | System feedback confirmed a recent submission. |
spell.failed | spell_failed | System feedback rejected or resisted a recent submission. |
spell.received | spell_received | Another entity cast or attacked with a spell on this character. |
The spellbook events use SlotChanged<Spell>. Cooldown and cast activity use:
CooldownStarted { observation, slot, name?, cooldown_ms?, remaining_ms? }
AbilityReady { observation, slot, name? }
SpellBegin { observation, slot, name?, total_lines }
SpellChant { observation, slot, name?, line, total_lines }
SpellCast { observation, slot, name?, arguments? }
SpellCancelled { observation, slot, name?, source }
SpellSucceeded {
observation,
slot,
name?,
arguments?,
feedback,
submitted_tick_ms,
elapsed_ms,
}
SpellFailed {
observation,
slot,
name?,
arguments?,
reason,
active_spell?,
feedback,
submitted_tick_ms,
elapsed_ms,
}
SpellReceived {
observation,
caster,
caster_object?,
name,
kind,
feedback,
}
Skill cooldown events use the same CooldownStarted and AbilityReady
payloads. cooldown_ms is the stable total duration from the live action-delay
packet and remaining_ms is the time left at observation. Skills also retain
start and end timestamps in client memory, which restores exact timing after a
late attach. Spells do not, so both fields can be absent after a late attach
even while the active cooldown and eventual ready transition remain observable.
When both are present, remaining_ms does not exceed cooldown_ms. A consumer
can calculate progress from both values but should treat *.ready as the
authoritative completion signal rather than relying only on a local countdown.
SpellCastArguments is tagged by its own type field:
SpellCastArguments =
{ type: "unknown" }
| { type: "target", id: u32?, name: string?, x: i32, y: i32 }
| { type: "input", value: string }
| { type: "values", values: u16[] }
Cancellation source is client, server, or replaced. Failure reason is
failed, error, resisted, already_active, or conflicting_effect.
Received spell kind is cast for friendly wording or attack for harmful
wording.
An instant spell normally emits only spell.cast. A delayed spell normally
emits spell.begin, one or more spell.chant events, and spell.cast.
spell.succeeded and spell.failed are later interpretations of system text,
not replacements for spell.cast.
Persistent effect events
Read active status effects from GET /clients/{client}/effects. See
Effects.
| SSE event | JSON type |
|---|---|
effect.added | effect_added |
effect.removed | effect_removed |
effect.changed | effect_changed |
effect.added { observation, icon, duration }
effect.removed { observation, icon }
effect.changed { observation, icon, duration }
These events describe the persistent effect icons shown by the client. They are
different from the temporary player.effect, monster.effect, and
mundane.effect visuals described below.
World object events
Read the currently retained view from GET /clients/{client}/objects. See
World for object fields, view-range behavior, and map
boundaries.
Players, monsters, and Mundanes publish these object actions:
player.appeared monster.appeared mundane.appeared
player.replaced
player.disappeared monster.disappeared mundane.disappeared
player.moved monster.moved mundane.moved
player.direction_changed monster.direction_changed mundane.direction_changed
player.inspected
player.replaced is published instead of player.appeared when a newly shown
player has the same name as one or more retained players but a different object
ID. Its payload contains every stale snapshot and the authoritative replacement:
PlayerReplaced {
observation: EventObservation,
previous: WorldObject[],
current: WorldObject,
}
Ground items publish:
item.appeared
item.disappeared
item.moved
Their JSON discriminator replaces the dot with an underscore, such as
player_appeared, mundane_direction_changed, or item_moved.
The remaining object events use this payload:
ObjectChanged {
observation: EventObservation,
object: WorldObject,
}
WorldObject is tagged by kind:
WorldObject =
Player { kind: "player", id, name?, x, y, direction, profile? }
| Monster { kind: "monster", id, sprite?, x, y, direction }
| Mundane { kind: "mundane", id, sprite?, name?, x, y, direction }
| Item { kind: "item", id, sprite, dye_color, x, y, z_index }
Appeared, moved, and direction-changed events carry the object after the update. A disappeared event carries the last retained object. Refreshes and map changes publish these same per-object lifecycle events instead of a collection-wide clear event.
Reduce the stream into retained object state as follows:
- Upsert
objectby ID for appeared, moved, and direction-changed events. - Remove
object.idfor disappeared events. - For
player.replaced, remove everypreviousID and upsertcurrent. - For
player.inspected, upsertplayerwhen retaining profile data. - Do nothing to the object collection for
client.resyncorclient.resync_completed. - On a map change, apply
location.changedfirst and then the following disappearance events in delivery order. Do not clear objects when the location event arrives.
An appeared event can replace an already retained ID when a redraw supplies changed fields. These reducer rules therefore remain idempotent across ordinary draws, F5 reconciliation, and map changes.
player.inspected is one atomic completion event:
PlayerInspected {
observation: EventObservation,
trigger: "appeared" | "manual" | "user",
player: WorldObject,
changes: ("info" | "equipment" | "legend")[],
}
The player contains the complete current profile. The first inspection lists
all three change domains. An identical refresh has an empty changes array,
which makes manual completion observable without several independently ordered
partial events. character.profile_changed similarly carries the previous
optional local identity and complete current identity from self-look.
Entity visual events
Visible players, monsters, and Mundanes can publish animation, visual effect, and damage feedback:
player.animated monster.animated mundane.animated
player.effect monster.effect mundane.effect
player.damaged monster.damaged mundane.damaged
Their JSON discriminators follow the same underscore form, such as
player_animated, monster_effect, or mundane_damaged.
EntityAnimated {
observation,
entity: WorldObject,
animation: u8,
initial_duration_ms: i32,
}
EntityEffect {
observation,
entity: WorldObject,
effect: u16,
source: WorldObject?,
frame_interval_ms: i16?,
}
EntityDamaged {
observation,
entity: WorldObject,
health_percent: u8,
}
health_percent is the server’s 0 through 100 value for the temporary health
meter. It is not the amount of damage dealt. Effects drawn only at ground
coordinates are not published yet.
Audio events
Audio packets provide useful automation cues even when they do not change visible state.
| SSE event | JSON type | Payload after observation |
|---|---|---|
sound.played | sound_played | effect: u8 |
music.started | music_started | track: u8 |
music.stopped | music_stopped | No additional fields |
The numeric values are the effect and music identifiers sent by the server. These transient events have no REST recovery route and are not replayed.
Message events
Read recent retained messages from GET /clients/{client}/messages. See
Messages for channel parsing, filtering, paging, retention, and
privacy.
| SSE event | Channel |
|---|---|
message.say | Nearby speech |
message.shout | Nearby shout |
message.chant | Spell chant or mock chant used for an NPC interaction |
message.whisper | Incoming or outgoing whisper |
message.guild | Guild chat |
message.group | Group chat |
message.system | Client or server system text |
message.world | World shout |
message.internal | Daemon-only inter-client payload |
All message routes use the JSON discriminator type: "message". The channel
inside the payload distinguishes them:
Message {
timestamp: string,
tick_ms: u32?,
channel: MessageChannel,
sender: string?,
recipient: string?,
text: string?,
payload: object?,
}
The SSE ID carries message stream ordering; it is not repeated in the JSON
message. The daemon stores a normalized message before broadcasting it, except
for message.chant, which is intentionally transient. Some system messages
also produce spell.succeeded, spell.failed, spell.received, or
item.pickup_failed. Both frames are intentional: one preserves the text shown
by the game and the other supplies semantic feedback data.
Internal messages contain payload and omit tick_ms and text. They are
published by POST /messages/send only to the selected recipient stream, or to
every connected client stream for a broadcast. They never enter the game or
DLL transport. See Messages.
NPC dialog events
Read the current page from GET /clients/{client}/dialog. The
NPC dialogs chapter documents the dialog model, revision checks,
response actions, and complete event payloads.
| SSE event | JSON type | Meaning |
|---|---|---|
dialog.opened | dialog_opened | A merchant or pursuit dialog became active. |
dialog.changed | dialog_changed | The server replaced the current page or response choices. |
dialog.submitted | dialog_submitted | A daRPC action answered or navigated the current page. |
dialog.closed | dialog_closed | The dialog ended locally, remotely, during a map change, or during recovery. |
Message-dialog events
Read the current set from GET /clients/{client}/message-dialogs. The
Message dialogs chapter documents capture bounds,
revision checks, and dismissal.
| SSE event | JSON type | Meaning |
|---|---|---|
message_dialogs.changed | message_dialogs_changed | The complete set of native message dialogs opened, changed, or closed. |
Look-result events
Typed Look and FarLook requests produce one transient result event. See Looking at tiles for request bodies, correlation, and popup suppression.
look.result
LookResult {
observation: EventObservation,
command_id: u32,
target: { kind: "ahead" | "tile", x: u16, y: u16 },
text: string,
}
The JSON discriminator is type: "look_result". The result is not retained in
the client snapshot, so subscribe before submitting the command. Ahead targets
contain the resolved tile computed from the DLL’s confirmed position and facing
when it submits the native Look packet.
Field-map events
Read current state from GET /clients/{client}/field-map. The
Field maps chapter documents pane detection, destinations,
revision checks, selection, and complete payload fields.
| SSE event | JSON type | Meaning |
|---|---|---|
field_map.opened | field_map_opened | A validated native field-map panel became active. |
field_map.changed | field_map_changed | The active field map and destination list were replaced. |
field_map.selection_submitted | field_map_selection_submitted | The client sent the retained destination’s canonical selection packet. |
field_map.closed | field_map_closed | The native panel was no longer registered and visible. |
Opened, changed, and selection-submitted events contain field_map with the
complete FieldMapState. Closed contains the complete state as previous.
There is no selection-started event: a native click can animate before sending,
and only the observed outgoing packet is authoritative for submission.
Bulletin events
Read current board, entry, mailbox, and composer state from
GET /clients/{client}/bulletin. The
Bulletin boards and player mail chapter documents view shapes,
paging, scrolling, composition, revision checks, and mutation outcomes.
| SSE event | JSON type | Meaning |
|---|---|---|
bulletin.opened | bulletin_opened | A supported native bulletin session became active. |
bulletin.changed | bulletin_changed | The active view, retained page, selection, viewport, draft, or navigation state changed. |
bulletin.submitted | bulletin_submitted | The server confirmed an article post, mail send, or highlight. |
bulletin.deleted | bulletin_deleted | The server confirmed entry deletion. |
bulletin.failed | bulletin_failed | The server rejected the named bulletin action. |
bulletin.closed | bulletin_closed | The native bulletin session closed. |
Opened and changed events contain the complete bulletin state. Mutation
events contain bulletin, action, raw_status, and message; the action on
bulletin.failed identifies the rejected operation. Closed contains the
complete prior state as previous.
Group events
Read current membership and invitations from GET /clients/{client}/group.
The Groups chapter explains invitation actions, the group-open
toggle, and server confirmation.
group.settings_changed
GroupSettingsChanged {
observation: EventObservation,
group: GroupState,
}
group.invitation_sent
GroupInvitationSent {
observation: EventObservation,
target: string,
}
group.invitation_received
GroupInvitationReceived {
observation: EventObservation,
invitation: GroupInvitation,
group: GroupState,
}
group.invitation_closed
GroupInvitationClosed {
observation: EventObservation,
invitation: GroupInvitation,
reason: GroupInvitationCloseReason,
group: GroupState,
}
group.joined
GroupJoined {
observation: EventObservation,
group: GroupState,
}
group.member_joined | group.member_left
GroupMemberChanged {
observation: EventObservation,
member: GroupMember,
group: GroupState,
}
group.disbanded
GroupDisbanded {
observation: EventObservation,
group: GroupState,
}
State-bearing events include the complete resulting group. Replace the
consumer’s retained value with that group instead of applying an inferred
partial change. group.invitation_sent only confirms local submission because
the game does not send a direct response when the other player declines.
Player exchange events
Read the current offer from GET /clients/{client}/exchange. The
Exchange chapter explains initiation, quantity handling,
one-time gold, acceptance, and cancellation.
exchange.opened
ExchangeOpened {
observation: EventObservation,
exchange: ExchangeState,
}
exchange.item_added
ExchangeItemAdded {
observation: EventObservation,
party: ExchangeParty,
item: ExchangeItem,
exchange: ExchangeState,
}
exchange.gold_changed
ExchangeGoldChanged {
observation: EventObservation,
party: ExchangeParty,
gold: u32,
exchange: ExchangeState,
}
exchange.accepted
ExchangeAccepted {
observation: EventObservation,
party: ExchangeParty,
message: string,
exchange: ExchangeState,
}
exchange.completed | exchange.cancelled
ExchangeFinished {
observation: EventObservation,
message: string,
exchange: ExchangeState,
}
party is local or other. Each event includes the complete offer state at
that point. Replace a consumer’s retained value with exchange instead of
trying to infer state from only the changed field.
Complete event index
| Domain | Events | REST recovery route |
|---|---|---|
| Stream | stream.ready, stream.resync_required, stream.closed | Reread every resource the consumer uses. |
| Client lifecycle | client.logged_in, client.disconnected | /status |
| Client requests | client.command, client.resync, client.resync_completed | None; transient events are not replayed. |
| Status | stats.changed, vitals.changed, progression.changed, gold.changed, weight.changed, modifiers.changed, location.changed, blind.changed, action_restriction.changed, character.appearance_changed, character.hidden_changed, character.profile_changed | /status |
| Map downloads | map.requested, map.downloaded | None; transient events are not replayed. |
| Walking | walking.started, walking.stopped, walking.obstructed, walking.route_changed, character.turned, character.emoted | /status |
| Inventory | item.added, item.removed, item.changed, item.used, item.dropped, item.given, item.picked_up, item.pickup_failed, gold.dropped, gold.given | /items, then /status for gold |
| Equipment | equipment.unequipped | /equipment |
| Skills | skill.added, skill.removed, skill.changed, skill.cooldown, skill.ready, skill.used | /skills |
| Spells | spell.added, spell.removed, spell.changed, spell.cooldown, spell.ready, spell.begin, spell.chant, spell.cast, spell.cancelled, spell.succeeded, spell.failed, spell.received | /spells, then /status for casting state |
| Effects | effect.added, effect.removed, effect.changed | /effects |
| World objects | player.appeared, player.replaced, player.inspected, player.disappeared, player.moved, player.direction_changed; the corresponding appeared, disappeared, moved, and direction events for monsters and Mundanes; item.appeared, item.disappeared, item.moved | /objects |
| World visuals | player.animated, player.effect, player.damaged, and the corresponding monster and Mundane events | None; transient events are not replayed. |
| Audio | sound.played, music.started, music.stopped | None; transient events are not replayed. |
| Messages | message.say, message.shout, message.chant, message.whisper, message.guild, message.group, message.system, message.world, message.internal | /messages, except transient chants |
| NPC dialogs | dialog.opened, dialog.changed, dialog.submitted, dialog.closed | /dialog |
| Message dialogs | message_dialogs.changed | /message-dialogs |
| Field maps | field_map.opened, field_map.changed, field_map.selection_submitted, field_map.closed | /field-map |
| Bulletins | bulletin.opened, bulletin.changed, bulletin.submitted, bulletin.deleted, bulletin.failed, bulletin.closed | /bulletin |
| Groups | group.settings_changed, group.invitation_sent, group.invitation_received, group.invitation_closed, group.joined, group.member_joined, group.member_left, group.disbanded | /group, then /status for convenience fields |
| Exchange | exchange.opened, exchange.item_added, exchange.gold_changed, exchange.accepted, exchange.completed, exchange.cancelled | /exchange, then /status for is_in_exchange |
| Legend | legend.mark_added, legend.mark_changed, legend.mark_removed | /legend |
The OpenAPI document at /openapi.json remains the exact machine-readable
schema for these payloads. This chapter is the human-readable reference.
Raw packets
daRPC exposes a low-level escape hatch for protocol research and testing:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"direction":"client","command":"7E","payload":"00 03 02"}' \
"http://127.0.0.1:2626/clients/ZiLo/raw/send"
Warning: Raw packets bypass daRPC’s normal game-specific validation. Incorrect commands, lengths, fields, or state assumptions can disconnect the session, corrupt client state, crash the game client, or trigger server-side failures. Use this interface only when the exact packet format and required client state are known.
The JSON request has three required string fields:
{
"direction": "client",
"command": "7E",
"payload": "00 03 02"
}
| Field | Format |
|---|---|
direction | client sends a client packet to the connected game server. server dispatches a synthetic server packet inside the game client. |
command | Exactly two hexadecimal digits, optionally prefixed by 0x. This becomes the first byte of the packet body. |
payload | Zero or more two-digit hexadecimal bytes separated by ASCII whitespace. The maximum is 255 bytes. Use an empty string for no payload. |
The endpoint joins command and payload; it does not accept encrypted wire
frames, transport headers, lengths, or checksums. For the example above, the
native body is 7E 00 03 02.
API examples
Send a custom client packet to the game server:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"direction":"client","command":"7E","payload":"00 03 02"}' \
"http://127.0.0.1:2626/clients/ZiLo/raw/send"
Dispatch a custom server packet to the client:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"direction":"server","command":"3A","payload":""}' \
"http://127.0.0.1:2626/clients/ZiLo/raw/send"
Like other native actions, a raw send returns a command status. The action is
queued and executed on the client’s main thread. A 200 OK response means it
reached a terminal command state; 202 Accepted means the returned command ID
can be polled through the normal command-status route.
Direct CLI
The direct client provides the same operation for one injected process:
darpc.exe raw send --pid 3780 client 7E "00 03 02"
darpc.exe raw send --pid 3780 server 3A
darpc.exe --output json raw send --pid 3780 client 7E "00 03 02"
Quote a nonempty payload so it is passed as one argument. Omitting the payload means zero payload bytes.
Direction behavior
client calls the supported client’s normal plaintext packet-submission
function. The body enters the same outbound observation hook as native client
actions before the game applies its transport framing and encryption.
server creates the minimal decoded server-event shape expected by the
supported client and calls its central event dispatcher. It does not contact
the game server. The synthetic event enters daRPC’s normal server-event hook,
so any recognized state update is observed just like a received packet.
daRPC validates only the hexadecimal syntax, one-byte command, payload bound, supported client build, and command queue. It intentionally cannot validate the semantics of an arbitrary packet. Non-loopback API access makes this surface especially sensitive because the HTTP API has no authentication or Transport Layer Security (TLS).
Executable components
The Windows release contains three executable programs. Choose the narrowest one that fits the task:
| Program | Architecture | Purpose |
|---|---|---|
loader.exe | 32-bit x86 | Inspect, attach to, detach from, or launch a supported game client. |
darpc.exe | 64-bit x86-64 | Run one command against one attached client without starting the web service. |
darpcd.exe | 64-bit x86-64 | Discover and aggregate clients, then expose the REST API, Server-Sent Events, OpenAPI document, and Swagger UI. |
darpc.exe and darpcd.exe are alternative controllers for the same client.
Only one controller can own a client’s named-pipe connection at a time. Use
darpc.exe for scripts and terminal workflows. Use darpcd.exe for long-lived
API clients, multiple game clients, event subscriptions, and browser-based API
exploration.
The fourth runtime component, darpc.dll, is injected into the 32-bit client
and is not run from a command prompt. See the darpc.dll architecture
chapter for its responsibilities and safety boundaries.
The following chapters provide the complete command-line syntax, flags, examples, output formats, and operational notes for every executable.
loader.exe
Use the loader when you want direct control over launching a game client,
attaching daRPC to an existing client, or unloading it cleanly. If the daemon
should manage clients for you, start with darpcd.exe instead.
loader.exe is the 32-bit x86 entry point for starting or attaching daRPC. It
owns two implemented workflows:
- Attach, inspect, and detach
darpc.dllin an already-running compatible client. - Launch a compatible client and arrange for
darpc.dllto be loaded before its primary thread resumes.
Late injection is what allows daRPC to attach without requiring the game session to have started behind a proxy.
Validation
A matching window or process name only identifies a candidate. Before
injection, the loader must validate the target architecture, executable
identity, supported version, and whether darpc.dll is already loaded or still
initializing.
Injection should fail closed when compatibility cannot be established. A failed attach must not terminate an existing client. A failed launch terminates only the child created by that loader invocation before it can run normally. Repeated requests should be safe and must not load duplicate copies of the library. Before calling a lifecycle export in an existing process by its validated relative virtual address, the observed loaded-module path must match the selected DLL.
The supported client is Dark Ages 7.41 with a 3,112,960-byte executable and
SHA-256 fingerprint
054A5D6ADC56099C6BFD9D2A58675AFF62DC788B63209A3D906492F5B89E96C6.
Both attach and launch canonicalize and fingerprint the executable before any
remote operation or child creation. Any other executable fails with
unsupported_client.
Before child creation, launch converts canonical verbatim drive and Universal Naming Convention (UNC) paths back to conventional Win32 form for the application name, command line, and working directory. Validation continues to use the canonical path, while the legacy client and its audio middleware see the path form produced by an ordinary Windows launch.
The caller supplies the intended DLL path explicitly. The loader canonicalizes
that path, requires the file name darpc.dll, validates its x86 Portable
Executable headers, and resolves the required lifecycle exports. It does not
search the current directory or select among multiple DLLs implicitly.
Repository-owned integration targets can opt into an unsupported-client bypass
with DARPC_LOADER_TEST_ALLOW_UNSUPPORTED_CLIENT=1. This escape hatch exists
only in debug builds. Release builds ignore it and always require the exact
supported fingerprint.
Command-line reference
The command surface is:
loader [--json] inspect <pid>
loader [--json] attach [--diagnostics hook-timing] <pid> <dll-path>
loader [--json] detach <pid> <dll-path>
loader [--json] launch [--allow-multiple] [--diagnostics hook-timing] [--server <host[:port]>] \
[--show-items-with-alt] [--skip-intro] [--skip-notice] [--skip-exchange-alerts] \
<executable-path> <dll-path> [-- <argument>...]
Arguments after the -- separator are forwarded to the launched executable.
The executable path is also supplied explicitly as its argv[0] in
conventional Win32 form.
| Command | Purpose |
|---|---|
inspect | Validate a running process and report whether the supported DLL is loaded. |
attach | Validate a running process, inject the DLL, and confirm the loaded module. |
detach | Ask the DLL to unload cleanly and confirm that the module is gone. |
launch | Create a supported client in a suspended state, apply the default bootstrap fix and selected startup options, inject the DLL, then resume it. |
--json is a global output flag and must precede the command. It writes one
machine-readable result to standard output while keeping diagnostics on
standard error.
loader.exe inspect 3780
loader.exe --json attach 3780 .\darpc.dll
loader.exe detach 3780 .\darpc.dll
The six optional launch patches are independent, may be combined, and are
disabled by default. Every supported-client launch also applies a mandatory
bootstrap sequence patch before the child resumes. It resets the outgoing
encrypted-packet sequence in the communications worker immediately before
CHello is encrypted, then removes the original producer-side late reset. This
keeps CHello at sequence zero and the later CMulti at sequence one during
both initial startup and a return from a game server to the main login server.
All startup patches apply only to a new suspended child. attach never
modifies client startup behavior. The loader validates the exact 7.41
executable fingerprint, both packet-encryption calls, and the original late
reset call. It writes and verifies an executable 21-byte bridge, restores
code-page protections, and flushes the instruction cache before resuming. Any
mismatch or incomplete write terminates the still-suspended child rather than
starting it partially patched.
| Launch option | Behavior |
|---|---|
--allow-multiple | Bypasses the local Nexon.SingleInstance result check. |
--server <host[:port]> | Resolves the host to IPv4, enables the client’s positional endpoint parser, and disables fallback to the official endpoint. The default port is 2610. |
--show-items-with-alt | Reveals up to 255 ground items as translucent hints while either Alt key is held, including items hidden behind static map art. |
--skip-intro | Enters the client’s normal post-video state directly. |
--skip-notice | Hides both notice-window paths, enables early title-menu pointer input, and removes the fixed one-second transfer delay while preserving normal notice and transfer processing. |
--skip-exchange-alerts | Replaces the one-button alert shown after a player exchange completes or is cancelled with the same text in the floating game-message bar. Exchange state and item or gold transfers are unchanged. |
--diagnostics hook-timing enables runtime hook timing before the DLL installs
its hooks. It is accepted by both attach and launch. The same mode can be
enabled or disabled later over IPC, so reinjection is not required for routine
diagnosis. Omitting the option keeps timing disabled.
Standard launch profile
The standard project profile passes the five general launch options explicitly:
loader.exe launch --allow-multiple --server <host[:port]> \
--skip-intro --skip-notice --skip-exchange-alerts \
<executable-path> <dll-path>
Add --show-items-with-alt when the optional Alt reveal behavior is
desired.
Use --server 127.0.0.1:2610 when intentionally routing through
Arbiter, a Dark Ages network analyzer and
local proxy. Arbiter must be configured to listen there and forward the
connection; strict endpoint selection does not fall back when the loopback
connection fails. Keeping the options explicit allows individual behaviors to
be omitted during diagnosis without changing the loader’s unflagged behavior.
For --server, human diagnostics show the resolved IPv4 address and port. If
no additional client arguments were forwarded, they also show the exact game
command line. When additional arguments exist, the diagnostic omits them to
avoid recording potentially sensitive values.
Human mode writes progress diagnostics to standard error and one final result
to standard output. --json keeps the same diagnostics on standard error and
writes exactly one JSON result to standard output.
A successful JSON result always contains ok, command, pid,
creation_time, changed, darpc_loaded, and module_base.
creation_time is a decimal string so its 64-bit Windows FILETIME value
remains exact in JavaScript consumers. module_base is an x86 address number
or null.
An error result contains ok, command, pid, and an error object with
stable kind and message fields. command is null when argument parsing
could not identify a command. pid identifies a launch-owned child when
failure occurred after process creation and is otherwise null. The process
exit codes are:
| Exit | Error kind |
|---|---|
| 2 | invalid_arguments |
| 3 | unsupported_platform |
| 4 | invalid_dll |
| 5 | process_missing |
| 6 | process_exited |
| 7 | access_denied |
| 8 | wrong_architecture |
| 9 | already_loaded |
| 10 | timeout |
| 11 | initialization_failed |
| 12 | shutdown_failed |
| 13 | remote_operation_failed |
| 14 | internal |
| 15 | launch_failed |
| 16 | unsupported_client |
Implementation boundaries
The loader keeps its current responsibilities in small, domain-specific modules:
pe.rsvalidates the selecteddarpc.dllfile and loaded module image and produces their Portable Executable identity and required lifecycle export relative virtual addresses (RVAs).process.rsowns target process handles, architecture inspection, process identity, executable-path discovery, and loaded-module discovery.darpc-game-clientowns the exact supported executable fingerprint, canonical-path validation, and version-specific launch patch contracts.launch.rsowns suspended process creation, Windows argument quoting, primary-thread resumption, and child-only failure cleanup.patch.rsselects requested launch patches, finds the suspended main image, validates original bytes, and coordinates protected writes.remote.rscontains low-level remote allocation, memory reading and writing, and remote thread execution, including the bounded wait.dll.rsowns the Windows details for loading and unloading a DLL, including forwardedLoadLibraryWandFreeLibraryexport resolution.lifecycle.rscoordinates the daRPC lifecycle and decides when rollback is safe.
These are internal loader boundaries, not a general-purpose injection or Portable Executable framework.
Attach lifecycle
The implemented attach path:
- Validates the selected DLL and its required lifecycle exports.
- Opens and validates the target process as 32-bit x86.
- Resolves and validates the target’s exact Dark Ages 7.41 executable.
- Refuses to load a duplicate
darpc.dll. - Loads the DLL and verifies its module base through target module enumeration.
- Calls
darpc_initializewith the supported ABI version. - Unloads the DLL when initialization completes with an ordinary non-success status and the DLL reports that unloading is safe.
- Re-inspects the target module list before reporting success.
If completion of the initialization thread is uncertain, the loader leaves the
DLL loaded rather than risk unloading code that may still be executing. The DLL
also has a distinct UNLOAD_UNSAFE lifecycle status for a hook commit whose
rollback or thread resumption could not be proven safe. Late attach reports the
failure but deliberately skips FreeLibrary for that status. A suspended child
that returns the same status is terminated by the launch owner.
Detach lifecycle
The implemented detach path:
- Validates the selected DLL and opens the x86 target.
- Finds the loaded
darpc.dllthrough target module enumeration and verifies that its path matches the selected DLL. - Reads the loaded module’s bounded Portable Executable headers and export table, then requires its timestamp, image size, and lifecycle export RVAs to match the selected DLL exactly.
- Calls
darpc_shutdown(0)only after that identity check succeeds. - Calls
FreeLibraryonly after shutdown returns success. - Re-inspects the target and reports success only when
darpc.dllis absent.
If the DLL file is replaced while an older build remains mapped in the client, detach fails before creating a remote thread. Restore the matching file at the same path to unload that build safely, or restart the client.
A shutdown failure or uncertain completion leaves the DLL loaded. Every remote thread wait is bounded to 10 seconds. A timeout is reported separately and the loader avoids cleanup that could free memory or code still in use by the target.
Command repetition is deliberate:
inspectis read-only and reportschanged=false.- A repeated
attachfails withalready_loadedand does not create another DLL instance. - A repeated
detachsucceeds withchanged=falsewhen the DLL is already absent.
Launch lifecycle
The implemented launch path:
- Validates the selected DLL before creating a process.
- Resolves and validates the exact Dark Ages 7.41 executable before creating a process.
- Uses the executable parent directory as the child working directory.
- Creates the child with
CREATE_SUSPENDED, general handle inheritance disabled, and no copied standard handles. - Leaves the child processor affinity inherited from the launcher and permits the client to manage it during startup.
- Validates the child as x86 and records its creation time without requiring module enumeration before Windows user-mode loader startup.
- Resolves a selected server to dotted IPv4 and prepends the address and explicit port to the child arguments before process creation.
- Reads the loaded main-module base from the child process environment block and validates every original instruction for the default runtime patch and any selected launch patches before writing anything.
- Applies complete instructions with temporary writable protection, flushes the instruction cache, restores protection, and reads back each result.
- Loads
darpc.dlland callsdarpc_initializewhile the primary thread remains suspended. - Resumes the primary thread only after patching and initialization succeeds.
- Terminates and waits for only that owned child if any launch operation fails.
The loader and launched child are the same architecture and run in the same
Windows session. The suspended-load path therefore uses the shared x86
kernel32.dll LoadLibraryW address before target module enumeration becomes
available. Native Windows tests exercise this boundary. Once the process has
started normally, later inspection and detach operations use target module
enumeration as usual.
Windows quoting doubles backslashes where required around embedded quotes and at quoted argument boundaries. Every argument is quoted independently, so spaces, empty values, quotes, trailing backslashes, and Unicode are preserved.
The exact 7.41 contracts follow the documented translucent-walk-refresh, multiple-client, command-line-endpoint, disable-endpoint-fallback, skip-intro, hide-notice, early-continue, and fast-server-transfer targets. The translucent-walk commit is always applied to a validated 7.41 client before launch; it routes a preserved translucent refresh through the full appearance update so the walk finishes, the accepted destination commits, and the object-owned translucency state updates together. The early-continue patch enables the existing pointer hit-testing path while the initial menu gate is set; keyboard input remains unchanged. Fast server transfer changes the fixed post-connect sleep from one second to a yield; the actual blocking connection can still pause the animation. The executable is never modified on disk. A byte mismatch or failed write leaves the primary thread suspended and enters owned-child cleanup.
Verification
The Windows integration test builds a real x86 loader, DLL, and inert target, then exercises attach, detach, suspended launch, and required failure classifications. A small controllable fixture DLL is used only for initialization failure, shutdown failure, and timeout cases.
From a Windows PowerShell shell:
cargo build `
-p loader -p rpc-dll -p injection-target -p loader-fixture-dll `
--target i686-pc-windows-msvc
./tools/injection-target/test-loader.ps1 `
-TargetDir ./target/i686-pc-windows-msvc/debug
The launch checks confirm that initialization was logged before the target
entered main, arguments and the executable working directory were preserved,
handles were not inherited, a normal process can exit, and a failed initialization leaves no
suspended child. The same sequence runs in the Windows workflow.
The live-client checks are intentionally local and require a legally obtained Dark Ages 7.41 installation. They never copy the executable, enter credentials, or record game data. Build the x86 artifacts, close every running client, and run:
./tools/test-game-client.ps1 `
-ClientPath "C:\path\to\Darkages.exe" `
-TargetDir ./target/i686-pc-windows-msvc/debug
This script proves unsupported-client rejection, two independent controlled target processes, live late attach, live suspended launch, duplicate detection, lifecycle logging, unload, and client liveness after unload. It force-stops only the client processes it starts, so it is not evidence of normal interactive exit behavior.
When orchestrating a Parallels guest from macOS, use direct current-user guest execution for live launches as well as builds, controlled targets, inspection, and attach. A scheduled task or other launch intermediary is unnecessary:
prlctl exec "<vm-name>" --current-user powershell.exe -NoProfile -Command \
"& '<loader-path>' launch --allow-multiple --skip-intro --skip-notice '<client-path>' '<dll-path>'"
Add --server '<host[:port]>' when endpoint selection is part of the check. A
loopback endpoint such as 127.0.0.1:2610 can route through Arbiter when its
guest-local proxy is already listening and forwarding. Automated checks may
launch the client and inspect non-sensitive process state, but must not enter
credentials, record private game data, or force-terminate a client they do not
clearly own.
Complete the interactive portion of behavioral acceptance privately:
- Close every running client. Start the client directly, log in, move, open and close representative user interface panels, and exit normally.
- With no client running, use
loader launch <client-path> <dll-path>. Repeat the same actions and exit normally with the inert DLL still loaded. - With no client running, start the client directly, use
loader attach <pid> <dll-path>, repeat the same actions, and exit normally with the inert DLL still loaded. - Record whether all three runs behaved the same. Do not put credentials, private chat, or packet data in the record.
Verify optional launch patches with automated current-user launches where
practical. Exercise each option independently, then launch two clients
concurrently with --allow-multiple --skip-intro --skip-notice and, when needed,
--skip-exchange-alerts, --show-items-with-alt, or
--server <host[:port]>. Confirm that the intro and
notice are absent, both clients reach normal login, terminal exchange alerts
are absent only when requested, holding Alt reveals ground items only when
requested, the selected endpoint is used, and ordinary login and exit behavior
remain intact. An unflagged launch remains the
comparison case. An explicit server is strict: if that connection fails, the
client follows its normal disconnected cleanup and does not retry the compiled
official endpoint.
For the mandatory bootstrap patch, also enter a game server, use the client’s
normal exit-to-login action, and confirm that the main login server remains
connected through the next CHello and CMulti exchange.
The loader-owned startup patches run between suspended process validation and
DLL initialization. The bootstrap sequence patch is always included for a
validated 7.41 launch; the flags above control only their named optional
patches. Hooks and trampolines owned by daRPC remain the responsibility of
darpc_initialize and darpc_shutdown.
darpc.exe command-line interface
The direct CLI talks to one injected DLL without going through the daemon. Use
it for diagnostics, simple one-client scripts, or protocol inspection. Use
darpcd.exe when you need discovery, several clients, REST, or live
event streams.
Status: The direct commands documented below are implemented.
darpc.exe is a direct, single-client command-line interface to an injected
darpc.dll. It connects to the process-specific named pipe, exchanges typed
binary protocol messages, and presents responses as human-readable text or
stable JSON.
The CLI does not call the darpcd.exe HTTP API, inject DLLs, or invoke
loader.exe. This keeps a useful standalone path for developers and automation
that need only loader.exe, darpc.dll, and darpc.exe.
The command-line boundaries are:
| Tool | Responsibility |
|---|---|
loader.exe | Launch, inspect, attach, detach, and apply supported launch patches. |
darpc.exe | Exchange typed protocol messages directly with one injected DLL. |
darpcd.exe | Maintain multiple client connections and expose aggregate state through web APIs. |
Command-line reference
Every direct command accepts the process selector --pid <pid>. Put the
optional global output selector before the command:
darpc.exe [--output <table|json>] <command> [arguments]
| Flag | Meaning |
|---|---|
--output table | Write the default human-readable output. |
--output json | Write one stable JSON value to standard output for scripts. |
--pid <pid> | Connect to the daRPC pipe owned by this game process. This command-level flag is required. |
--input <text> | Supply text to a spell prompt. |
--target-id <id> | Cast a spell at the specified numeric object identifier. |
--target <x> <y> | Cast a spell at the specified map coordinates. |
For spell casting, --target-id and --target are mutually exclusive. Omit
both for a spell that does not require an explicit target. Item names supplied
to sell, deposit, withdraw, and repair commands are case-sensitive and must
preserve their punctuation and spacing exactly.
Direct IPC commands
The implemented operations prove communication, expose hook health, read a current client snapshot, and submit movement through the client:
darpc hello --pid <pid>
darpc ping --pid <pid>
darpc echo --pid <pid> "hello"
darpc tick health --pid <pid>
darpc snapshot --pid <pid>
darpc diagnostic --pid <pid>
darpc diagnostics hooks --pid <pid>
darpc diagnostics enable --pid <pid>
darpc diagnostics disable --pid <pid>
darpc diagnostics reset --pid <pid>
darpc raw send --pid <pid> <client|server> <NN|0xNN> [hex-payload]
darpc assail --pid <pid>
darpc stat <strength|dexterity|intelligence|wisdom|constitution> --pid <pid>
darpc turn --pid <pid> <north|east|south|west>
darpc walk --pid <pid> <north|east|south|west>
darpc walk --pid <pid> <x> <y>
darpc walk --pid <pid> cancel
darpc skill use --pid <pid> <slot>
darpc skill swap --pid <pid> <source> <destination>
darpc spell cast --pid <pid> <slot>
darpc spell cast --pid <pid> <slot> --target-id <object-id>
darpc spell cast --pid <pid> <slot> --target <x> <y>
darpc spell cast --pid <pid> <slot> --input <text>
darpc spell swap --pid <pid> <source> <destination>
darpc item use --pid <pid> <slot>
darpc item drop --pid <pid> <slot> <x> <y> [quantity]
darpc item give --pid <pid> <slot> <object-id> [quantity]
darpc item swap --pid <pid> <source> <destination>
darpc gold drop --pid <pid> <amount> <x> <y>
darpc gold give --pid <pid> <amount> <object-id>
darpc item pickup --pid <pid> <x> <y>
darpc unequip --pid <pid> <slot-number>
darpc emote --pid <pid> <name|code>
darpc chant --pid <pid> <text>
darpc item sell --pid <pid> <item-name>
darpc item sell-all --pid <pid> <item-name>
darpc item deposit --pid <pid> <item-name>
darpc item withdraw --pid <pid> <item-name>
darpc item repair --pid <pid> <item-name>
darpc item repair-all --pid <pid>
darpc interact --pid <pid> <object-id>
darpc dialog select --pid <pid> <revision> <index> [quantity]
darpc dialog input --pid <pid> <revision> <text>
darpc dialog previous --pid <pid> <revision>
darpc dialog next --pid <pid> <revision>
darpc dialog close --pid <pid> <revision>
darpc field-map select --pid <pid> <revision> <destination-index>
darpc bulletin open --pid <pid>
darpc bulletin world --pid <pid> <x> <y>
darpc bulletin open-section --pid <pid> <revision> <section-id>
darpc bulletin select-section --pid <pid> <revision> <section-id>
darpc bulletin open-entry --pid <pid> <revision> <entry-id>
darpc bulletin select-entry --pid <pid> <revision> <entry-id>
darpc bulletin older --pid <pid> <revision>
darpc bulletin scroll --pid <pid> <revision> <position>
darpc bulletin back --pid <pid> <revision>
darpc bulletin forward --pid <pid> <revision>
darpc bulletin previous --pid <pid> <revision>
darpc bulletin next --pid <pid> <revision>
darpc bulletin compose-post --pid <pid> <revision>
darpc bulletin compose-mail --pid <pid> <revision>
darpc bulletin reply --pid <pid> <revision>
darpc bulletin update-post --pid <pid> <revision> <subject> <body>
darpc bulletin update-mail --pid <pid> <revision> <recipient> <subject> <body>
darpc bulletin submit --pid <pid> <revision>
darpc bulletin delete --pid <pid> <revision> <entry-id>
darpc bulletin highlight --pid <pid> <revision> <entry-id>
darpc bulletin close --pid <pid> <revision>
darpc message-dialog dismiss --pid <pid> <revision> <id>
darpc group toggle --pid <pid>
darpc group invite --pid <pid> <player>
darpc group accept --pid <pid> <invitation-id>
darpc group decline --pid <pid> <invitation-id>
darpc exchange item --pid <pid> <slot> [quantity]
darpc exchange gold --pid <pid> <amount>
darpc exchange accept --pid <pid>
darpc exchange cancel --pid <pid>
darpc who --pid <pid>
darpc legend --pid <pid>
darpc inspect --pid <pid> <object-id>
darpc command status --pid <pid> <command-id>
darpc command cancel --pid <pid> <command-id>
For raw packets, quote a nonempty space-separated payload, for example darpc raw send --pid 3780 client 7E "00 03 02". The command byte accepts two
hexadecimal digits with an optional 0x prefix. The payload accepts at most 255
payload bytes. See Raw packets before using this low-level interface;
malformed packets can disconnect sessions or crash the game client or server.
chant sends its text through the client’s spell-chant channel. The item
convenience commands build the NPC phrases documented in Inventory.
Item names are case-sensitive and are preserved verbatim, including punctuation,
repeated spaces, and leading or trailing spaces. Quote names at the shell so the
entire name reaches darpc.exe as one argument.
Related operations use a domain and subcommand. Examples include skill use,
item swap, and dialog select. These commands use the real PID-based named
pipe, binary framing, protocol negotiation, request correlation, sequencing,
and connection lifecycle. Their behavior is:
helloreports compatible DLL and process metadata.pingverifies a complete request and response round trip and reports its elapsed time.echoreturns its UTF-8 payload byte-for-byte, with a 4 KiB input limit.tick healthsamples the client tick counter twice, 250 milliseconds apart, and reports installation metadata, both counter values, their wrapping difference, and whether the counter advanced.snapshotschedules a bounded capture on the client main thread and reports lifecycle, character, map, inventory, equipment, spellbook, skillbook, active spell-effect, dialog, field-map, bulletin, group roster, invitation, and complete native planned-route state plus event, capture timing, and request round-trip metadata.diagnosticsubmits a no-op command to the bounded main-thread queue, waits up to one second, and reports its state, queue delay, execution duration, and client main-thread ID.diagnostics hooksqueries runtime hook timing.diagnostics enableanddiagnostics disablechange the mode without reinjection.diagnostics resetclears counters without changing the current mode. Each stage reports its budget, calls, total, average, maximum, last duration, and over-budget count in microseconds.assailsubmits the client’s native0x13basic-attack packet. The resulting client observations can emitplayer.animatedandsound.playedevents.statspends one available stat point by sending native packet0x47with the selected strength, dexterity, intelligence, wisdom, or constitution flag. The corresponding short aliases arestr,dex,int,wis, andcon.turncancels any queued native route and asks the client to face one of the four cardinal directions.walkwith a direction cancels any queued route and attempts one native, collision-checked step.walkwith x/y asks the client’s native pathfinder to follow a route to that zero-based map tile.skill useinvokes a learned one-based skill slot through the client’s native activation routine. It does not select the skill panel, change focus, or synthesize keyboard or mouse input.skill swapexchanges two one-based skillbook slots.spell castinvokes a learned one-based spell slot through the matching native client routine. Its optional argument is one visible object ID, one zero-based map tile, or 1 through 100 ASCII bytes. The DLL checks that the selected spell expects that argument shape. A targeted spell defaults to the casting character when no target is supplied. A new cast may replace a delayed cast already in progress.spell swapexchanges two one-based spellbook slots.item useactivates a live one-based inventory slot through the client’s ordinary item path.item dropanditem givesubmit a validated quantity from a live slot. Quantity defaults to 1. Giving begins the game’s ordinary exchange flow.item swapexchanges two one-based inventory slots.gold dropandgold givesubmit a nonzero amount to a tile or object ID.item pickupasks the server for the top ground item at a zero-based tile and uses the first empty inventory slot available at execution time.unequipaccepts the client’s one-based equipment slot number from 1 through 18.emoteaccepts a confirmed case-insensitive name such aswave, or a normal client UI emote code. See Emotes for the named list.interactstarts a conversation with one visible Mundane object ID.dialog selectsubmits a zero-based displayed row and optional nonzero quantity.dialog inputsubmits nonempty ASCII text. Dialog selection, input, navigation, and close commands require the current dialog revision so stale actions fail closed in the DLL.field-map selectsubmits one zero-based destination from the active field map. It requires the current field-map revision and uses the retained checksum and travel coordinates. See Field maps.bulletincommands open global or world-tile boards; select, open, page, and scroll lists and entries; navigate native dialog history; compose board articles or player mail; and submit deletion or highlight requests. Every command exceptopenandworldrequires the revision reported by the active bulletin state. See Bulletin boards and player mail.message-dialog dismisscloses one active native message dialog by the revision and opaque ID returned by current state. See Message dialogs.group toggleuses the native client toggle. It opens or closes invitations while solo and leaves or disbands an active group.group invitesends a validated ASCII player name.group acceptandgroup declineanswer one retained invitation ID. Directsnapshotexposes retained group state, but the daemon API adds visible-name resolution, REST resources, and live events.exchange itemadds a live inventory slot to an already open player exchange. Quantity defaults to 1 and is limited to 255.exchange goldsets one nonzero amount no greater than the current character gold.exchange acceptandexchange cancelwait for the server to finish or close the ordinary exchange window.whorequests the server-ordered online-player list, waits up to three seconds, and suppresses only its own client panel. Requests within one second share an in-flight or recently completed result.command statusreads a retained command result by its nonzero ID.command cancelatomically cancels a command that is still accepted. A command that already started retains its completed state.legendrequests a fresh SelfLook from the server and prints every legend mark with its text, tag, color, and friendly icon name. Requests share the same one-second coalescing window as the REST endpoint.inspectrefreshes one visible player’s profile by object ID, waits up to three seconds, and suppresses only its correlated other-player information pane. It returns identity, group-open state, equipment, and legend metadata.
The commands share darpc-protocol with the DLL and daemon. Each requires an
explicit nonzero process ID and cannot manage multiple clients in one command.
Output
Human-readable output is the default. Put --output json before the command to
emit one stable JSON value on standard output:
darpc --output json hello --pid <pid>
darpc --output json ping --pid <pid>
darpc --output json echo --pid <pid> "hello"
darpc --output json tick health --pid <pid>
darpc --output json snapshot --pid <pid>
darpc --output json diagnostic --pid <pid>
darpc --output json turn --pid <pid> north
darpc --output json walk --pid <pid> 120 85
darpc --output json skill use --pid <pid> 5
darpc --output json skill swap --pid <pid> 5 6
darpc --output json spell cast --pid <pid> 7 --input "nothing"
darpc --output json item swap --pid <pid> 1 2
darpc --output json dialog select --pid <pid> 7 0
darpc --output json field-map select --pid <pid> 11 1
darpc --output json bulletin open-entry --pid <pid> 15 4280
darpc --output json group invite --pid <pid> ZiLo
darpc --output json who --pid <pid>
darpc --output json legend --pid <pid>
darpc --output json inspect --pid <pid> <object-id>
darpc --output json command status --pid <pid> <command-id>
Diagnostics belong on standard error so scripts can parse JSON from standard output without filtering it. Exit codes distinguish invalid input, missing or busy endpoints, protocol incompatibility, malformed responses, and other I/O failures.
Connection ownership
The DLL pipe currently accepts one controller at a time. darpc.exe and
darpcd.exe are alternative consumers of that pipe, not layers in the same
request path. A direct CLI command reports the endpoint as busy when the daemon
owns the connection. It does not fall back to the daemon or disconnect it.
Future commands
New CLI commands should be added only when the DLL exposes the matching typed protocol operation. Each command should:
- Target exactly one explicit PID.
- Validate arguments before opening the pipe.
- Use typed protocol messages rather than arbitrary byte or command strings.
- Preserve equivalent human-readable and stable JSON representations.
- Remain usable without
darpcd.exe.
Additional game-state reads and actions can extend the existing ipc hierarchy
as their protocol messages become real. The CLI should not grow daemon discovery,
aggregation, web configuration, or multi-client policy.
Daemon access
Consumers that need aggregated multi-client state use the darpcd.exe HTTP
API directly. The daemon publishes an OpenAPI document at /openapi.json and
an interactive Swagger UI at /docs, so another command-line HTTP wrapper is
not part of the planned architecture.
darpcd.exe
The daemon is the normal starting point for dashboards, scripts, and tools that work with one or more game clients. This chapter covers running and configuring it. Use Web API for HTTP routes and Live events for the streaming interface.
Status: Automatic client discovery, the identity registry, daemon-managed load, unload, and launch, current client state, routed movement commands, REST, and Server-Sent Events are implemented.
darpcd.exe is a 64-bit x86-64 Windows daemon that makes injected clients easy
to use from local applications.
Its current responsibilities are to:
- Discover supported game client windows and their deterministic daRPC pipes.
- Track uninjected processes as loader candidates.
- Connect and reconnect to available
darpc.dllinstances. - Invoke the configured
loader.exefor explicit lifecycle operations. - Optionally load the configured DLL once into each uninjected client.
- Aggregate client identity, connection health, snapshots, and ordered state updates from each connected client.
- Route bounded commands through each client’s existing pipe session.
- Expose default-loopback REST and Server-Sent Events APIs, an OpenAPI document, and Swagger UI, with an explicit IPv4 bind for trusted networks.
Additional game actions can build on this boundary later. REST provides bounded requests and responses for those actions, while Server-Sent Events provide the live update stream. The daemon retains observations but is not the authority for client memory or local state.
Command-line reference
darpcd.exe [--pid <pid> ...] [--port <port> | --listen <ipv4[:port]>]
[--auto-load] [--managed]
[--loader-path <path>] [--dll-path <path>] [--maps-path <path>]
darpcd.exe --print-openapi
| Flag | Meaning |
|---|---|
--pid <pid> | Retain a specific process as a controlled target. Repeat the flag for multiple clients. Normal window discovery remains active. |
--port <port> | Listen on this TCP port instead of 2626. The listener remains bound to 127.0.0.1. |
--listen <ipv4[:port]> | Bind to an explicit IPv4 interface and optional port. An omitted port defaults to 2626. This flag cannot be combined with --port. |
--auto-load | Use the configured loader and DLL once for each discovered, supported client that is not already loaded. |
--managed | Treat standard input as a parent-owned lifetime pipe and shut down normally when it reaches end-of-file. |
--loader-path <path> | Use this loader.exe for managed load, unload, launch, and automatic loading. The default is loader.exe beside the daemon. |
--dll-path <path> | Use this darpc.dll for managed load, launch, and automatic loading. The default is darpc.dll beside the daemon. |
--maps-path <path> | Override the automatically discovered local client Maps directory used by GET /maps/{map_id}/download. The path must name an existing directory. |
--print-openapi | Print the OpenAPI 3.1 document as JSON and exit. This standalone flag cannot be combined with server flags. |
Paths containing spaces must be quoted. Repeating a single-value flag or using an unknown flag is an error. The daemon reports startup failures on standard error and exits nonzero.
Start normal discovery and serve the API on the default loopback address:
darpcd.exe
Allow a host or another virtual machine on a trusted network to reach the API:
darpcd.exe --listen 0.0.0.0:2626
0.0.0.0 binds every IPv4 interface. Prefer the VM’s specific IPv4 address
when practical, and restrict the port with Windows Firewall. The API has no
authentication or Transport Layer Security (TLS), so every host that can reach
the listener can read state and submit actions.
Automatically load uninjected supported clients and select explicit runtime files:
darpcd.exe --auto-load --loader-path "C:\daRPC\loader.exe" --dll-path "C:\daRPC\darpc.dll"
Run the daemon as a child owned by another program:
darpcd.exe --managed --auto-load
The parent must start the child with piped standard input and retain the sole
write end. The daemon ignores bytes written to the pipe; they are not commands.
Closing the write end, including when the parent exits unexpectedly, produces
end-of-file and requests a normal daemon shutdown. The daemon stops discovery
and new HTTP connections, closes active Server-Sent Events streams, gives
other in-flight HTTP requests a bounded drain period, and exits successfully.
It does not stop Darkages.exe, unload darpc.dll, or detach clients.
Without --managed, the daemon never reads standard input. --managed cannot
be combined with --print-openapi, and there is no HTTP shutdown route. A
parent that launches additional child processes must ensure they do not inherit
another copy of the lifetime pipe’s write end, because any remaining writer
delays end-of-file.
Override the local client’s automatically discovered map directory:
darpcd.exe --maps-path "C:\Dark Ages\Maps"
Without the flag, the daemon adopts the Maps directory beside the first
discovered Darkages.exe, including a client discovered after daemon startup.
Until a client or override supplies a directory, map downloads return 404.
The daemon fails at startup if an explicit override is missing or is not a
directory.
Export the same OpenAPI document that the running daemon serves at
/openapi.json:
darpcd.exe --print-openapi > openapi.json
Discovery and registry
Start the daemon without a PID to discover clients from their verified
Darkages top-level window class:
darpcd.exe
darpcd.exe --port 3626
darpcd.exe --auto-load
Repeat --pid <pid> to retain additional controlled targets or processes that
do not expose the normal game window. Explicit and discovered targets use the
same independent connection workers:
darpcd.exe --pid 3780 --pid 6648
The daemon’s client roster owns the membership of explicit, discovered, and recently launched targets together with their registry records and connection workers. Membership is tracked independently from worker availability. A worker startup failure therefore remains retryable while the target is desired, and the target can still be removed cleanly after it disappears. Removal stops the worker before deleting the public registry record, and daemon shutdown signals every remaining worker.
Each worker retries a missing or busy pipe, performs the shared controller
handshake, requests a fresh snapshot, long-polls bounded state-event batches,
and sends a periodic Ping to detect a broken connection.
An accepted release connection must report the supported x86 architecture,
executable fingerprint, and client version. Registry identity combines the PID, raw
process creation time, and DLL instance ID. A reused PID or reloaded DLL
therefore replaces the prior record instead of inheriting it.
Each worker also owns a bounded command receiver. HTTP requests carry the expected process and DLL identity, and the worker rejects a request after a replacement or disconnect. It processes at most one routed command between normal event polls, assigns protocol request IDs on the owning session, and never opens a competing pipe connection. A full worker queue affects only that client.
An incompatible peer remains visible as a target status but is not accepted as a client and is never reinjected automatically. A discovered target is removed after its game window disappears. An explicit PID remains configured until the daemon exits.
--auto-load applies the configured loader and DLL to every not_loaded
target once per tracked process. This includes targets present at daemon startup
and targets discovered later. Connecting, connected, busy, initializing, and
incompatible targets are not injected. Each target is handled independently,
so one validation or loader failure does not stop discovery or other clients.
Automatic loading records the attempt before starting the loader. It therefore
does not retry on every discovery pass, and an explicit unload remains unloaded
for the rest of that tracked process lifetime. Removing and rediscovering the
process, or restarting the daemon with --auto-load, makes it eligible again.
Managed lifecycle
The loader and DLL paths default to loader.exe and darpc.dll beside
darpcd.exe. Override those server-side paths when the artifacts live
elsewhere:
darpcd.exe --loader-path <loader.exe> --dll-path <darpc.dll>
These paths are also used by --auto-load; the flag never accepts a different
DLL or bypasses normal loader validation.
Each launch request supplies the full executable path for the intended
installation. The daemon assumes no client base directory; loader.exe uses
the executable’s parent directory as the launched process working directory.
The HTTP API can load the configured DLL into a discovered PID, unload it, or
launch the requested executable suspended and initialize the DLL before the
client resumes. loader.exe repeats architecture, DLL, and executable
validation for every operation. A window match or request path is only a
candidate signal.
Launch requests expose the client executable path and only the supported startup choices: allow multiple clients, skip the intro, skip the notice sequence, suppress terminal exchange alerts, and optionally select a server endpoint. The API never accepts arbitrary process arguments or request-selected loader and DLL paths.
The current console output reports transitions such as:
HTTP API listening on http://127.0.0.1:2626
client pid=3780 status=connecting
client pid=3780 status=not_loaded
client pid=3780 status=initializing
client pid=3780 status=connected creation_time=... instance=... protocol=1.3 ...
client pid=3780 status=disconnected instance=... reason="..."
client pid=3780 status=busy
client pid=3780 status=incompatible instance=... reason="..."
client pid=3780 status=removed
After the handshake, each worker requests a fresh snapshot and stores it with that client’s identity and connection metadata. Reconnecting after a daemon restart therefore reconstructs daemon state without reinjecting the DLL. The snapshot carries the event boundary it already represents. Consecutive absolute updates reduce into the retained state and appear in REST without another memory walk. A reported overflow or sequence or revision gap causes an immediate fresh snapshot.
The registry validates and reduces each observation once before REST or
Server-Sent Events can publish it. A rejected batch does not partially update
the retained state and none of its events reach subscribers. The last valid
snapshot remains available internally for recovery comparisons, but public
state routes return 503 Service Unavailable until the worker obtains and
commits a fresh snapshot.
Active spell effects are retained as a focused collection resource. Ordered add, remove, and relative-duration changes update that resource and the per-client event stream from the same event boundary.
Web interface
The HTTP server binds to 127.0.0.1:2626 by default. --port <port> overrides
the port while retaining the loopback boundary. --listen <ipv4[:port]>
explicitly selects another IPv4 interface for trusted VM or local-network use.
The generated OpenAPI document is served at /openapi.json, and the vendored
Swagger UI is served at /docs. HTTP models remain separate from registry and
binary protocol types.
Each connected client also exposes
GET /clients/{client}/events. The daemon subscribes before reading the
current registry snapshot, emits a stream.ready boundary, and then emits only
later state changes for that exact process and DLL identity. The internal
broadcast channel holds 4,096 events. A lagging subscriber receives
stream.resync_required and closes; it cannot block the game hook, client
worker, or another subscriber.
The same web boundary can submit, query, and cancel the no-op diagnostic command. A separate bounded daemon router wakes the registry loop, which sends the request to the matching per-client worker. The returned status includes the DLL instance ID, client tick timing, execution duration, and game main-thread ID.
Each connected worker also samples the existing tick-hook health counter once
per second. Three consecutive samples below 60 ticks per second produce one
tick_rate_degraded daemon log entry. The next healthy sample produces one
tick_rate_recovered entry, avoiding continuous warnings during one incident.
For DLL component 1.5.2 and later, the worker also queries hook timing once per
second. Disabled responses are silent. When an over-budget counter advances,
the daemon writes one hook_budget_exceeded entry with the client PID, stage,
budget, delta, total, maximum, and last duration. HTTP callers can query,
enable, disable, or reset the same counters without reconnecting the client.
See the Web API chapter for routes, request models, responses, and failure behavior.
Failure isolation
A daemon restart must not end a game session. The pipe closes when the daemon
stops, darpc.dll immediately returns to listening, and a replacement daemon
can reconnect without reinjection. One worker failure changes only that
target’s status and cannot terminate another worker or the daemon.
Lifecycle work runs outside the asynchronous HTTP executor. Connections, requests, DLL event storage, and daemon stream fanout are bounded so one slow API consumer or game client cannot starve the others.
Discovery and recovery
This chapter explains how the daemon finds clients and recovers connections.
It is most useful when a client appears as not_loaded, remains disconnected,
or is being managed with --auto-load.
Discovery is owned by darpcd.exe. The daemon periodically reconciles candidate
game clients with available daRPC endpoints. darpc.dll does not need to locate
or notify the daemon.
The daemon also accepts repeated explicit --pid <pid> targets for controlled
processes or clients without the normal game window. Explicit and discovered
targets use the same registry and connection workers.
Deterministic pipe names
Once initialized, each darpc.dll creates a named pipe derived from its process
identifier (PID):
\\.\pipe\da-rpc-{pid}
The DLL keeps this endpoint available and accepts a replacement connection after a controller disconnects or restarts. The implemented endpoint rejects remote clients and grants access to the process owner, Windows system, and administrators.
The pipe exposes one instance because the DLL has one controller. During
development, darpc.exe can own it for direct diagnostics. In normal use,
darpcd.exe owns it; a second connector receives a distinct busy error and must
not inject another DLL.
Reconciliation loop
darpcd.exe reconciles once at startup and then once per second.
- Enumerate top-level windows and select the verified
Darkagesgame window class using an exact, case-sensitive match. - Resolve every matching window to its process identifier.
- Derive the expected named-pipe path for each PID.
- Attempt a short, bounded connection and perform the daRPC handshake.
- Remove discovered targets whose game window has disappeared.
- Retry living candidates during the next reconciliation.
A window-class match is only a candidate filter. It is not proof of a safe
client version or a valid daRPC endpoint. Explicit load and launch operations
therefore go through loader.exe, which repeats executable, architecture, DLL,
and already-loaded validation before changing the process.
The daemon retains a newly launched PID for five seconds while the resumed client creates its game window. This grace period prevents a successful launch from disappearing between the loader result and the next window enumeration.
Candidate states
The pipe result determines the next step:
| Result | Meaning | Action |
|---|---|---|
| Connection and handshake succeed | A compatible darpc.dll is available. | Request a snapshot and begin listening for updates. |
| Pipe is busy | An endpoint exists but cannot accept this connection yet. | Retry without injecting. |
| Pipe is missing during a short grace period | The DLL may still be initializing. | Wait for the next reconciliation. |
| Pipe remains missing | The process may not be injected. | Report not_loaded; allow an explicit API load or one opt-in automatic load attempt. |
| Handshake fails | An endpoint exists but is incompatible or invalid. | Report the error and do not inject automatically. |
loader.exe must repeat its own compatibility and already-loaded checks before
injection, even when darpcd.exe reports a candidate.
The HTTP API exposes explicit load and unload operations for tracked PIDs and a
launch operation for a request-selected executable that the loader must
validate. By default, discovery never triggers injection. With --auto-load,
the daemon consumes a target’s first not_loaded state and schedules one
validated loader attempt outside the reconciliation loop. It does not retry the
same tracked process after a failure or explicit unload. Handshake failures
therefore cannot cause an automatic reinjection loop.
Daemon recovery
While the daemon is unavailable, darpc.dll continues updating local state. The
pipe server detects the broken connection and returns to its listening state.
After restart, darpcd.exe performs its normal startup reconciliation, connects,
requests a new snapshot, and resumes event delivery from the snapshot boundary.
This restores current state without requiring a registry entry, shared file, system service, or event backlog.
Why not custom window messages
Custom Windows messages are not a primary discovery mechanism because a notification can be missed and an uninjected client cannot respond. They also create a reverse dependency in which injected code must know how to locate the daemon.
A message may be added later as a latency optimization, but reconciliation must remain the source of truth. If polling is already fast and inexpensive, the notification adds little value.
Game data
The game-data chapters are organized around the questions a player-facing tool usually asks: who is logged in, what the character carries, what is nearby, and what just happened. They document the stable public view exposed by the daemon rather than internal client memory.
daRPC presents each game client as a set of familiar resources. Character status, items, equipment, skills, spells, effects, nearby objects, messages, NPC dialogs, message dialogs, field maps, bulletins, groups, and exchange state each have their own REST route and documentation chapter.
This chapter explains the behavior they share. The individual chapters focus on what a Dark Ages player or tool author can do with each kind of data.
Finding the data you need
| Domain | Current state | Live changes | Action |
|---|---|---|---|
| Character status | /status | Stats, vitals, progression, gold, weight, modifiers, and flags | None |
| Inventory | /items | Items added, removed, or changed | None |
| Equipment | /equipment | No dedicated equipment event yet | None |
| Skills | /skills | Skillbook changes and skill use | /skills/use, /skills/swap |
| Spells | /spells | Spellbook changes and casting stages | /spells/cast, /spells/swap |
| Effects | /effects | Effects added, changed, or removed | None |
| World | /objects and /status | Location, visible objects, and entity visuals | None |
| Movement | /status | Walking and turning | /turn, /walk, and /resync |
| Emotes | None | Character emotes | /emote |
| Messages | /messages | Chat and system messages by channel | /messages/send |
| NPC dialogs | /dialog | Open, changed, submitted, and closed pages | Dialog response actions |
| Message dialogs | /message-dialogs | Complete dialog-set changes | /message-dialogs/dismiss |
| Field maps | /field-map | Open, changed, selected, and closed panels | /field-map/select |
| Bulletin boards and player mail | /bulletin | Views, pages, selection, drafts, requests, results, and close | /bulletin/actions |
| Groups | /group | Invitations, settings, and roster changes | Group actions |
| Exchange | /exchange | Both offers and acceptance state | Offer, accept, and cancel actions |
All client routes begin with /clients/{client}. The {client} value may be a
process ID or the current character name. See Choosing a client
for the exact rules.
Current state and live changes
REST answers the question, “What does this client know now?” Server-Sent Events (SSE) answer the question, “What changed after I started listening?”
A common consumer flow is:
- Open
/clients/{client}/events. - Wait for
stream.ready. - Read the REST resources needed by the tool.
- Apply later SSE events in their delivered order.
The daemon subscribes to changes before it reads the ready boundary. This prevents a gap between the current REST state and the live stream. SSE does not replay events from before the subscription. If a stream reports that it fell behind, read the REST resources again and reconnect.
The Live events chapter documents the common event envelope, payloads, ordering, reconnect, and lag behavior. Each domain chapter explains the events relevant to that data in game terms.
How a client gets its first state
When the daemon connects, the DLL captures one complete baseline from the game client. That baseline includes the current character, map, planned route, collections, effects, and any world objects still available in client memory.
The capture runs on a normal client tick because that is where the game changes most of these structures. The DLL copies bounded values into memory it owns, then lets its pipe worker convert and send them. It does not serialize JSON, write logs, or perform named-pipe input/output on the game thread.
After the baseline, observed game events update the retained state. A REST read uses the daemon’s current copy and does not make the DLL walk game memory again. A fresh complete baseline is taken when a new daemon connects or when daRPC needs to recover from a missed update.
Observation metadata
Snapshot-backed responses include an observation object. It identifies the
source process and helps consumers understand how fresh related resources are.
Important fields include:
pididentifies the source game process.revisionadvances when retained state changes.event_sequenceorders incremental changes.captured_tick_msis the client tick of the last full baseline.updated_tick_msadvances when a later event changes the state.capture_duration_usrecords how long the baseline memory walk took.world_generationchanges when the active game world is replaced.
ObservationMetadata {
pid: u32,
revision: u32,
event_sequence: u32,
captured_tick_ms: u32,
updated_tick_ms: u32,
capture_duration_us: u32,
world_generation: u32,
}
SSE event observations also carry instance_id, which identifies one loaded
DLL lifetime. See Common observation metadata.
Two separate REST requests can have different revisions if the client changes between them. Read the revision when several resources must be compared as one view.
Missing and empty values
daRPC does not invent values when the client cannot provide them.
nullmeans that a value or collection was unavailable.- An empty array means the collection was read successfully and had no entries.
- Optional fields remain absent or null until the client has supplied them.
- Empty inventory, equipment, spellbook, and skillbook slots are omitted.
A state route returns 404 Not Found for an unknown client and 503 Service Unavailable when the client has not produced a usable observation. The latter
may include a capture failure reason.
The structures in this book use a small notation:
string,bool, and integer names such asu32describe JSON value types.- A trailing
?means the value can be absent ornull. T[]means an array of values shaped likeT.- Structures show JSON fields, not Rust or game-client memory layouts.
Each client has its own view
Dark Ages characters share one game world, but each running client sees only part of it. Nearby monsters and players may disappear from one client’s view while another client on the same map still sees them. The same is true for messages and some local user-interface state.
For that reason, daRPC currently keeps state per client. It does not merge several clients into one global world model or guess which observation is the newest. A future aggregator can build that shared view while retaining the source client and observation time.
Threading in plain language
The client main thread owns most game state. daRPC copies state and runs native actions there so it does not race the client from an unrelated thread. The hook paths remain short and use fixed buffers or bounded queues. Parsing, serialization, web requests, and named-pipe work happen elsewhere.
See Runtime hooks for the installed hooks, their purpose, and the attach and detach lifecycle.
Character status
Character status is the best starting point for a dashboard, overlay, or automation rule. It describes who is logged in, the current map, important character values, and a few pieces of client-only action state.
| Use | Route or events |
|---|---|
| Read current status | GET /clients/{client}/status |
| Watch changes | Status and walking events |
Reading status
curl "http://127.0.0.1:2626/clients/ZiLo/status"
The response groups the data into a lifecycle, optional character, optional map, and common observation metadata. The generated Swagger schema shows every field and exact JSON type.
Status {
observation: ObservationMetadata,
lifecycle: ClientLifecycle,
character: Character?,
map: MapLocation?,
planned_route: PlannedRoute?,
}
The character data includes:
- Character ID, name, gender, class, hairstyle, hair color, and body sprite
- Nation, title, guild rank, display class, and guild from the latest self-look
- Level, ability level, experience, ability points, and progress toward the next level and ability level
- Strength, intelligence, wisdom, constitution, and dexterity
- Current and maximum health and mana
- Gold, weight, and maximum weight
- Armor class, damage, hit, magic resistance, attack element, and defense element
is_hidden,is_blinded,is_casting,is_walking, andis_action_restrictedmovement_source, which identifies the active movement origin and is null while idle- The last server-confirmed
is_group_opensetting and currentgroup_members
The map data includes its ID, available name, zero-based x/y position, width, and height.
Character {
id: u32?,
name: string?,
gender: CharacterGender?,
hair_style: u16?,
hair_color: u8?,
body_sprite: u16?,
class: CharacterClass,
identity: PlayerIdentity?,
is_hidden: bool,
is_action_restricted: bool,
is_blinded: bool,
is_casting: bool,
is_walking: bool,
movement_source: ActionSource?,
is_group_open: bool?,
is_in_exchange: bool,
group_members: Vec<GroupMember>,
gold: u32,
weight: u32,
max_weight: u32,
progression: CharacterProgression,
stats: CharacterStats,
vitals: CharacterVitals,
modifiers: CharacterModifiers?,
}
MapLocation {
id: u32,
name: string?,
x: i32?,
y: i32?,
width: i32,
height: i32,
}
PlannedRoute {
source: ActionSource,
generation: u32,
tiles: Vec<{ x: i32, y: i32 }>,
}
planned_route.tiles contains the complete native plan from the current tile
through the goal. Its source identifies who built or replaced that plan. It
is replaced atomically after pathfinder rebuilds and as confirmed steps are
consumed. See Movement for source semantics and
Movement for generation and
empty-route behavior.
Hidden characters
is_hidden identifies a character that is using Hide, including a hidden
character that remains visible as a translucent sprite because of a detection
spell. For the local character, /status reports the client object’s hidden or
translucent state. A transition emits character.hidden_changed on the SSE
stream.
Nearby players report is_hidden on their player objects. A player is treated
as hidden when a 0x33 player draw has either a zero body sprite or the
translucent/hidden flag. The resulting player.appeared event carries the
current is_hidden value. There is no player.hidden_changed event: the
character.* namespace is reserved for the local character, while nearby
players use the world-object events described in World.
Hidden draws are intentionally treated as sparse observations. They do not erase the local character’s last complete human appearance, or a nearby player’s last known name and profile, when those fields are omitted. Retained data is matched by entity ID. Inspecting a hidden player can still supply a profile, which is retained for later sparse observations of that same entity. Leaving the observed area still removes the player normally.
Monster form is separate from Hide. A local monster form has no human
appearance, reports is_hidden: false, and changing into or out of it emits
character.appearance_changed. See
Hide and monster-form transitions
for the exact SSE payload changes in both directions.
Client lifecycle
The lifecycle field tells you where the client is, even when no character is
available yet:
| Value | Meaning |
|---|---|
unknown | daRPC cannot confidently classify the current scene. |
title | The client is at the title or login flow. |
transition | The client is between stable game worlds. |
in_game | A usable character and map are active. |
disconnected | The reconnect dialog is visible. |
The reconnect dialog takes priority over the scene behind it. A disconnected
status may retain the last readable character and map, so use lifecycle
rather than the presence of character to decide whether the session is live.
The DLL refreshes lifecycle during client ticks. Consumers can also watch
client.logged_in and client.disconnected; see
Client lifecycle events.
Action flags
is_walking means the client’s native pathfinder has an active queued route.
A single directional step does not set it.
is_casting means a delayed spell is in progress. Instant spells often begin
and finish between two REST reads, so the spell events
are the better record of those casts.
is_action_restricted represents a specific client restriction used by
movement, ground drops, incoming exchange start, and inventory rearrangement.
It does not mean that every action is blocked. Turning and ordinary skill or
spell activation can still be available.
is_blinded follows the blind state retained from the character’s latest
status update.
is_in_exchange is true while daRPC retains an open player exchange. The full
offer is available from GET /exchange.
Appearance limits
Gender, hairstyle, hair color, and body sprite come from the local character’s appearance record. They are unavailable together while the character is shown through a monster-disguise image.
Readable names are used for gender, class, and elements. Raw client identifiers and memory addresses are not exposed.
stats.stat_points is the current number of unspent character stat points.
The five attribute values remain strength, dexterity, intelligence,
wisdom, and constitution.
Spending stat points
Use POST /clients/{client}/stats/{stat} to spend one available
stats.stat_points. The request has no body. For example, this increases
strength for the client named ZiLo:
curl --request POST "http://127.0.0.1:2626/clients/ZiLo/stats/strength"
Short names are accepted, so this request increases constitution:
curl --request POST "http://127.0.0.1:2626/clients/ZiLo/stats/con"
The accepted values are strength or str, dexterity or dex,
intelligence or int, wisdom or wis, and constitution or con.
The daemon returns HTTP 400 without sending a packet when no point is
available. Wait at least 500 milliseconds between successful requests for the
same character; an earlier request returns HTTP 429.
The direct Windows equivalent is:
darpc stat strength --pid 1234
Live status events
The complete payload structures and recovery route are in Character status events.
Listen on GET /clients/{client}/events. These events update status:
| Event | What changed |
|---|---|
stats.changed | Available stat points and all five character attributes |
vitals.changed | One or more health or mana values |
progression.changed | Level, ability level, experience, or remaining progress |
gold.changed | Carried gold |
weight.changed | Current or maximum weight |
modifiers.changed | Combat modifiers or elements |
blind.changed | is_blinded |
action_restriction.changed | is_action_restricted |
character.appearance_changed | The local character entered or left a non-human appearance |
character.hidden_changed | The local character entered or left Hide |
character.profile_changed | Nation, title, guild rank, display class, or guild |
location.changed | Absolute x/y and, when applicable, an atomic map change |
walking.started | Native pathfinding began a queued route |
walking.stopped | The queued route ended or was interrupted |
walking.obstructed | A direct or queued movement step was rejected at a tile |
walking.route_changed | The complete native planned route was rebuilt, consumed, or cleared |
spell.begin | A delayed cast began and is_casting became true |
spell.cast | A cast completed and is_casting became false |
spell.cancelled | A delayed cast ended without casting |
Status event values are absolute replacements, not amounts to add or subtract. Several events can share one revision when one game update changed several groups.
Lifecycle transitions emit client.logged_in when the title screen enters the
game and client.disconnected when the client returns to its disconnected
state. A closed process also closes its stream. Consumers should reread status
after reconnecting.
See World for map transitions and Movement for route details.
Inventory
The inventory resource contains the items carried by one character. Empty slots are omitted, which makes it easy to scan the items that are actually present.
| Use | Route or events |
|---|---|
| Read carried items | GET /clients/{client}/items |
| Use, drop, give, or pick up an item | POST /clients/{client}/items/... |
| Swap inventory slots | POST /clients/{client}/items/swap |
| Sell, store, withdraw, or repair by exact name | POST /clients/{client}/items/... |
| Drop gold | POST /clients/{client}/gold/drop |
| Give gold | POST /clients/{client}/gold/give |
| Watch changes and submitted actions | Inventory events |
Reading inventory
curl "http://127.0.0.1:2626/clients/ZiLo/items"
Each occupied item includes:
slot, using the client’s one-based inventory slotspriteanddye_color- An available canonical
name quantityandcan_stackdurabilityandmax_durability
Inventory {
observation: ObservationMetadata,
items: InventoryItem[]?,
}
InventoryItem {
slot: u8,
sprite: u16,
dye_color: u8,
name: string?,
quantity: u32,
can_stack: bool,
durability: u32,
max_durability: u32,
}
The sprite value has the client’s internal item classification flag removed.
Stackable names do not include the rendered [ quantity ] suffix because
quantity is already a separate field.
The client’s special gold slot is omitted. Use the top-level gold field from
character status instead.
Using and moving items
Use an item by one-based slot or case-insensitive name:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"name":"Red Potion"}' \
"http://127.0.0.1:2626/clients/ZiLo/items/use"
Drop an item only at a zero-based map tile:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"slot":12,"destination":{"x":3,"y":6}}' \
"http://127.0.0.1:2626/clients/ZiLo/items/drop"
Give an item only to a visible human, monster, or NPC:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"name":"Red Potion","quantity":2,"target":"OtherPlayer"}' \
"http://127.0.0.1:2626/clients/ZiLo/items/give"
quantity defaults to 1. An empty slot, zero quantity, quantity larger than
the current stack, or quantity other than 1 for a non-stackable item returns
400 Bad Request. The DLL checks the live slot and quantity again on the game
thread before submitting the action.
Pick up the top ground item at a tile with:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"position":{"x":3,"y":6}}' \
"http://127.0.0.1:2626/clients/ZiLo/items/pickup"
The client protocol identifies the tile rather than a ground object ID. On a stacked tile, the server decides which visible item is picked up.
Gold uses the same distinct ground and entity routes:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"amount":100,"destination":{"x":3,"y":6}}' \
"http://127.0.0.1:2626/clients/ZiLo/gold/drop"
curl --request POST \
--header "Content-Type: application/json" \
--data '{"amount":100,"target":"OtherPlayer"}' \
"http://127.0.0.1:2626/clients/ZiLo/gold/give"
An object target may be a visible human or named creature, matched without case sensitivity, or its numeric object ID. Name lookup checks human players first, then falls back to monsters and NPCs. The local character is not a valid transfer target.
Rearrange inventory with the same swap payload used by skills and spells:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"source":{"name":"Red Potion"},"destination":{"slot":12}}' \
"http://127.0.0.1:2626/clients/ZiLo/items/swap"
Each selector contains exactly one of slot or name. Names are matched
without case sensitivity. A destination selected by slot may be empty; a name
always resolves to an occupied slot. The two selectors must resolve to
different slots.
Chant and NPC item actions
Send arbitrary nonempty ASCII text through the spell-chant channel with:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"text":"ard cradh"}' \
"http://127.0.0.1:2626/clients/ZiLo/chant"
The convenience routes submit the NPC phrases shown below through that same channel:
| Route | Request | Submitted chant |
|---|---|---|
/items/sell | {"name":"Dark Belt"} | buy my Dark Belt |
/items/sell-all | {"name":"Dark Belt"} | buy my all Dark Belt |
/items/deposit | {"name":"Dark Belt"} | i will deposit Dark Belt |
/items/withdraw | {"name":"Dark Belt"} | give my Dark Belt back |
/items/repair | {"name":"Dark Belt"} | repair my Dark Belt |
/items/repair-all | No body | repair all |
Item names are case-sensitive and must be supplied verbatim. daRPC does not look them up in the current inventory or normalize their capitalization, punctuation, repeated spaces, or leading and trailing spaces. The complete formatted chant must contain at most 255 ASCII bytes.
How inventory stays current
The initial baseline reads the occupied inventory slots from client memory. Later inventory packets tell daRPC which slots may have changed.
Some game actions update more than one slot. Moving an item, swapping two items, splitting a stack, or merging stacks can arrive as several closely spaced updates. daRPC waits for a short quiet period, rereads the affected slots, and applies the complete group before REST or SSE consumers see it.
This avoids reporting a simple move as an item leaving and immediately coming back. Repeating an identical same-slot update produces no event.
Inventory events
The complete payload structures and batch rules are in Inventory and equipment events.
| Event | Meaning |
|---|---|
item.added | A slot gained an item or a stack quantity increased. |
item.removed | A slot became empty or a stack quantity decreased. |
item.changed | An existing item moved, swapped, split, merged, or changed details. |
item.used | The client submitted an item-use request. |
item.dropped | The client submitted an item drop with slot, quantity, and destination. |
item.given | The client submitted an item exchange request with slot, quantity, and target ID. |
item.picked_up | The client submitted a tile pickup with its chosen destination slot. |
item.pickup_failed | A submitted tile pickup received correlated carry-limit feedback. |
gold.dropped | The client submitted a gold drop with amount and destination. |
gold.given | The client submitted a gold transfer with amount and target ID. |
These names refer only to carried inventory. Ground items use
item.appeared, item.disappeared, and item.moved as described in
World.
Action events describe an outgoing request observed at the client’s normal packet boundary. Giving an item opens the game’s ordinary exchange flow; it does not mean the other player accepted it. Later inventory and gold state events confirm results accepted by the server.
When the system message <item>, You can't have more than <limit>. follows one
unambiguous pending pickup, the daemon also emits item.pickup_failed. Its
payload includes the attempted position and destination slot, item_name,
limit, reason: "carry_limit", the original feedback, and submission
timing. The ordinary message.system event remains in the stream. Ambiguous or
expired pickup attempts do not produce the semantic failure event.
Continue an open offer, set gold, accept, or cancel through the player exchange API.
Each inventory event contains:
{
observation,
batch_index,
batch_count,
slot,
before,
after,
}
before is null when the slot was empty. after is null when the slot became
empty. batch_index is zero-based, and all frames from one multi-slot change
share the same batch_count. The daemon has already applied the whole batch to
the REST inventory before it sends the first frame.
When a tool needs a simple answer rather than slot-by-slot history, it can
handle any inventory event by rereading /items.
Availability
items: null means the client could not expose inventory at that time. An
empty array means the inventory was read successfully and no occupied items
were found.
Equipment
The equipment resource describes every occupied wearable slot for the current character.
| Use | Route or events |
|---|---|
| Read equipped items | GET /clients/{client}/equipment |
| Unequip an item | POST /clients/{client}/equipment/unequip |
| Watch submitted unequip actions | Equipment events |
Reading equipment
curl "http://127.0.0.1:2626/clients/ZiLo/equipment"
Each entry includes:
- A readable
slotname spriteanddye_color- An available item
name durabilityandmax_durability
Equipment {
observation: ObservationMetadata,
items: EquipmentItem[]?,
}
EquipmentItem {
slot: EquipmentSlot,
sprite: u16,
dye_color: u8,
name: string?,
durability: u32,
max_durability: u32,
}
The sprite value has the client’s internal item classification flag removed. Empty slots are omitted.
Equipment slot names are stable snake-case values:
weapon, armor, shield, helmet, earrings, necklace,
left_ring, right_ring, left_gauntlet, right_gauntlet,
belt, greaves, boots, accessory1, overcoat, over_helm,
accessory2, accessory3
Unequipping an item
Use the same readable slot name to move equipped gear back to inventory:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"slot":"armor"}' \
"http://127.0.0.1:2626/clients/ZiLo/equipment/unequip"
The action is submitted on the client main thread and produces an
equipment.unequipped event when the outgoing request is observed. Its
payload contains the slot name. The event records the request, while a later
equipment snapshot or incremental equipment support confirms server state.
Updates and events
The action payload is documented under Inventory and equipment events.
Equipment is included in the complete client baseline and exposed through
REST. The current implementation does not yet track later gear changes or
publish a dedicated equipment.changed SSE event.
/equipment therefore reflects the most recent complete baseline. A new daemon
connection or resynchronization captures a fresh baseline before its
stream.ready boundary. Until incremental equipment tracking is added, the
absence of an event or REST change is not proof that the character has not
changed gear.
Availability
items: null means the equipment collection was unavailable. An empty array
means it was read successfully and every equipment slot was empty.
Skills
The skillbook resource lists the learned skills in the character’s skill pane. daRPC can also use one of those skills through the same native client path as a normal activation.
| Use | Route or events |
|---|---|
| Read learned skills | GET /clients/{client}/skills |
| Use a skill | POST /clients/{client}/skills/use |
| Swap skills | POST /clients/{client}/skills/swap |
| Perform a basic attack | POST /clients/{client}/assail |
| Watch skillbook and use activity | Skill events |
Reading the skillbook
curl "http://127.0.0.1:2626/clients/ZiLo/skills"
Each occupied slot includes:
- One-based
slot iconand availablename- Current
levelandmax_level cooldown.active- Optional
cooldown.cooldown_mscontaining the total cooldown duration - Optional
cooldown.remaining_mswhen the client retains an exact expiry
A cooldown can be known to be active even when the exact remaining time is not available. Live action-delay packets are authoritative for the total duration. The skillbook’s retained start and end timestamps provide current progress and recover exact timing after a late attach.
Skillbook {
observation: ObservationMetadata,
skills: Skill[]?,
}
Skill {
slot: u8,
icon: u16,
name: string?,
level: u8,
max_level: u8,
cooldown: Cooldown,
}
Cooldown {
active: bool,
cooldown_ms: u32?,
remaining_ms: u32?,
}
Using a skill
Select the skill by one-based slot:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"slot":5}' \
"http://127.0.0.1:2626/clients/ZiLo/skills/use"
Or by case-insensitive name:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"name":"Assail"}' \
"http://127.0.0.1:2626/clients/ZiLo/skills/use"
Use exactly one selector. Slots range from 1 through 90. An invalid body or
slot returns 400 Bad Request; an unknown name or empty learned slot returns
404 Not Found.
The daemon resolves a name against its retained skillbook. The DLL then checks the live slot again and calls the client’s normal skill activation routine. It does not open the skill panel, change the visible lower-tray page, move the mouse, or synthesize a click.
An executed command means local client activation ran. skill.used is the
later observation that the client submitted the skill. Neither result alone is
a promise that the game server accepted the action.
Basic attack (Assail)
Assail is the client’s built-in basic attack action. It does not select or require a learned skillbook slot:
curl --request POST "http://127.0.0.1:2626/clients/ZiLo/assail"
The direct named-pipe client exposes the same action for one injected process:
darpc assail --pid 3780
The command queues on the game thread and submits the native client attack
packet 0x13. A successful command result means the packet was submitted. The
corresponding server response can produce player.animated and sound.played
events, which provide the observable animation and audio cues.
Use /assail for the built-in basic attack. Use /skills/use when selecting a
learned skillbook entry by slot or name.
Swapping skills
curl --request POST \
--header "Content-Type: application/json" \
--data '{"source":{"slot":5},"destination":{"name":"Assail"}}' \
"http://127.0.0.1:2626/clients/ZiLo/skills/swap"
Both selectors accept exactly one of slot or case-insensitive name, using
the same payload as inventory and spell swaps. A destination slot may be empty.
The source must be occupied, and the resolved slots must be different.
Skillbook events
The complete payload structures and batch rules are in Skill events.
| Event | Meaning |
|---|---|
skill.added | A learned skill appeared in a slot. |
skill.removed | A skill left a slot. |
skill.changed | A skill moved or its retained details changed. |
skill.cooldown | A retained skill entered or restarted cooldown. |
skill.ready | A retained skill left cooldown and is ready to use. |
These events use the same batch_index, batch_count, slot, before, and
after shape as inventory events. Moving or
swapping skills can update several slots in one batch. Identical same-slot
updates are ignored.
skill.changed is not emitted for a cooldown-only transition. A cooldown event
contains observation, the one-based slot, optional name, optional
cooldown_ms, and optional remaining_ms. cooldown_ms is the stable total
duration, while remaining_ms is the time left at observation and never
exceeds the total when both are present. The live packet supplies the total;
the retained skill timestamps supply progress. A ready event
contains observation, slot, and optional name. When the client exposes an
exact skill expiry, daRPC schedules a read at that deadline. Otherwise it polls
only the watched active slot until the skill is ready.
Skill use event
skill.used is emitted when daRPC observes the client’s outbound skill-use
submission. It contains the one-based slot and the skill name when the
daemon can resolve it from the current skillbook.
This event also covers skills used through the normal game interface. It is an observation of the client behavior, not only a receipt for the REST command.
Availability
skills: null means the skillbook was unavailable. An empty array means it was
read successfully and no occupied skill slots were found.
Spells
The spellbook resource describes learned spells, their targeting behavior, and their visible cooldown state. daRPC can cast a spell through native client methods and report each stage of delayed casting.
| Use | Route or events |
|---|---|
| Read learned spells | GET /clients/{client}/spells |
| Cast a spell | POST /clients/{client}/spells/cast |
| Swap spells | POST /clients/{client}/spells/swap |
| Watch casting, feedback, and spellbook changes | Spell events |
Reading the spellbook
curl "http://127.0.0.1:2626/clients/ZiLo/spells"
Each occupied slot includes:
- One-based
slot iconand availablename- Current
levelandmax_level - The number of chant
lines target_type:none,target, ortext_input- An optional cleaned ASCII
promptfor text-input spells - Cooldown activity plus optional total and exact remaining durations
The prompt is only present for text-input spells. A cooldown can be known to be
active without exact cooldown_ms or remaining_ms values. daRPC retains exact
timing from live server action-delay packets. A spell already cooling when the
DLL attaches exposes only its active flag because the spellbook retains no
start or end timestamp.
Spellbook {
observation: ObservationMetadata,
spells: Spell[]?,
}
Spell {
slot: u8,
icon: u16,
name: string?,
level: u8,
max_level: u8,
lines: u8,
target_type: SpellTargetType,
prompt: string?,
cooldown: Cooldown,
}
Cooldown {
active: bool,
cooldown_ms: u32?,
remaining_ms: u32?,
}
Casting a spell
Select the spell by one-based slot or case-insensitive name:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"name":"Mist"}' \
"http://127.0.0.1:2626/clients/ZiLo/spells/cast"
Targeted spells accept a character or Mundane name, a current object ID, or a map tile:
curl --request POST --header "Content-Type: application/json" \
--data '{"name":"Taunt","target":"OtherPlayer"}' \
"http://127.0.0.1:2626/clients/ZiLo/spells/cast"
curl --request POST --header "Content-Type: application/json" \
--data '{"slot":12,"target":1843}' \
"http://127.0.0.1:2626/clients/ZiLo/spells/cast"
curl --request POST --header "Content-Type: application/json" \
--data '{"name":"Ground Spell","target":{"x":20,"y":14}}' \
"http://127.0.0.1:2626/clients/ZiLo/spells/cast"
A targeted spell with no target defaults to the casting character. A name
search is case-insensitive and checks players within 14 tiles before visible
Mundanes. Players remain valid targets while invisible when their latest
observation, including object ID and position, is still retained. Object IDs
must identify a current retained target within the same range. Tile coordinates
are zero-based and must fit the current map.
For example, a named Mundane target could use "target": "Beggar".
Text-input spells use input:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"name":"Learning Spell","input":"Elemental Bless 6"}' \
"http://127.0.0.1:2626/clients/ZiLo/spells/cast"
Text input must contain 1 through 100 ASCII bytes. Extra, conflicting, or
incorrect argument types return 400 Bad Request. An unknown spell or named
target returns 404 Not Found.
The DLL checks the live spell slot and arguments again before calling the matching native client routine. It does not switch the visible spell panel or synthesize user input.
After the native routine accepts a cast, the command remains pending for 500 milliseconds so immediate server feedback can determine its result. These exact system messages complete the command with distinct failures:
| System message | Failure |
|---|---|
Your Will is too weak. | insufficient_mana |
The magic has been deflected. | resist |
No target. | invalid_target |
That doesn't work here. | not_allowed |
not_allowed includes attempts to cast on no-cast maps. Silence during the
bounded response window completes the command successfully.
Spell casts have 10 percent tolerance on the normal one-second start deadline. This bounded window accommodates small native dispatcher overruns during an earlier cast; the one-second action deadline remains in effect for other commands.
Swapping spells
curl --request POST \
--header "Content-Type: application/json" \
--data '{"source":{"name":"Mist"},"destination":{"slot":12}}' \
"http://127.0.0.1:2626/clients/ZiLo/spells/swap"
Both selectors accept exactly one of slot or case-insensitive name, using
the same payload as inventory and skill swaps. A destination slot may be empty.
The source must be occupied, and the resolved slots must be different.
Casting events
The complete payload structures and stream names are in Spell events.
daRPC observes the same outbound spell path for casts started through the game interface and casts requested through REST.
| Event | Meaning |
|---|---|
spell.begin | A delayed spell began. Includes slot, optional name, and total_lines. |
spell.chant | One visible chant line was submitted. Includes line and total_lines. |
spell.cast | The final spell use was submitted. Includes any retained arguments. |
spell.cancelled | A delayed spell ended without a final cast. |
spell.succeeded | The game confirmed a submitted spell by name. |
spell.failed | The game reported that a submitted spell failed or was rejected. |
spell.received | Another player, Mundane, or monster cast or attacked with a spell on this character. |
An instant spell normally produces only spell.cast. A delayed spell normally
produces spell.begin, one or more spell.chant events, and then spell.cast.
Cancellation source is client, server, or replaced. Starting another
spell while chanting is allowed. daRPC reports the old spell as replaced before
it reports the new spell’s begin or cast event, so the two casts do not appear
to overlap.
The final cast can retain one of these argument forms:
target: object ID, available name, and coordinates
input: submitted text
values: bounded numeric values used by less common spell types
unknown: the argument could not be classified
Spells with no arguments omit this field. Target names are best-effort daemon enrichment. The object ID and coordinates come from the observed submission.
is_casting in character status follows the same ordered begin,
cast, and cancellation events.
Cast results
spell.cast means the client submitted the spell. It does not by itself mean
the server accepted the result. The daemon keeps up to 256 recent submissions
for each connected DLL instance and compares later system feedback with that
queue. A submission expires after five seconds.
A named success such as You cast Mist matches the oldest queued cast with
that spell name. Generic failures match a cast only when exactly one submission
is pending. When several submissions are pending, the feedback does not contain
enough information to prove which cast failed, so the daemon discards the
ambiguous candidates and emits only the original message.system event. The
queue is held only in memory and is cleared when the DLL disconnects or the
daemon restarts.
The system message You failed to concentrate. matches a queued Fas Spiorad
cast by name and produces spell.failed with reason failed.
spell.succeeded and spell.failed retain the submitted slot, available
spell name, and cast arguments. They also include:
feedback: original system feedback
submitted_tick_ms: client tick when spell.cast was observed
elapsed_ms: wrapping millisecond difference to the feedback
Failure reason is one of:
failed, error, resisted, already_active, conflicting_effect
For a conflicting curse, active_spell contains the spell named by the game
when available. The attempted spell remains in name.
spell.received does not need a matching local submission. It contains the
reported caster, spell name, and a kind of cast or attack. If that
caster is still visible, caster_object supplies the current world object as
best-effort context. Friendly feedback uses the game’s “cast … spell on you”
form, while harmful feedback uses its “attacks you with … spell” form.
The original message.system is still retained and broadcast. Semantic spell
events add useful structure without hiding the text that appeared in the game.
Spellbook events
| Event | Meaning |
|---|---|
spell.added | A learned spell appeared in a slot. |
spell.removed | A spell left a slot. |
spell.changed | A spell moved or its retained details changed. |
spell.cooldown | A retained spell entered or restarted cooldown. |
spell.ready | A retained spell left cooldown and is ready to cast. |
Spellbook changes use batch_index, batch_count, slot, before, and
after. Moving or swapping spells can create several frames in one batch. The
daemon applies the full batch before it broadcasts the first frame.
spell.changed is not emitted for a cooldown-only transition. A live
action-delay packet supplies cooldown_ms and remaining_ms on
spell.cooldown. On late attach those fields remain absent, but daRPC polls the
active slot until it can emit spell.ready. Both cooldown events include
observation, the one-based slot, and the spell name when known.
Availability
spells: null means the spellbook was unavailable. An empty array means it was
read successfully and no occupied spell slots were found.
Effects
Effects are the small timed spell icons shown by the client. Players often recognize their remaining duration by the color of the bar rather than an exact number of seconds. daRPC exposes the same relative stages.
| Use | Route or events |
|---|---|
| Read active effects | GET /clients/{client}/effects |
| Watch effect changes | Persistent effect events |
Reading effects
curl "http://127.0.0.1:2626/clients/ZiLo/effects"
Each active effect contains:
icon, which identifies the displayed effectduration, which is a relative remaining-time band
Effects {
observation: ObservationMetadata,
effects: Effect[]?,
}
Effect {
icon: u16,
duration: EffectDuration,
}
From longest to shortest, the duration values are:
white, red, orange, yellow, green, blue
These values are not exact timers. The client retains the visible phase, so daRPC does not invent a remaining number of seconds.
The initial baseline reads the client’s ten effect slots. Later effect updates keep the retained resource current without another complete memory capture.
Effect events
The complete payload structures are in Persistent effect events.
| Event | Meaning | Data |
|---|---|---|
effect.added | A new icon became active. | icon, duration |
effect.changed | An active icon moved to another duration band. | icon, new duration |
effect.removed | The icon expired or was cleared. | icon |
Effects are identified by icon. A removed event has no duration because the effect is no longer active.
Availability
effects: null means the effect slots were unavailable. An empty array means
the slots were read successfully and no effects were active.
World
World data describes the current map and the objects this client can see. It is a client-sized view of the world, not a permanent list of everything on the map.
| Use | Route or events |
|---|---|
| Read map and self position | GET /clients/{client}/status |
| Read visible objects | GET /clients/{client}/objects |
| Read one cached visible player by name | GET /clients/{client}/players/{player} |
| Refresh one visible player | POST /clients/{client}/players/{player}/inspect |
| Watch objects and visuals | World object events |
Map and position
The current map is part of:
curl "http://127.0.0.1:2626/clients/ZiLo/status"
It includes the map ID, available name, zero-based x/y coordinates, width, and height.
Ordinary movement acknowledgements update the character’s absolute x/y position. A refresh or server correction can also replace it with an authoritative position.
A map change arrives in two parts. The client first receives the new map
identity and size, then receives the character’s position on that map. daRPC
holds the first part until the position arrives and publishes both together in
one location.changed event. It does not expose a new map with coordinates
left over from the previous map. The stream publishes location.changed first,
then one disappearance event for each object retained from the previous view.
Visible objects
curl "http://127.0.0.1:2626/clients/ZiLo/objects"
The response can contain four object kinds:
| Kind | Available data |
|---|---|
player | ID, optional name, x/y, direction, is_hidden, is_solid, optional visual, and optional inspected profile |
monster | ID, optional sprite, x/y, direction, and is_solid |
mundane | ID, optional name and sprite, x/y, direction, and is_solid |
item | ID, sprite, dye_color, x/y, per-tile z_index, and is_solid |
Mundane is the Dark Ages name for a non-player character (NPC). The npc
filter remains accepted as an alias. Item sprite values have the client’s
internal item classification flag removed.
Players and mundanes are always solid, and items are never solid. Monsters use the draw packet’s creature type: type 0 is solid and type 1 is passable.
Ground-item z_index is local to one tile. Zero is the bottom item, and higher
values are drawn above it.
WorldObjects {
observation: ObservationMetadata,
objects: WorldObject[]?,
}
WorldObject =
Player { id, name?, x, y, direction, is_hidden, is_solid, visual?, profile? }
| Monster { id, sprite?, x, y, direction, is_solid }
| Mundane { id, sprite?, name?, x, y, direction, is_solid }
| Item { id, sprite, dye_color, x, y, z_index, is_solid }
Filter the result with a comma-separated types query:
curl "http://127.0.0.1:2626/clients/ZiLo/objects?types=player,mundane,monster"
Without types, the route returns every observed kind. An unknown type or
malformed filter returns 400 Bad Request.
Player visuals
Opcode 0x33 supplies a visual block for each drawn player. A normal player
uses form: "human" and exposes every sprite used by the client renderer:
head, body, arms, boots, pants, armor, weapon, shield, overcoat, and three
accessories. hair_color and skin_color are top-level visual fields, alongside
the boots, pants, overcoat, and accessory dye colors. The block also includes
gender, rest position, face shape, and translucency flag used by the renderer.
HumanVisual {
form: "human",
gender,
head_sprite, body_sprite, arms_sprite, boots_sprite, pants_sprite,
armor_sprite, weapon_sprite, shield_sprite, overcoat_sprite,
accessory1_sprite, accessory2_sprite, accessory3_sprite,
hair_color, skin_color, boots_color, pants_color, overcoat_color,
accessory1_color, accessory2_color, accessory3_color,
rest_position, face_shape, is_translucent,
}
CreatureVisual {
form: "creature",
sprite, color, boots_color, pants_color,
}
A transformed player uses form: "creature" and exposes the creature sprite
plus the three color bytes carried by that packet layout. Creature-form draws
are not treated as hidden. visual: null means the player was synthesized from
partial retained state before a complete packet or memory appearance was
available; no sprite or color defaults are invented.
Player profiles
When opcode 0x33 draws a human, daRPC automatically requests that player’s
object information. The successful response fills profile with nation, title,
guild rank, display class, guild, user state, is_group_open, worn equipment,
legend marks, and inspected_tick_ms. profile: null means the player is
visible but the inspection has not completed.
is_hidden is true when the draw has a zero body sprite or the packet marks
the player translucent. Hidden draws can zero other fields, so daRPC merges
them by entity ID and retains the last observed name plus inspected profile.
Monster-form draws use the creature visual layout and are not classified as
hidden merely because they do not contain the normal human appearance block.
The profile equipment uses the same slot, sprite, and dye-color names as local
equipment. Other-player packets do not provide item names or durability, so
those fields are not invented. display_class is separate from the base class;
for example, it can be Summoner for a Wizard.
Automatic requests do not open the game’s other-player information pane. A
normal player click still opens it and also refreshes daRPC’s cache. Leaving
view removes the player object, and the next 0x33 redraw starts a fresh
inspection.
Read one visible player’s retained object and latest profile without sending a packet to the game server:
curl "http://127.0.0.1:2626/clients/ZiLo/players/Eidolon"
The cached lookup is case-insensitive and returns the same WorldObject shape
as the objects collection. It can return profile: null while the automatic
inspection is pending. It searches only the current visible-object set; daRPC
does not retain a historical profile after that player leaves view.
Use a manual refresh for changes that do not redraw the player, such as a belt or necklace change:
curl -X POST "http://127.0.0.1:2626/clients/ZiLo/players/Eidolon/inspect"
Both player-name routes use the same case-insensitive visible-player lookup.
Missing names return 404 and ambiguous names return 409. Only the refresh
route can return 504 when the game server does not respond.
The initial baseline walks the client’s retained object collection. A creature name or numeric sprite can be unavailable after a late attach when the client no longer retains the original draw details. Pressing the normal client refresh key asks the server to redraw nearby objects. daRPC reconciles those packets against the retained visible-object set. Numeric creature sprites learned from the redraw are retained through the follow-up snapshot.
Object events
The complete object and visual payload structures are in World object events.
Players, monsters, and Mundanes publish the same core object actions. Players also publish two events for identity and profile changes:
| Object | Events |
|---|---|
| Player | player.appeared, player.replaced, player.inspected, player.disappeared, player.moved, player.direction_changed |
| Monster | monster.appeared, monster.disappeared, monster.moved, monster.direction_changed |
| Mundane | mundane.appeared, mundane.disappeared, mundane.moved, mundane.direction_changed |
player.replaced is emitted instead of player.appeared when a newly drawn
player has the same name as one or more retained players but a different
object ID. Its payload contains every stale player snapshot in previous and
the authoritative replacement in current.
Ground items use:
item.appeared
item.disappeared
item.moved
Each object event carries the complete public object after the change.
Treat appeared, moved, and direction-changed events as upserts by stable object
ID. A redraw can publish an appeared event with replacement fields for an ID
that is already retained. Disappearance carries the last retained object and
removes that ID. player.replaced removes every ID in previous and upserts
current; player.inspected upserts player when profile state is retained.
Map changes remove the previous view through ordinary disappearance events.
Consumers update the map on location.changed, then apply the following object
events in delivery order. They do not clear the collection in response to the
location event or a separate world-boundary event.
An F5 or POST /clients/{client}/resync refresh also uses normal lifecycle
events. daRPC retains the current set while redraw packets arrive, suppresses
unchanged stable IDs, publishes appearances for new IDs, and publishes
disappearances for retained IDs that do not return. Reconciliation completes
when RefreshUserOK follows redraw activity or when the one-second refresh
window closes. No authoritative position or redraw response leaves the
last-known view intact. client.resync reports only that the request was sent.
The matching client.resync_completed is ordered after the lifecycle changes
and closes the refresh window whether or not RefreshUserOK arrived. See
Refresh and resynchronization for the complete behavior.
A concurrent GET /objects returns the retained, progressively reconciled view
rather than an intentionally empty intermediate collection.
The server normally sends draw events for objects entering view but may not send an explicit removal when the local character simply walks out of range. After accepted self movement, daRPC culls retained objects outside the client-sized view and reports their disappearance. The collection is still this client’s latest observation rather than an authoritative map population.
Entity visual events
The stream also reports temporary visuals for visible players, monsters, and Mundanes:
player.animated monster.animated mundane.animated
player.effect monster.effect mundane.effect
player.damaged monster.damaged mundane.damaged
An *.animated payload contains the complete entity, the client animation
number, and initial_duration_ms. That timer is the initial value sent by the
server, not a promise that the animation remains visible for exactly that long.
An *.effect payload contains the entity and the one-based effect number
sent by the server. It can also contain a source entity and a frame interval
when the packet supplies them. Effects drawn only at ground coordinates are
not published yet.
An *.damaged payload contains the entity and health_percent, the server’s
0 through 100 value used for the temporary health meter. It is a percentage,
not the amount of damage dealt.
Looking at tiles
daRPC exposes the game’s Look and FarLook requests as typed asynchronous actions. Their result text can reveal the server-provided names of NPCs and ground items without retaining or displaying the response as a native popup.
Look at the tile directly ahead:
POST /clients/Eidolon/look
Look at a tile on the character’s current map:
POST /clients/Eidolon/far-look
Content-Type: application/json
{"position":{"x":40,"y":19}}
FarLook coordinates are zero-based nonnegative integers. They must fit the game’s unsigned 16-bit wire fields and the current map bounds. FarLook cannot select another map.
Both routes return the usual native command status. The command_id correlates
the command with a later look.result Server-Sent Events (SSE) frame:
event: look.result
id: 100
data: {"type":"look_result","data":{"observation":{"pid":6864,"instance_id":"...","revision":224,"event_sequence":100,"tick_ms":559274097},"command_id":7,"target":{"kind":"tile","x":40,"y":19},"text":"Light Belt\tLight Belt\tfior sal"}}
observation supplies the usual source, revision, and event-ordering metadata.
target includes x and y for both routes. Its kind is ahead for Look and
tile for FarLook. The DLL resolves an ahead target from its confirmed position
and facing when it submits the native packet. text preserves the
server-provided dialog text, including its separators.
The DLL intercepts only a bounded popup response while a typed look command is pending. It publishes the exact text through the normal ordered event path and suppresses that popup before the original client dispatcher runs. It does not open and dismiss the dialog afterward. Unrelated message dialogs and popups continue through the normal client behavior.
The game response contains no request identifier. Only one typed Look or
FarLook request may therefore be pending for a client; another request fails
with rejected until the first completes, expires, or is cancelled. A result
is transient and is not part of the retained client snapshot. Subscribe to
GET /clients/{client}/events before submitting the request when the result
must not be missed.
Movement
daRPC exposes the game client’s stock movement and a narrow exact-route control surface. It does not replace or improve the client’s native planner. An external bot that needs obstacle avoidance, group-aware costs, or reliable replanning should own those decisions and submit short exact routes.
Consumer surface
| Need | HTTP or SSE interface |
|---|---|
| Current map, position, walking flag, and planned route | GET /clients/{client}/status |
| Visible players, creatures, NPCs, and their positions | GET /clients/{client}/objects |
| Current group state and members | GET /clients/{client}/group |
| Static map bytes for an external planner | GET /maps/{map_id}/download |
| One cardinal step, a stock destination walk, or an exact route | POST /clients/{client}/walk |
| Stop the current route | DELETE /clients/{client}/walk |
| Ordered movement and world updates | GET /clients/{client}/events |
Coordinates are zero-based. Read the current map ID, dimensions, and position from status before submitting movement.
Choosing a movement mode
One step
Submit one direction when the controller wants to drive the character one tile at a time:
{"direction":"north"}
The DLL calls the stock walk helper. A rejected step returns a command failure.
If the helper accepts the step but the character never reaches the adjacent
tile, walking.stopped reports obstructed.
The client predicts each step until its visual transition commits. A second direct step submitted during that transition is rejected so it cannot overlap the prediction. Submit it again after the position update or use a destination route, which the client can queue safely.
Stock destination
Submit a destination to ask the client’s built-in planner to build and execute its normal ground route:
{"destination":{"x":120,"y":85}}
This mode is intentionally vanilla. daRPC does not change native collision
answers, add player or monster exclusions, retry a rejected edge, or rebuild a
stalled route. Use it when the game’s ordinary shortest-path behavior is good
enough. A valid tile with no native path reports no_path.
When a destination replaces a route during an active step, the DLL builds from that step’s staged destination and leaves the replacement queued. The client’s normal step-completion callback commits the staged tile and starts the queued route. Repeated replacements update that queue without starting another prediction early.
Exact route
Submit a map-tagged list of absolute tiles when an external planner owns the route:
{
"route": {
"map_id": 3001,
"tiles": [
{"x":11,"y":22},
{"x":12,"y":22},
{"x":12,"y":23}
]
}
}
The route must:
- contain 1 through 256 tiles;
- start at the character’s current confirmed position, or at the staged destination of an active step;
- stay on the stated current map and inside its dimensions;
- use unique tiles connected by cardinal one-tile edges; and
- pass both native collision checks at submission time.
The native self object has separate committed and staged positions during a visual step. The DLL validates an idle route against the committed position and an active-step replacement against the staged destination. The packet-confirmed position must match that effective origin. This allows an acknowledged step to finish visually without treating its older committed tile as a desynchronization.
That distinction explains most observed packet/native differences. After an
acknowledgement, packet state can already be at the staged tile while the
object’s committed tile remains one step behind until animation completion.
This is healthy when transition_active is true and staged position matches the
packet. A persistent mismatch was produced when an overlapping prediction was
calculated from the older committed tile and then installed after the client
committed the prior step. Exact-route deferral and direct-step rejection prevent
that sequence. A server correction can create another temporary mismatch until
the following authoritative position refresh completes.
Validation is transactional. Map, position, transition, tile, edge, or
collision rejection leaves the route already executing in the client and all
daRPC destination and walking tracking unchanged. Only a fully validated and
installed route emits walking.stopped with reason replaced, updates route
tracking, and emits walking.route_changed, in that order.
An accepted active-step replacement is placed in the native route vector but is not advanced immediately. The normal client step-completion callback first commits the staged tile and then advances the replacement. Repeated replacements therefore cannot start overlapping predictions.
The DLL places the validated route into the client’s native route vector and starts its normal walker. Animation, packets, acknowledgements, and pacing remain client-owned. Route injection is not a teleport and does not bypass the live step validator.
If a later exact-route edge is rejected, daRPC emits
walking.obstructed, then walking.stopped with reason obstructed,
and clears the exact route. It does not retry or replan.
When the server sends a confirmed position correction, daRPC stops and clears an external exact route immediately. The stock client requests its authoritative position when the correction differs from the local object, so normal recovery does not require F5. Wait for the resulting location update and replan. F5 remains a manual fallback if the client does not complete that refresh.
Resynchronizing during movement
Physical F5 and POST /clients/{client}/resync use the same synchronization
path. When a scheduled refresh reaches the front of that path, daRPC clears the
queued route, but it does not interrupt a step the client has already accepted.
If that step’s visual transition is active, daRPC waits for its staged
destination to become the committed native position before sending refresh
packet 0x38. This avoids asking the server to redraw while the client still
exposes the prior committed tile.
The HTTP response can arrive during this wait. client.resync marks the later
packet submission. The correlated client.resync_completed means daRPC closed
the refresh window after RefreshUserOK or the one-second fallback. A movement
consumer should wait for the matching completion event before submitting its
next walk. It does not need to measure the animation duration, poll native
state, or clear and rebuild world objects.
Server-driven correction refreshes remain immediate and do not enter this deferred user-request path. See Refresh and resynchronization for coalescing, response fields, fallback behavior, and object reconciliation.
Cancelling movement
DELETE /clients/{client}/walk resets the stock route, clears route
telemetry, and emits walking.stopped with reason cancelled when a walk
was active. The direct CLI equivalent is:
darpc walk --pid <pid> cancel
The reset cannot revoke a step the client has already accepted. A final
location.changed can therefore arrive after cancellation. Replan from the
latest confirmed position rather than the position reported by the cancel
response.
Replacing an active walk with a destination or route emits reason replaced.
A direct step submitted during an active visual transition is rejected and
leaves the current movement intact. Turning while a walk is active emits reason
cancelled.
Cancelling a queued command through
DELETE /clients/{client}/commands/{command_id} is different. It prevents a
command that has not begun from executing; it does not stop an already active
route.
Recommended external-planner loop
- Read status, objects, and group state. Download and cache the raw map when the map ID changes.
- Build the planner’s own cost field. Static map collision can be combined with temporary dynamic costs from visible objects.
- Plan from the latest confirmed position. A controller can treat creature tiles and nearby safety margins as blocked or expensive, prefer proximity to group members, and give unrelated players a smaller cost. Those policies belong to the controller because they depend on its goal and risk tolerance.
- Submit a short exact-route prefix. Short prefixes reduce the amount of work invalidated when an object moves.
- Replace the saved route whenever
walking.route_changedarrives. Update the start position fromlocation.changed. - On a terminal event, apply the reason-specific recovery below. Never wait indefinitely for the same route to recover itself.
A useful starting segment length is 4 through 16 edges. The best value depends on map density and how quickly the controller receives object updates.
Action source
Movement, turns, and planned routes expose a tagged source:
{ "kind": "unknown" }
{ "kind": "client" }
{ "kind": "command", "command_id": 41 }
command means the action occurred while the DLL executed that exact daRPC
command. client means it originated inside the game client outside command
execution. This includes physical keyboard or mouse input, synthetic Windows
input, native client behavior, and other injected tools, so it must not be
treated as proof of a human action. unknown is used when observation began
after an action was already active or the origin could not be retained.
Status exposes character.movement_source. It is null while idle and carries
the retained source for an active movement episode. A walking.stopped source
describes the movement episode that ended, not necessarily the action that
caused it to stop. For example, turning can cancel command-originated movement,
but the stop event still identifies that movement command.
Stop reasons
walking.stopped contains:
walking.stopped {
observation: EventObservation,
source: ActionSource,
current: TilePosition,
destination: TilePosition?,
reached_destination: bool?,
reason: completed | obstructed | replaced | cancelled | position_corrected,
}
| Reason | Meaning | Suggested controller action |
|---|---|---|
completed | The observed walk ended normally. If a destination is known, reached_destination says whether the final tile matches it. | Confirm the current position and submit the next segment if needed. |
obstructed | The walk ended before reaching its known destination, including a rejected edge or an accepted direct step that made no progress. | Penalize walking.obstructed.attempted when that event is present, otherwise use destination, then replan from current. |
replaced | A different route or movement command superseded this walk. | Track the replacement command and discard the old plan. |
cancelled | Movement was explicitly reset or cancelled. | Stop unless the controller deliberately requested cancellation as part of replanning. |
position_corrected | The server corrected the character position while walking. | Discard the route and reread status before planning again. |
destination and reached_destination can be null for movement initiated
inside the game when no reliable destination was observed.
Route and obstruction events
planned_route in status is the latest observed client route:
planned_route {
source: ActionSource,
generation: u32,
tiles: Vec<TilePosition>,
}
walking.route_changed carries the same fields. Tiles are absolute and
ordered from the current tile toward the goal. A new native build advances the
generation. Confirmed movement consumes tiles from the front without changing
the generation. An empty tile list is the authoritative cleared route.
walking.obstructed reports:
walking.obstructed {
observation: EventObservation,
source: ActionSource,
map_id: u32,
current: TilePosition,
attempted: TilePosition,
direction: north | east | south | west,
destination: TilePosition?,
mode: direct | native_route | exact_route | pursuit,
}
The DLL reports the rejection but does not modify native routes or pursuits. Only a failed externally installed exact route is reset automatically.
Stream recovery and map changes
SSE is ordered per client. If the consumer receives
stream.resync_required, it must reread every resource it uses before
planning again. At minimum, reread status, objects, and group state.
Never submit one exact route across two maps. End the first segment on the warp
tile, wait for the atomic location.changed event containing the new map and
entry position, refresh the map and world inputs, and plan a new segment.
Command completion
The HTTP command response reports whether the main-thread operation completed, failed, or remains queued. It does not prove that a multi-step walk later reached its destination. Use ordered location, route, obstruction, and stopped events for the movement outcome.
See Web API for command status and timeout behavior, and Events for stream ordering and recovery.
An exact-route invalid_state response includes diagnostics with the route,
packet, and native map IDs; packet, committed native, and staged positions;
transition-active state; current route mode; and current destination. Missing
values are null. Use the reason field to distinguish map transition,
unavailable native state, map mismatch, position mismatch, and unavailable map
dimensions. A rejected replacement has not changed the route reported by these
fields.
Emotes
daRPC can play the same character expressions exposed by the normal client UI.
curl --request POST \
--header "Content-Type: application/json" \
--data '{"name":"wave"}' \
"http://127.0.0.1:2626/clients/ZiLo/emote"
Names are case-insensitive. The confirmed names are:
| Ctrl shortcut | Name | Code | Ctrl+Alt shortcut | Name | Code |
|---|---|---|---|---|---|
| Ctrl+1 | smile | 0 | Ctrl+Alt+1 | rock | 25 |
| Ctrl+2 | cry | 1 | Ctrl+Alt+2 | scissors | 26 |
| Ctrl+3 | sad | 2 | Ctrl+Alt+3 | paper | 27 |
| Ctrl+4 | wink | 3 | Ctrl+Alt+4 | oof | 28 |
| Ctrl+5 | stunned | 4 | Ctrl+Alt+5 | speechless | 29 |
| Ctrl+6 | raz | 5 | Ctrl+Alt+6 | blue | 30 |
| Ctrl+7 | surprise | 6 | Ctrl+Alt+7 | blush | 31 |
| Ctrl+8 | sleepy | 7 | Ctrl+Alt+8 | heart | 32 |
| Ctrl+9 | yawn | 8 | Ctrl+Alt+9 | sweat | 33 |
| Ctrl+0 | kiss | 12 | Ctrl+Alt+0 | sing | 34 |
| Ctrl+- | wave | 13 | Ctrl+Alt+- | ack | 35 |
You may provide a numeric client code instead, for example {"code":13}.
Numeric codes also keep the unnamed Alt-only expressions available. A code must
be one exposed by the client UI: 0 through 8 or 12 through 35.
The HTTP response reports whether the main-thread command ran. An observed
request also produces character.emoted with the numeric code.
Messages
daRPC normalizes recent chat and system messages so a tool does not need to parse the punctuation the client uses for each channel.
| Use | Route or events |
|---|---|
| Read recent messages | GET /clients/{client}/messages |
| Send a message | POST /clients/{client}/messages/send |
| Send an internal message | POST /messages/send |
| Filter retained history | channels, since, skip, and count |
| Watch new messages | Message events |
Reading recent messages
curl "http://127.0.0.1:2626/clients/ZiLo/messages"
Each message contains:
timestamp, formatted as ISO 8601 in the daemon’s local time and UTC offset- Optional
tick_ms, the client’s wrapping Windows millisecond tick channel- Optional
senderandrecipient - Cleaned game message
text, or an internal messagepayloadobject
Messages {
messages: Message[],
}
Message {
timestamp: string,
tick_ms: u32?,
channel: MessageChannel,
sender: string?,
recipient: string?,
text: string?,
payload: object?,
}
Retained history uses one of these channels:
say, shout, whisper, guild, group, system, world, internal
Spell chants are transient message.chant SSE events and are intentionally not
stored by /messages. This keeps spell and NPC command chants from crowding
ordinary conversation history.
Whisper packet type is authoritative when the server returns an error without
the usual name> or name" formatting. Such records remain whisper; sender
and recipient are absent when no participant can be extracted.
Channel markers and participant punctuation shown by the game are removed from
the text. Empty messages are ignored. A world shout is stored once as world,
even though the client also renders a duplicate shout-form message.
Sending messages
Send nearby speech, shouts, guild chat, group chat, or a whisper through the selected client:
curl -X POST "http://127.0.0.1:2626/clients/ZiLo/messages/send" \
-H "content-type: application/json" \
-d '{"channel":"whisper","recipient":"Eidolon","content":"hello"}'
The request body has channel, optional recipient, and content fields.
channel must be say, shout, guild, group, or whisper. A whisper
requires a recipient; every other channel rejects one. Content must contain
from 1 through 100 ASCII characters. Whisper recipients must contain from 1
through 15 ASCII characters without whitespace.
Guild and group messages use the game’s directed-message packet with the
special recipients ! and !!, respectively. Callers select guild or
group; they do not supply those markers as whisper recipients.
Internal messages
Internal messages travel only inside darpcd.exe. They are not sent to the
game client, DLL, or game server. Send one to a connected in-game character by
name:
curl -X POST "http://127.0.0.1:2626/messages/send" \
-H "content-type: application/json" \
-d '{"channel":"internal","recipient":"Eidolon","payload":{"action":"ready"}}'
Omit recipient to deliver to every connected daRPC client. A broadcast with
no connected clients succeeds with {"delivered":0}. A named recipient that
does not exist returns 404; duplicate active names return 409.
Provide exactly one of content or payload. payload must be a JSON object.
content accepts nonempty Unicode text without the game’s 100-character limit
and is delivered as {"content":"..."} inside payload. The API’s bounded
4 KiB request-body limit still applies. Internal records omit tick_ms and
text, use channel: "internal", and appear only in daRPC REST history and
SSE streams.
Filtering and paging
Messages are sorted newest first. The route returns 20 records by default.
| Query | Meaning |
|---|---|
channels | Comma-separated channels, such as say,shout. |
since | Only messages strictly newer than this ISO 8601 timestamp. |
skip | Skip this many matching records after sorting. Default 0. |
count | Return at most this many records. Default 20, maximum 100. |
Example:
curl "http://127.0.0.1:2626/clients/ZiLo/messages?channels=say,shout&since=2026-08-02T15:00:00-04:00&skip=0&count=20"
since is optional. When it is omitted, the route searches the retained
history without a time boundary.
Live message events
The complete message payload and stream behavior are in Message events.
Each channel has its own SSE routing name:
message.say
message.shout
message.chant
message.whisper
message.guild
message.group
message.system
message.world
message.internal
All nine routes use the JSON discriminator type: "message". The channel is
inside data.channel. Separate SSE names let a browser subscribe only to the
channels it cares about.
Message events do not contain the common state observation object. The SSE
id still provides ordering, and the subscription path identifies the client.
The daemon adds normal chat and system messages to REST history before
broadcasting them. It broadcasts chants without retaining them.
Some system messages also confirm spell results or reject a ground-item pickup.
In those cases the stream contains both message.system and a semantic spell
event or item.pickup_failed. The original message remains available for
display and debugging. See Spells for spell
correlation behavior.
Retention
The daemon keeps at most 4,096 messages and 1 MiB of message text and payload per DLL instance. It removes the oldest messages first. History is held in memory and is cleared when the daemon restarts or a new DLL instance replaces the old one.
If an SSE connection is interrupted, read /messages with a suitable since
value to recover recent conversation context. Chants and state events from
before the subscription are not replayed.
Privacy
Message history can contain private whispers. daRPC does not write message text to its normal logs, but any local program with access to the loopback API can read retained messages. Run only consumers you trust.
NPC dialogs
daRPC can observe and interact with the merchant and pursuit windows used by Mundanes. This includes ordinary conversation choices, text prompts, shop lists, inventory pickers, and spell or skill pickers.
Dialog actions use the same native client methods as the game interface. They run on the client main thread, update the visible window normally, and preserve the client’s response-pending behavior.
Read the current dialog
curl "http://127.0.0.1:2626/clients/ZiLo/dialog"
The response contains normal observation metadata and either the current
dialog or null:
{
"observation": {
"pid": 6076,
"instance_id": "890b3755fccd8d45b165bed41165457a",
"revision": 42,
"event_sequence": 38,
"captured_tick_ms": 16209995,
"updated_tick_ms": 16210012,
"capture_duration_us": 2548,
"world_generation": 3,
"lifecycle": "in_game"
},
"dialog": {
"revision": 7,
"kind": "pursuit",
"target": { "id": 4172 },
"speaker": {
"name": "Beggar",
"sprite": 31,
"sprite_type": "creature",
"color": 0,
"show_graphic": true
},
"content": "Can you spare a moment?",
"response_pending": false,
"navigation": {
"previous": false,
"next": true,
"close": true
},
"interaction": {
"type": "choices",
"data": [
{ "index": 0, "text": "Yes." },
{ "index": 1, "text": "Not now." }
]
}
}
}
kind is merchant or pursuit. These names describe the two client dialog
families, not only shops and quests. A merchant-family dialog can also ask for
text or show player-owned items, spells, and skills.
The speaker sprite has its item or creature marker removed. sprite_type
preserves which kind of graphic the server supplied. show_graphic tells you
whether the game requested the portrait area.
Interaction types
The interaction.type field tells a controller which response, if any, the
current page accepts:
| Type | Meaning |
|---|---|
message | Informational page with navigation or close actions. |
choices | Select one zero-based row from data. |
input | Submit text using the supplied byte limit and optional surrounding text. |
items | Select a server-provided item row. Some rows include a price, description, or available quantity. |
inventory | Select one of the character’s inventory rows. |
spells | Select a spell row. |
skills | Select a skill row. |
protected | A client-managed protected form. It can be observed but not automated. |
unsupported | The page is retained for observation, but daRPC does not know how to answer it. |
The fields in a row depend on what the server supplied. Optional values can be
missing. A slot is one-based when present, while a displayed index is
always zero-based.
Start a conversation
curl --request POST \
--header "Content-Type: application/json" \
--data '{"target":"Beggar"}' \
"http://127.0.0.1:2626/clients/ZiLo/interact"
Use the visible Mundane’s case-insensitive name or object ID. For example, the same request can select an object by ID:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"target":4172}' \
"http://127.0.0.1:2626/clients/ZiLo/interact"
The target must be a Mundane in the current /objects state. This route does
not synthesize a click. It invokes the client’s normal world-object interaction
method on the main thread.
Answer a dialog
Every dialog action includes the revision returned by the current dialog.
This prevents an answer intended for one page from being applied after the
server has replaced it.
Select a choice, item, inventory entry, spell, or skill:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"revision":7,"index":0,"quantity":1}' \
"http://127.0.0.1:2626/clients/ZiLo/dialog/select"
quantity defaults to 1. It is checked against the current row when the
server supplied a limit.
Submit an input prompt:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"revision":8,"input":"ZiLo"}' \
"http://127.0.0.1:2626/clients/ZiLo/dialog/input"
Input must be nonempty ASCII text and must fit the current dialog’s byte limit.
Use the current navigation controls:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"revision":8}' \
"http://127.0.0.1:2626/clients/ZiLo/dialog/previous"
curl --request POST \
--header "Content-Type: application/json" \
--data '{"revision":8}' \
"http://127.0.0.1:2626/clients/ZiLo/dialog/next"
curl --request POST \
--header "Content-Type: application/json" \
--data '{"revision":8}' \
"http://127.0.0.1:2626/clients/ZiLo/dialog/close"
Each accepts the same small revision body. The requested button must be
available in navigation. A dialog waiting for
the server has response_pending: true; further answers are rejected until a
new page arrives. Close remains available when the client permits it.
Follow the current page
NPC conversations are server-driven. After every action, read /dialog again
and respond to the new interaction and revision. Do not assume that every
shop, pursuit, or character sees the same sequence of pages.
A typical purchase works like this:
- Start the conversation with
/interact. - Select the Buy choice from the returned
choicespage. - Read the new
itemspage. Each row identifies its displayedindexand can include its name, description, price invalue, and available quantity. - Submit the chosen row with its current revision and desired quantity.
- Confirm the result through
/items,/status, and the event stream.
For example, selecting one item from revision 12 looks like this:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"revision":12,"index":1,"quantity":1}' \
"http://127.0.0.1:2626/clients/ZiLo/dialog/select"
Selling commonly adds a confirmation page:
- Select Sell from the shopkeeper’s opening
choicespage. - Read the returned
inventoryrows. Each row maps a displayed zero-basedindexto a one-based inventoryslot. - Select the row and quantity. The server can replace it with a
choicespage containing the offered price. - Read that new page and revision, then select Yes or No by its displayed index.
- Confirm that the inventory and gold state changed as expected.
The exact pages and wording belong to the game server. daRPC exposes what the client is currently showing instead of assigning special meanings to choice indexes.
Revisions and errors
Dialog revisions are wrapping nonzero u32 values scoped to one loaded DLL
instance. They increase when a dialog opens, changes, is submitted, or closes.
They do not need to be consecutive in an application.
A stale revision returns 409 Conflict with stale_dialog. Other useful
conflict codes are dialog_unavailable and dialog_pending. A response that
does not fit the current page returns 400 Bad Request with
invalid_dialog_action.
The daemon validates the revision first, then the DLL validates it again on the client main thread. The second check closes the small race where the server could replace a page while an HTTP command was waiting in the queue.
Normal action responses use the shared command model. 200 OK means the native
call reached a final state during the HTTP wait. 202 Accepted means it is
still queued. See Using daRPC.
Live dialog events
The client event stream publishes four dialog events:
dialog.opened
DialogOpened {
observation: EventObservation,
dialog: DialogState,
}
dialog.changed
DialogChanged {
observation: EventObservation,
dialog: DialogState,
}
dialog.submitted
DialogSubmitted {
observation: EventObservation,
previous_revision: u32,
dialog: DialogState,
submission: DialogSubmission,
}
dialog.closed
DialogClosed {
observation: EventObservation,
previous: DialogState?,
reason: client | server | world_changed | disconnected | replaced,
}
DialogSubmission has one of these JSON forms:
{ "action": "select", "index": 0, "quantity": 1 }
{ "action": "input", "input": "ZiLo" }
{ "action": "previous" }
{ "action": "next" }
{ "action": "close" }
dialog.submitted describes a response sent through daRPC and includes the new
pending state. The following server page normally produces dialog.changed.
Actions made directly through the game interface still produce the resulting
changed or closed state, but are not labeled as daRPC submissions.
After stream.resync_required, reread /dialog along with any other resources
your tool uses. A fresh DLL connection includes the dialog cache in its initial
snapshot boundary.
Timing and late attach
Incoming dialog packets are copied after the client accepts them. Parsing, serialization, IPC, and web publication happen away from the hook. Dialog packets use their own fixed-capacity storage so they cannot consume the main high-volume event queue.
If daRPC is injected while a dialog is already open, it has not seen the packet
that created that page. /dialog can remain null until the existing window
closes or the server sends another dialog page. Open the conversation again
when a late-attached tool needs a complete baseline.
Message dialogs
Message dialogs are the small native windows opened by actions such as
sense and look. They are WindowMessageDialogPane instances, not
merchant or pursuit dialogs, and do not use the NPC dialog model.
Reading current dialogs
GET /clients/{client}/message-dialogs
The response contains observation metadata and a state with a wrapping
revision and a dialogs array. Each dialog has an opaque id, nullable
text, and a truncated flag. IDs contain no client addresses. Text is
capped at 4096 client bytes. The daemon coalesces concurrent reads and may
reuse a serialized snapshot for up to 250 milliseconds after it is received.
Dismissing a dialog
POST /clients/{client}/message-dialogs/dismiss
{"revision":7,"id":3}
The direct client provides the same operation:
darpc message-dialog dismiss --pid 1234 7 3
The DLL revalidates the revision, ID, pane type, registration, and visibility on the client main thread before calling the native close operation. The dismissal path forces a fresh snapshot before submission, and a stale revision fails closed.
Events
message_dialogs.changed carries observation metadata and the complete
current state whenever a message dialog opens, changes, or closes. An empty
dialogs array means none remains. After an SSE resync, reread the resource.
The DLL observes applicable SMessage packets and, while a dialog is active,
checks the pane collection at most once every 100 milliseconds for native
closes.
Field maps
Field maps are the native world-map panels opened by a map warp tile. daRPC
exposes the panel only while an exact FieldMapPane is registered and visible
in the supported client. Receiving the server packet alone does not make the
resource active. The DLL caches a validated packet that arrives before the
client registers the pane, then publishes it when the pane becomes visible.
Pane visibility polling begins only after a validated definition is cached and
runs at most once every 100 milliseconds. Packet observation remains immediate,
and destination selection still validates the live pane before submission.
The 7.41 client ignores bounded bytes after the declared destination records;
daRPC does the same while still bounds-checking every known field.
Reading the active field map
curl "http://127.0.0.1:2626/clients/ZiLo/field-map"
The response contains observation metadata and a nullable field_map:
FieldMapState {
revision: u32,
field_name: string,
current_node_index: u8?,
destinations: Vec<FieldMapDestination>,
selection: FieldMapSelection?,
}
FieldMapDestination {
index: u8,
screen_x: u16,
screen_y: u16,
name: string,
checksum: u16,
map_id: u16,
map_x: u16,
map_y: u16,
}
The daemon may reuse a serialized snapshot for up to 250 milliseconds after it is received. This coalesces concurrent reads instead of repeatedly walking client state. The endpoint does not depend on the daemon having observed every earlier state event, which is important because the panel can open and close during unrelated movement resynchronization. The selection endpoint forces a fresh snapshot before validating its revision and destination index.
field_name is the local asset stem, such as field001. The current node is
nullable because a malformed or out-of-range server index does not identify a
destination. Destination names are not required to be unique, so use index
as the stable selector for one revision.
screen_x and screen_y are server fallback presentation coordinates. The
client can replace them with values from its local <field_name>.txt asset.
They are not guaranteed pointer-click coordinates.
The checksum and travel coordinates are exposed for observation and debugging. They are read-only command inputs. daRPC reconstructs the selection packet from the retained destination so callers cannot forge or mix travel fields.
Selecting a destination
Submit the current revision and one zero-based destination index:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"revision":11,"destination_index":1}' \
"http://127.0.0.1:2626/clients/ZiLo/field-map/select"
The direct Windows command is:
darpc field-map select --pid 1234 11 1
The daemon and DLL both validate the revision and index. HTTP 409 indicates no active panel, a stale revision, or a selection that was already submitted. HTTP 400 indicates that the index is not one of the retained destinations. HTTP 429, 503, or 504 indicates that the bounded live-client request path was busy, unavailable, or did not answer within two seconds.
The client normally delays its native CFieldMap send while the marker moves.
daRPC publishes selection_submitted only after the actual outgoing packet is
observed. It does not publish a separate selection-started event. Submission
does not prove that the server accepted the destination, and it does not close
the panel. Later location and map events are authoritative for arrival.
Live events
| SSE event | JSON type | Meaning |
|---|---|---|
field_map.opened | field_map_opened | A validated native field-map panel became active. |
field_map.changed | field_map_changed | The server replaced the active field map and destination list. |
field_map.selection_submitted | field_map_selection_submitted | The client sent the selected destination’s canonical packet. |
field_map.closed | field_map_closed | The native panel was no longer registered and visible. |
Every event carries the complete field-map state. closed carries it as
previous; the other events use field_map. On an event-stream resync, reread
GET /clients/{client}/field-map.
Client ownership and bounds
The injected DLL retains field-map state without depending on the daemon. The server packet is validated and copied into bounded storage on the client main thread, then decoded after transfer to the IPC thread. Pane checks rescan the live bounded event-dispatcher collection and never retain a native pane pointer between observations.
Closing the pane makes active_field_map null but retains the last validated
definition inside the DLL. If the client later reopens that cached native pane
without another server packet, daRPC publishes a new opened revision and
resets its submitted selection. The retained definition is never exposed while
the pane is hidden or unregistered.
Bulletin boards and player mail
The bulletin domain covers global boards, trade boards, guild boards, boards opened from a world tile, and player mail. One native client session owns these views. daRPC exposes the active session as structured state, observes server-backed page and entry changes, and drives the native controls for selection, scrolling, history, and composition.
Board articles and mail share list and entry concepts. Their composition rules are different:
- A new board article has a subject and body.
- Player mail has a recipient, subject, and body.
- Replying to mail opens the native mail composer with the viewed author as a non-editable recipient when the client does the same.
The currently supported packet and native control layouts are based on the 7.41 client. Uninterpreted packet fields and operation status bytes remain raw until further traces and client-code analysis establish their meaning.
Opening a bulletin session
Global boards, guild boards, and mail begin with the server-provided section list:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"revision":0,"action":{"type":"open_server_list"}}' \
"http://127.0.0.1:2626/clients/ZiLo/bulletin/actions"
The direct Windows command is:
darpc bulletin open --pid 1234
A board in the world is opened with its tile coordinates. For example, tile 18,9:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"revision":0,"action":{"type":"open_world_board","x":18,"y":9}}' \
"http://127.0.0.1:2626/clients/ZiLo/bulletin/actions"
darpc bulletin world --pid 1234 18 9
Open actions use revision zero because no bulletin session is required yet. They submit the client’s canonical request. The resulting server packet and native dialog determine the active state.
Reading the active state
curl "http://127.0.0.1:2626/clients/ZiLo/bulletin"
The response has observation metadata and a nullable bulletin. It is null
when no supported bulletin dialog is active. The same state appears as
active_bulletin in the complete client snapshot and in darpc snapshot.
Every active state contains:
BulletinState {
revision: u32,
pending: BulletinOperation?,
last_operation_result: BulletinOperationResult?,
can_go_back: bool,
can_go_forward: bool,
view: BulletinView,
}
view.type identifies one of these shapes:
| Type | Meaning |
|---|---|
sections | Global, guild, mail, or other server-provided sections. |
entries | One board or mailbox list, including accumulated pages. |
entry | One opened article or mail message. |
board_post | The native new-article composer. |
player_mail | The native mail composer. |
A section has a signed-independent id, display name, kind, and source.
Source is represented as { "kind": "global|clicked|mail|unknown", "raw": n }
so an unrecognized client value is not discarded. Entry IDs are signed 16-bit
values because the client protocol also uses negative cursor sentinels.
Entry summaries contain the raw flags, author, month, day, and subject. A
full entry adds its body, navigation_flags, and unknown_before_id. The last
two fields are intentionally not decoded into guessed booleans.
The state also tracks the native control viewport as position and maximum.
This makes scroll state queryable after actions performed either through daRPC
or directly in the game UI.
Revision-guarded actions
All actions other than opening require the current bulletin revision. Submit them to:
POST /clients/{client}/bulletin/actions
The request always has this envelope:
{
"revision": 12,
"action": { "type": "next_entry" }
}
The supported action payloads are:
action.type | Additional fields | Effect |
|---|---|---|
open_server_list | none | Request global, guild, and mail sections. |
open_world_board | x, y | Activate the board at a world tile. |
open_section | section_id | Request the newest entries for a section. |
select_section | section_id | Select a visible native section row. |
open_entry | entry_id | Request one visible or retained entry. |
select_entry | entry_id | Select a visible native entry row. |
load_older | none | Request the next older page. |
scroll | position | Set the active native scroll control. |
back, forward | none | Navigate the native bulletin dialog history. |
previous_entry, next_entry | none | Request adjacent entries from an entry view. |
begin_board_post | none | Open the native article composer. |
begin_player_mail | none | Open the native mail composer. |
begin_reply | none | Reply to the currently viewed message. |
update_board_post | subject, body | Replace native board draft fields. |
update_player_mail | recipient, subject, body | Replace native mail draft fields. |
submit_compose | none | Send the current draft through the client protocol. |
delete_entry | entry_id | Request deletion from the current board or mailbox. |
highlight_entry | entry_id | Request the board-specific highlight operation. |
close | none | Close the current native bulletin dialog. |
select_section, select_entry, scroll, back, forward, composition, and
close operate on the native UI. Open, page, entry, post, mail, delete, and
highlight operations submit canonical packets. Selection is distinct from
opening so callers can reproduce keyboard or mouse navigation without causing
a server request.
HTTP 409 means there is no active session, the revision is stale, or the requested action is invalid for the current view. HTTP 400 means the action payload or bounded text is invalid. HTTP 429, 503, or 504 means the bounded live-client command path was busy, unavailable, or timed out.
Lists, paging, and scrolling
An entries view retains pages already received for the current section.
pagination reports:
unknownbefore paging state can be established.readywhen another older-page request can be submitted.loadingafter the request is observed and before its response.exhaustedafter the server returns an empty page.
Pages are merged by entry ID, so a repeated boundary row does not create a
duplicate. Storage is bounded. truncated: true means the client delivered
more sections or entries than daRPC could retain. Callers should not interpret
truncation as the server’s end of history.
Loading older entries and scrolling are separate actions. load_older extends
server-backed state; scroll changes the native viewport. An application can
load until exhausted, then use the retained entries and viewport to drive its
own UI.
Examples:
darpc bulletin open-section --pid 1234 12 4
darpc bulletin older --pid 1234 13
darpc bulletin scroll --pid 1234 14 6
darpc bulletin open-entry --pid 1234 15 4280
darpc bulletin next --pid 1234 16
Composing and mutation
Composition deliberately uses two steps. begin_* or reply opens the native
composer. update_* writes its currently queryable draft fields. A draft is
only the unsent content visible in that composer. submit_compose sends it.
darpc bulletin compose-post --pid 1234 20
darpc bulletin update-post --pid 1234 21 "Market day" "Trading begins at noon."
darpc bulletin submit --pid 1234 22
darpc bulletin compose-mail --pid 1234 30
darpc bulletin update-mail --pid 1234 31 Mileth "Hello" "Meet me by the inn."
darpc bulletin submit --pid 1234 32
Recipient text is at most 15 ASCII bytes, subject text is at most 60 ASCII bytes, and body text is at most 3000 ASCII bytes. NUL bytes are rejected. These bounds reflect the client protocol and native controls. Empty fields remain valid while a draft is being edited; server acceptance is reported separately.
Deletion and highlighting are server-backed requests. An observed outgoing
request becomes pending and is reported by bulletin.changed; a later server
result clears it and sets last_operation_result. Confirmed article posts and
mail sends emit bulletin.submitted, confirmed deletions emit
bulletin.deleted, and rejected mutations emit bulletin.failed.
Mutation events carry the complete bulletin state plus action, raw_status,
and the optional decoded server message. In particular, bulletin.failed
identifies whether posting, sending mail, deleting, or highlighting failed.
The raw fields remain available because status-byte meaning varies by response
shape. A command response means the client performed the requested action; the
mutation event reports what the server subsequently confirmed.
Live events
Subscribe through GET /clients/{client}/events:
| SSE event | JSON type | Meaning |
|---|---|---|
bulletin.opened | bulletin_opened | A supported bulletin session became active. |
bulletin.changed | bulletin_changed | Its view, selection, page, viewport, draft, or navigation state changed. |
bulletin.submitted | bulletin_submitted | The server confirmed an article post, mail send, or highlight. |
bulletin.deleted | bulletin_deleted | The server confirmed entry deletion. |
bulletin.failed | bulletin_failed | The server rejected the named bulletin action. |
bulletin.closed | bulletin_closed | The native bulletin session closed. |
Opened and changed events carry bulletin. Mutation events carry bulletin,
action, raw_status, and message. Closed events carry the prior state as
previous. After an event-stream resynchronization, reread
GET /clients/{client}/bulletin.
Ownership and observation boundaries
The injected DLL owns bulletin state independently of the daemon. Incoming and outgoing packets are copied into fixed-capacity, pointer-free storage. Native dialog pointers are rediscovered and validated on the client main thread; they are never sent over IPC or retained as public state. UI polling is bounded and runs only while bulletin tracking is active.
Player mail can contain private content. Keep the daemon on its default loopback listener, limit API access to trusted local consumers, and do not log complete bulletin snapshots or event payloads by default.
The implementation recognizes exact bulletin dialog and control layouts for the supported executable fingerprint. It fails closed when the session, dialog, control type, list bounds, or requested revision does not match. Future validation should confirm additional server status values, navigation flag bits, unusual page boundaries, guild-board permissions, and successful and failed mutations against a live client.
Groups
The group resource shows who is adventuring together, who leads the group, and which invitations are waiting for an answer. daRPC follows the same group rules as the game client and lets the server confirm every roster or setting change.
| Use | Route or events |
|---|---|
| Read current group state | GET /clients/{client}/group |
| Open or close grouping, or leave a group | POST /clients/{client}/group/toggle |
| Invite a player | POST /clients/{client}/group/invite |
| Answer an invitation | POST /clients/{client}/group/invitations/{id}/accept or /decline |
| Watch changes | group.* events on /clients/{client}/events |
Read the current group
curl "http://127.0.0.1:2626/clients/ZiLo/group"
GroupSnapshot {
observation: ObservationMetadata,
group: GroupState?,
}
GroupState {
members: Vec<GroupMember>,
invitations: Vec<GroupInvitation>,
is_group_open: bool?,
auto_accept: bool?,
}
GroupMember {
name: string,
is_leader: bool,
}
GroupInvitation {
id: u32,
inviter: string,
received_tick_ms: u32?,
}
An empty members array means the character is adventuring alone. The leader
is the member with is_leader: true. group is null outside a usable game
world.
is_group_open is the last setting confirmed by the server. It can be absent
until daRPC observes the character’s self-look data. auto_accept reports the
client option when daRPC can infer it from an incoming invitation. A pending
invitation found during late attach may not have received_tick_ms because its
original packet arrived before daRPC was loaded.
The invitation prompt is local client state rather than a complete server snapshot. daRPC checks for it at most once every 100 milliseconds. Each check walks the open prompt list once, which keeps invitation handling responsive without doing repeated client-memory work on every rendered tick.
The /status response also includes character.is_group_open and
character.group_members. group_members is always an array and stays empty
when the character is not grouped.
Toggle grouping
curl --request POST "http://127.0.0.1:2626/clients/ZiLo/group/toggle"
The request body is optional. When the character is adventuring alone, the route toggles whether other players may invite them. When the character is already grouped, it leaves the group or disbands it for the leader, then reopens invitations by default:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"leave_open":false}' \
"http://127.0.0.1:2626/clients/ZiLo/group/toggle"
Set leave_open=false to retain the game’s normal single-toggle behavior,
which leaves grouping closed. Reopening is a second ordered client command and
only runs after the leave command executes. The response describes the last
command submitted. Read the updated state or wait for a group event for the
server-confirmed result.
Invite a player
curl --request POST \
--header "Content-Type: application/json" \
--data '{"target":"OtherPlayer"}' \
"http://127.0.0.1:2626/clients/ZiLo/group/invite"
target may be a player name or a visible player object ID. A supplied name
does not need to be present in the client’s visible objects, which lets a caller
invite a known character elsewhere on the same map. Object IDs still require a
visible player with a known name. The target cannot be the calling character.
group.invitation_sent means the local client submitted the request. The game
does not report a remote decline or acceptance directly. A closed group setting
may instead produce the usual system message that the player refuses to join.
Membership changes remain authoritative.
Answer an invitation
Pending invitations have a daRPC invitation ID:
curl --request POST \
"http://127.0.0.1:2626/clients/ZiLo/group/invitations/7/accept"
curl --request POST \
"http://127.0.0.1:2626/clients/ZiLo/group/invitations/7/decline"
These requests have no body. They require the existing game invitation prompt,
submit the answer on the client main thread, and close that prompt normally. An
unknown or already closed ID returns 404.
group.invitation_closed records the local answer. Acceptance is confirmed
later by group.joined or another roster event.
The server messages Group disbanded. and <name> is joining this group. start
a 30-second self-look refresh window. Refresh requests use daRPC’s internal
self-inspection path: the 0x39 response updates group and legend state but is
suppressed before the native handler can open the self-look interface. The
self-look roster is Adventuring alone while solo or a newline-delimited list
headed by Group members while grouped. The list ends with Total n, and a
leading * marks the leader. While grouped, daRPC refreshes the roster every
two seconds. This catches joins, departures, and disbands even when the game
does not send fresh self-look data to every member at the same moment.
Live group events
Every state-bearing event contains the complete resulting group, so a
consumer can replace its retained group value without reconstructing hidden
client behavior.
| Event | Meaning |
|---|---|
group.settings_changed | The server-confirmed group-open setting changed. |
group.invitation_sent | The local client submitted an invitation request. |
group.invitation_received | A group prompt appeared and can be answered by ID. |
group.invitation_closed | A prompt was answered, dismissed, or invalidated. |
group.joined | A solo character received a nonempty roster. |
group.member_joined | A member appeared in an existing roster. |
group.member_left | A member disappeared from an existing roster. |
group.disbanded | The roster became empty. |
Invitation close reasons are accept_requested, declined, and dismissed.
See Live events for the common event envelope, ordering, and
resynchronization behavior.
Exchange
daRPC can open and complete the game’s normal player exchange without replacing the exchange window. The server still owns the offer and decides when it is complete or cancelled.
Start an exchange
Use the existing give routes with a visible player name or object ID:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"name":"Wine","quantity":3,"target":{"name":"OtherPlayer"}}' \
"http://127.0.0.1:2626/clients/ZiLo/items/give"
curl --request POST \
--header "Content-Type: application/json" \
--data '{"amount":1000,"target":{"name":"OtherPlayer"}}' \
"http://127.0.0.1:2626/clients/ZiLo/gold/give"
These requests start the exchange. They do not mean ZiLo accepted it. Wait for
exchange.opened or read the exchange resource before adding more to the
offer. daRPC automatically answers the first item’s server quantity request
only when the give originated from daRPC. Manually adding an item, whether it
opens the exchange or is added later, keeps the game’s native stack-quantity
prompt when more than one is available and skips the unnecessary prompt when
only one is available.
Read the current exchange
curl "http://127.0.0.1:2626/clients/ZiLo/exchange"
The response contains exchange: null when no tracked exchange is open.
Otherwise it contains both offers:
ExchangeState {
id: u32,
partner: string,
local: ExchangeOffer,
other: ExchangeOffer,
}
ExchangeOffer {
items: ExchangeItem[],
gold: u32,
accepted: bool,
}
ExchangeItem {
index: u8,
sprite: u16,
dye_color: u8,
quantity: u8,
name: string,
}
index is the zero-based position in the eight-row exchange offer. quantity
is 1 when the server sends no stack suffix and otherwise reflects the stack
count carried in the exchange item name.
Character status also exposes is_in_exchange for a quick open or closed
check.
Change the local offer
Add an item by one-based inventory slot or case-insensitive name:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"slot":4,"quantity":3}' \
"http://127.0.0.1:2626/clients/ZiLo/exchange/items"
curl --request POST \
--header "Content-Type: application/json" \
--data '{"name":"Wine"}' \
"http://127.0.0.1:2626/clients/ZiLo/exchange/items"
Quantity defaults to 1. It must fit the available stack and the exchange protocol’s range of 1 through 255. daRPC sends the game’s first add-item request, waits for the server’s quantity prompt when needed, and only then submits the count.
Set gold once:
curl --request POST \
--header "Content-Type: application/json" \
--data '{"amount":1000}' \
"http://127.0.0.1:2626/clients/ZiLo/exchange/gold"
The amount must be nonzero and no greater than the character’s current gold. The game permits gold to be set only once for an exchange. Offered items cannot be removed individually. Cancel the exchange and begin again to change either of those choices.
Once either player accepts, daRPC stops accepting offer changes for that exchange.
Accept or cancel
curl --request POST \
"http://127.0.0.1:2626/clients/ZiLo/exchange/accept"
curl --request POST \
"http://127.0.0.1:2626/clients/ZiLo/exchange/cancel"
These routes send a request and leave the window open until the server confirms
the result. Completion requires both players to accept. Cancellation by either
player closes the exchange. The normal client displays a one-button result
alert on both paths. Launch with --skip-exchange-alerts, or set
skip_exchange_alerts: true in a daemon launch request, to replace that modal
with the server’s same confirmation text in the floating game-message bar.
This visual confirmation does not synthesize a chat-history message. The typed
terminal exchange event remains the durable automation signal.
Live exchange events
Subscribe through GET /clients/{client}/events:
| Event | Meaning |
|---|---|
exchange.opened | The server opened an exchange window. |
exchange.item_added | Either offer gained or replaced an item row. |
exchange.gold_changed | Either offer’s gold changed. |
exchange.accepted | One player accepted and the exchange remains open. |
exchange.completed | Both players accepted and the exchange closed. |
exchange.cancelled | The server cancelled and closed the exchange. |
Every state-bearing event includes the complete resulting exchange value.
item_added, gold_changed, and accepted also include party, which is
local or other. Completion and cancellation include the final server
message and final offer state.
If an event consumer falls behind, reread /exchange. The DLL includes its
retained exchange in every fresh snapshot, so daemon reconnects do not lose an
exchange that daRPC has already observed.
An exchange window that was already open before late injection cannot be reconstructed from the initial memory walk. Cancel or finish that window and start the next exchange after daRPC is attached.
Online players
The Who list shows players currently reported online by the game server. daRPC requests the list through one chosen client, so guildmate markers are from that character’s point of view.
Read the list
curl "http://127.0.0.1:2626/clients/ZiLo/who"
The response keeps the server’s original player order:
{
"pid": 6076,
"received_tick_ms": 14290500,
"world_count": 128,
"country_count": 42,
"players": [
{
"name": "ZiLo",
"title": "Aisling",
"class": "priest",
"state": "awake",
"color": 3,
"is_master": true,
"is_guildmate": true
}
]
}
world_count is the larger population count displayed by the client.
country_count is the number of player rows supplied for this character. The
players array can be shorter after applying filters.
Player states are awake, do_not_disturb, daydreaming, need_group,
grouped, lone_hunter, group_hunting, need_help, or unknown.
Filter the response
Use a comma-separated, case-insensitive class filter:
curl "http://127.0.0.1:2626/clients/ZiLo/who?classes=warrior,rogue"
Supported class names are peasant, warrior, rogue, wizard, priest,
and monk. Add guild_only=true to keep only players marked as guildmates:
curl "http://127.0.0.1:2626/clients/ZiLo/who?classes=priest,wizard&guild_only=true"
Filters never reorder the list. An unknown class returns 400 Bad Request.
Request behavior
This route asks the game server for fresh data instead of reading the normal
character snapshot. Requests made within one second share the same in-flight
or recently completed result. If no matching response arrives within three
seconds, the route returns 504 Gateway Timeout.
daRPC captures its own response before the client opens the Who panel, so the request does not interrupt play. A Who request made by the player still opens and updates the normal client panel. The outbound and inbound requests are matched in order so one does not consume the other’s response.
Who is a point-in-time query, not a live state stream. It does not produce a Server-Sent Events (SSE) event. Request it again when a consumer needs a newer list.
For daemon-free use, the direct command returns the same unfiltered list:
darpc who --pid <pid>
darpc --output json who --pid <pid>
Legend
The legend resource returns every mark shown in the character’s self-look profile:
curl "http://127.0.0.1:2626/clients/ZiLo/legend"
The direct CLI exposes the same server refresh for one injected client:
darpc legend --pid <pid>
darpc --output json legend --pid <pid>
LegendSnapshot {
pid: u32,
received_tick_ms: u32,
marks: Vec<LegendMark>,
}
LegendMark {
text: string,
tag: string,
color: u8,
icon: LegendIcon,
}
icon is one of aisling, warrior, rogue, wizard, priest, monk,
heart, victory, none, or unknown. color is the client color value.
The text and tag are decoded from the values supplied by the game server.
Refresh behavior
Legend data arrives in the server’s self-look message and is not pushed again
when a mark changes. For that reason, each request asks the game server for a
new self-look before returning. Requests for the same client are coalesced for
one second, so concurrent or rapidly repeated reads reuse the completed result
instead of sending repeated refresh packets. The endpoint returns 504 Gateway Timeout if the server does not answer within three seconds.
Live events
Comparing a refreshed self-look with the retained legend produces these Server-Sent Events (SSE):
| Event | Payload |
|---|---|
legend.mark_added | observation, mark |
legend.mark_changed | observation, previous, current |
legend.mark_removed | observation, mark |
The game normally adds marks and does not remove them, but removal is modeled
so consumers can remain correct if a server ever returns a shorter legend.
Reread /legend after stream resynchronization.
Architecture
This chapter is a light tour of the system. It is for readers who want to know how daRPC works without reading protocol fields, memory layouts, or x86 code. Application authors can use the Web API and Live events without depending on these internal boundaries.
daRPC is split into four programs so the game-specific work stays close to the client while tools can use ordinary command-line and web interfaces.
| Component | What it does |
|---|---|
darpc.dll | Lives inside one game client, tracks its state, observes events, and runs native actions. |
loader.exe | Launches a supported client or attaches and detaches the DLL. |
darpc.exe | Talks directly to one DLL and prints human-readable text or JSON. |
darpcd.exe | Discovers several clients and exposes REST, SSE, OpenAPI, and Swagger UI. |
direct CLI
+---------------------- darpc.exe
|
Darkages.exe <-> darpc.dll <-> named pipe <-> darpcd.exe <-> REST / SSE
^ OpenAPI / Swagger
|
loader.exe
The DLL and loader are 32-bit because the Dark Ages client is 32-bit. The direct CLI and daemon are ordinary 64-bit Windows programs.
One DLL per client
Each injected DLL is responsible for its own game client. It understands the supported client layout, captures current state, and keeps that state updated as the game handles new events.
The DLL remains useful without the daemon. darpc.exe can connect directly for
one-client scripts or diagnostics. The DLL also continues tracking local state
when the daemon disconnects and accepts a replacement connection later.
One state-owned refresh transaction coordinates movement-safe F5 submission, object reconciliation, completion, fallback, and deferred snapshot recovery. Hooks supply observations, but do not sequence those state changes themselves.
Only one controller owns a DLL’s named pipe at a time. If the daemon is connected, a direct CLI request reports that the endpoint is busy rather than silently sending the request through the daemon.
The daemon is the meeting point
darpcd.exe discovers game clients and keeps a current public view of each
one. It never reads game memory itself. It uses the typed state and events sent
by each DLL, then presents player-friendly API models.
One client roster module owns target membership, connection-worker lifetime, registry lifetime, stale-event filtering, command routing, and snapshot recovery. The daemon loop supplies discovery results and publishes accepted changes, but it does not coordinate those lifecycle rules itself.
This boundary keeps Windows injection and client details out of dashboards, scripts, and other consumers. It also keeps a slow web request or event subscriber from blocking unrelated game clients.
REST reads current state and submits individual actions. Server-Sent Events (SSE) carry one-way live changes. Together they provide real-time interaction while keeping commands bounded and the event stream independently reconnectable. WebSockets are intentionally unsupported because another bidirectional transport would duplicate validation, flow control, ordering, and connection lifecycle behavior without a demonstrated requirement.
Reading game state
A new daemon connection begins with a complete client baseline. The DLL then sends smaller ordered updates as relevant game events occur.
The daemon commits each complete snapshot or contiguous event batch once.
That commit produces both the retained REST view and the matching live changes,
so the two interfaces cannot accept different interpretations of one batch.
If reduction fails, the daemon preserves the last valid snapshot internally,
marks the public observation unavailable, closes the current event stream with
stream.resync_required, and requests a fresh snapshot from the DLL.
client main thread -> bounded copy -> DLL state -> named pipe -> daemon -> REST / SSE
REST resources are views of the daemon’s retained state. Reading inventory or status does not trigger a new memory walk. See Game data for the baseline, revisions, missing values, and reconnect behavior.
Running native actions
Actions travel in the opposite direction:
REST or direct CLI -> named pipe -> bounded queue -> client tick -> native method
The pipe worker validates and queues pointer-free command data. A normal game tick executes at most one queued command on the client main thread. This is where the client expects its movement, skill, and spell methods to run.
Using native methods keeps client timing, interface state, and local validation in the normal path. Native pathfinding owns its route, so player input can cancel or replace it naturally. Exact external routes use the same native route storage and pacing. Route planning policy remains outside the DLL.
Hooks and main-thread affinity
Small runtime hooks provide safe moments to copy changing state and drain native commands. They do not perform web requests, named-pipe input/output, logging, or large conversions.
The incoming event hook ends at a bounded byte slice. A synchronous server-event processor behind that seam owns response interception, packet parsing, reusable parser scratch, and ordered state dispatch. This keeps client memory and detour mechanics out of server semantics without adding a queue or changing when updates become visible.
The Runtime hooks chapter explains every installed hook, why it exists, how work is moved off the main thread, and how daRPC removes the hooks before unloading.
Client views are not a global world
Several clients can observe the same map, but each has its own view distance and last-seen time. daRPC keeps those observations separate. It does not claim that one client’s visible-object list is the full map population.
A future shared-world projection can merge compatible observations, but it must preserve their source and freshness.
Design goals
- Keep injected work small and bounded.
- Preserve the client’s original behavior unless a feature explicitly changes it.
- Validate the exact supported client before reading state or installing hooks.
- Keep raw pointers and client layouts inside the injected process.
- Let the DLL, daemon, and consumers disconnect without closing the game.
- Give tool authors portable interfaces and game-friendly data names.
Runtime hooks
This chapter explains which parts of the client daRPC observes and why some actions must run on the game thread. It intentionally stays above instruction addresses and assembly details. API consumers do not need to manage these hooks.
A hook gives daRPC a short callback at a useful point in the normal client flow. The original client function still runs. daRPC uses the callback to copy bounded pieces of state, observe an action, or execute one queued native command.
Hooks are installed only after the executable has been identified as the exact supported client build.
Installed hooks
| Hook | Purpose |
|---|---|
| Client tick | Captures requested baselines, settles collection changes, watches walking state, and executes at most one queued native command. |
| Decoded server event | Observes supported updates after the client has handled them, including status, inventory, abilities, effects, objects, movement, and messages. It captures correlated daRPC Who and player-inspection responses before the client opens their panels. |
| Outbound packet submission | Observes supported ability, item, gold, equipment, emote, pickup, turn, Who, player-inspection, and local slash-command requests before encryption. |
| Map size | Captures map identity, name, and dimensions so a map change can be committed atomically with the following position. |
| Native path control | Combines live and complete-map collision during breadth-first search, recovers failed queued steps, and copies the retained route before movement consumes its first step. |
These five hooks have different jobs because no single client boundary provides all the information daRPC needs.
Client tick
The client tick is daRPC’s safe meeting point with the game main thread.
The client can call this dispatcher boundary much faster than it renders. daRPC counts every callback for liveness, but runs main-thread tick work at most once per distinct Windows millisecond tick. This preserves responsive commands and state observation without repeating the same reads in a tight polling loop.
When requested, it copies a complete state baseline into preallocated DLL memory. The pipe worker converts and sends that copy later. Regular REST reads do not request another baseline.
The tick also:
- Reconciles inventory, spellbook, and skillbook slots after their short settling window
- Detects when native pathfinding starts and stops
- Detects confirmed steps that shorten the retained planned route
- Replans a retained native ground destination after a queued step is rejected
- Checks cached field-map pane visibility at most once every 100 milliseconds
- Checks active message-dialog panes at most once every 100 milliseconds
- Executes at most one queued action or diagnostic command
- Publishes small health counters used by hook diagnostics
This work is bounded. The callback does not allocate, serialize, log, wait for another thread, or perform named-pipe input/output.
Decoded server events
The event hook runs after the client has successfully handled a recognized server event. Running afterward lets the client remain authoritative and lets daRPC compare against the state the client accepted.
The hook copies only bounded data from event families daRPC understands. Those updates drive:
- Character status, vitals, progression, gold, weight, and modifiers
- Blind and action-restriction state
- Position and map transition completion
- Inventory, spellbook, and skillbook reconciliation
- Active spell effects
- Visible players, monsters, Mundanes, and ground items
- Body animations, attached visual effects, and temporary health meters for visible living entities
- Chat and system messages
- Merchant and pursuit dialog pages
- Field-map panels, destination lists, and submitted selections
- Player exchange state, offers, acceptance, completion, and cancellation
After the bounded copy, one synchronous server-event processor owns interception, parsing, reusable object scratch, and semantic dispatch. The hook itself owns only the client ABI, pointer and length validation, the copy, reentrancy protection, timing, and health counters. No queue or worker is inserted between native dispatch and the state update, so packet order and immediate visibility are preserved.
Unknown, malformed, oversized, or unreadable events are ignored. The client’s original result is preserved. The intentional exceptions are Who and other-player information responses matched to daRPC requests. Those responses are copied for the waiting controller and are not passed to their stock panel-opening handlers. Player-started requests still run normally.
The pre-dispatch checks are dormant unless daRPC has an outstanding Who or
player-inspection request. The x86 detour checks the pending markers and opcode
0x36 or 0x34 before entering Rust, and restores the registers it uses before
continuing into the client. Ordinary server events therefore take the original
dispatcher path without client-memory reads or Rust work before dispatch.
Outbound action observation
Some useful events describe what the client sends rather than what it receives. The outbound hook watches the common plaintext submission path for:
- Skill use
- The start of a delayed spell
- Each submitted chant line
- Final spell use
- Item use, tile drop, and player or monster exchange requests
- Gold tile drops and exchange requests
- Ground-item pickup, equipment removal, emotes, and turning
- Who requests, including whether daRPC or the player started each request
- Object-information requests (
0x43subtype 1), correlated by visible ID and order - Refresh requests and their payload-free
RefreshUserOKresponses - Public-speech slash commands and escaped literal slashes
NPC dialog responses use native main-thread methods and are observed through their retained dialog state. This preserves the visible page and the client’s normal response-pending transition without constructing dialog packets in daRPC.
Field-map server events are observed after native dispatch so daRPC can confirm
that an exact FieldMapPane was registered and made visible. Each tick rescans
the bounded live pane collection to detect closure. Outgoing packet 0x3F is
the only selection-submitted signal; receiving 0x2E or observing the native
click animation alone is insufficient.
This is how daRPC reports ability and action events for requests started through either daRPC or the normal game interface. It also helps keep spell replacement and cancellation ordering sensible.
A second outbound detour validates the native refresh helper entry and inspects
only its caller return address. Calls from either physical F5 site set one
atomic pending flag and return without sending. The next client tick uses the
same movement-safe coordinator as POST /resync: it resets queued movement,
waits for any active step to commit, and then submits 0x38 through the normal
packet path. Repeated physical requests coalesce. The validated
movement-correction caller is deliberately passed through to the original
helper so automatic recovery remains immediate. The detour does not allocate,
log, perform interprocess communication, or walk client memory.
Only the recognized, bounded fields needed by the state model are copied. Full packet bodies are not retained or written to the diagnostic log. Original client submissions continue normally except that one-slash commands are suppressed and a double-slash escape is submitted with one slash removed.
Atomic map changes
The game supplies a new map and the character’s new position in separate updates. Publishing the map immediately could briefly pair it with coordinates from the old map.
The map-size hook stages the new map ID, name, width, and height. The decoded event hook commits that staged map only when the following position arrives. Snapshots and ordinary movement publication pause across this short boundary.
The same staged dimensions correlate a native cache miss without adding file
I/O. A matching outbound 0x05 request publishes map.requested. The decoded
event seam records bounded 0x3C row indices after the native handler returns;
the final row publishes map.downloaded only when every prepared row was
observed with the expected body length. No packet body is retained.
Consumers therefore see one location.changed event containing a consistent
map and position. The event is published before ordinary per-object
disappearance events retire the previous map’s visible-object view. No
collection-reset event is emitted.
Planned route capture
The path-control hook validates two exact version-741 code contracts: the breadth-first path-builder entry and the queued-step call site. It does not replace collision answers or alter the native planner.
The queued-step wrapper preserves the stock call and publishes a rejected edge
as walking.obstructed. Native ground routes and pursuits are otherwise left
untouched. A rejected externally installed exact route is reset so an external
controller can replan from the confirmed position.
The path-builder entry hook runs after the client’s breadth-first search succeeds. It always reads the retained 12-byte step records, reverses their goal-to-start queue order, and expands direction values into absolute start-to-goal tile positions. This forced capture preserves same-length route replacements. Each client tick first compares only the pathfinder generation and remaining-step count with the retained route. Unchanged ticks skip the event-buffer claim and native vector walk; changed routes expand the remaining prefix so confirmed consumption produces a route revision. Pathfinder generations distinguish rebuilds, including pursuit routes that happen to select identical tiles.
The game-thread callback writes only to preallocated route buffers. Four event buffers bound pending revisions; exhaustion requests the normal snapshot resynchronization instead of blocking movement or allocating in the hook.
Exact route commands validate a bounded, map-tagged cardinal tile sequence on
the main thread. They use the client’s native vector append helper to create
12-byte direction, source_y, source_x records in goal-to-start order, publish
the installed route, and start the first step through normal queued movement.
Main-thread affinity
Most game state is owned and changed by the client main thread. Native movement, skill, and spell methods also expect the client state associated with that thread.
Calling them directly from the daemon’s pipe worker would create two problems:
- The game could change a structure while daRPC was reading it.
- A native method could run in a thread context it was never designed for.
daRPC instead uses bounded handoffs:
read: client hook -> fixed copy or event queue -> pipe worker -> daemon
action: daemon -> pipe worker -> command queue -> client tick -> native method
The client-facing side handles only fixed-size or preallocated data. Text conversion, JSON, logging, HTTP, and named-pipe work happen away from the game thread.
The queues are deliberately bounded. If a producer cannot enqueue safely, it reports pressure or requests a later resynchronization instead of blocking the client or growing memory without limit.
Attach and detach lifecycle
Each hook installation is transactional. If a later hook cannot be installed, DLL initialization removes the hooks already installed and stops the worker.
Detaching follows the reverse flow:
- Stop accepting new pipe work.
- Cancel commands that have not started.
- Remove the outbound, event, path-builder, map, and tick hooks in safe reverse order.
- Wait for any callback already in progress to finish.
- Release DLL-owned state and unload the library.
This order prevents a hook from calling code or using memory that has already been unloaded. Installation and removal also coordinate with the process’s other threads so an instruction pointer is not left inside code while it is being replaced or restored.
DllMain remains minimal. Substantial initialization and shutdown happen
outside the Windows loader lock.
Failure behavior
Hook callbacks preserve original client behavior and do not unwind across the client boundary. A bad or unsupported observation is skipped. A daemon or pipe disconnect does not remove local client state or terminate the game.
The project also tests the reusable hook mechanism against an owned x86 harness before qualifying it in the live client. The harness covers installation, original-function trampolines, concurrent calls, rollback, and removal.
darpc.dll
This chapter describes the injected component’s responsibilities and lifecycle. Most tool authors can treat it as the client-side engine behind the Web API.
darpc.dll is a 32-bit x86 dynamic-link library injected into one compatible
game client. It provides the bridge between the client’s internal event system
and the daRPC named-pipe protocol.
IPC lifecycle
darpc_initialize validates the host identity and starts one IPC worker. The
worker binds \\.\pipe\da-rpc-{pid} before initialization reports success,
then waits for one local controller without touching the game thread. DllMain
does not start IPC or wait for the worker.
Each connection begins with the DLL’s Hello and must answer with a compatible
HelloAck. The worker serves bounded Ping, Echo, tick health, diagnostics, snapshot,
event-poll, and main-thread command operations.
It uses overlapped reads, writes, and accepts so darpc_shutdown can signal the
worker, cancel pending input/output, and join it before unloading. If bounded
shutdown cannot prove the worker stopped, shutdown fails and the loader leaves
the DLL loaded.
Malformed frames, invalid ordering, and broken connections end only that connection. The worker returns to listening for a replacement controller. The pipe is local-only, has one instance, and grants access to the process owner, Windows system, and administrators. A disconnected or absent controller does not affect the client process.
Event integration
The client dispatches decoded server events through a central handler. daRPC observes supported events after the client handles them, preserving the client’s original result and behavior.
Client event handlers also use a common function to queue outgoing network actions. Integrating at that boundary allows daRPC to initiate actions through the client’s own serialization and encryption path rather than implementing a second packet stack.
Native actions run through the client’s own methods on its main thread. This keeps local interface state and client timing in the normal path instead of trying to recreate an action with a packet alone.
State ownership
When attached to a running client, darpc.dll reconstructs a snapshot from
validated pointers, relative virtual addresses, and version-specific client
layouts. Capture is scheduled through the client tick hook and runs on the
client main thread. Bounded raw values are published to the pipe worker, which
owns text decoding, allocation, and serialization. See Game data
for the snapshot surface and concurrency model.
The DLL also observes the central decoded-event dispatcher after original handling. Bounded status, collection, effect, position, world-object, message, and audio values update retained state or enter a fixed 1 MiB queue as ordered events. A tick-time lifecycle check records login and disconnect transitions. For the initial map, map-size metadata and the authoritative position are joined regardless of arrival order. Later map-size metadata is staged until a new authoritative position completes the transition. A snapshot also retains that accepted position while the matching local player object is still becoming available. The pipe worker serves those updates through bounded long polls. It requests no allocation, logging, serialization, or IPC work from the hook path.
A separate observer watches the common outbound submission boundary after the client has processed an action. It copies only recognized bounded fields, preserves the original result, and records ordered ability, item, gold, equipment, pickup, emote, and turn events. The DLL tracks an active delayed spell so completion, server or client cancellation, and replacement by another spell remain distinct.
A complete snapshot records the latest event sequence already represented in its values. The queue rebases to that boundary, and overflow or an ordering gap causes the controller to request another complete snapshot. This keeps state ownership inside the DLL while avoiding an unbounded replay log.
This state tracking is independent of darpcd.exe. If the daemon stops, the DLL
continues to update its state and keeps its named-pipe server ready for a new
connection.
Diagnostic logging
The DLL writes one process-specific diagnostic log to
%USERPROFILE%\darpc\logs\pid-{pid}.log. It records lifecycle and connection
transitions, changed snapshot lifecycle or world state, warnings, and failures.
Repeated successful snapshots and successful or accepted commands remain silent.
Identical snapshot failures are recorded once until capture succeeds or the
failure reason changes.
Each log is limited to 1 MiB. After a complete record crosses that limit, the
file is rewritten before the next record and starts with a log_rotated marker.
This keeps the newest actionable records without allowing a long-running client
to grow one log indefinitely.
Command execution
The IPC worker validates command fields and submits pointer-free entries to a fixed 64-slot queue. It may wait up to the protocol’s bounded response window for a state transition, but it never executes client work. The existing tick hook removes at most one queue entry per tick and publishes accepted, executed, failed, cancelled, or timed-out status through atomics.
The diagnostic executor calls no client function and changes no game state.
Turn and walk executors resolve only the supported live world and call the
client’s confirmed direction, collision, reset, and pathfinding functions on
the main thread. Exact-tile walking checks current zero-based map bounds first;
a native builder that cannot reach a valid tile reports no_path. It uses the
ground route builder and never enables the client’s target pursuit or automatic
attack loop. The DLL retains a daRPC-requested destination until the native
route stops, then compares it with the latest accepted position for ordered
walking lifecycle events. Terminal results remain queryable for a bounded
period; new work may evict the oldest completed result rather than allowing
retained history to consume pending queue capacity.
The local self object keeps committed and staged tiles separate during a visual step. A replacement ground route uses the staged tile and remains queued until the native step-completion callback advances it. A direct step is rejected while that transition is active, before reset or prediction calls occur.
An exact-route walk instead validates a map-tagged sequence of at most 256
absolute tiles, appends the client’s native 12-byte route records in reverse
consumption order, and starts normal queued walking. The DLL observes native
route construction and queued-step results without changing native collision
or replanning. Before installation, it also rejects the route with
invalid_state when the packet-confirmed map or position differs from the
client’s native local self object. Only a rejected externally installed exact
route is reset.
Every queued command remains pointer-free.
Chant commands submit a bounded 0x0E message packet with spell-chant mode 2
through the confirmed client packet function. The packet carries one nonempty
length-prefixed ASCII string and preserves every supplied byte. NPC sell,
deposit, withdraw, and repair helpers format their phrases in the controller and
use this same executor.
Message commands submit bounded public or directed chat packets on the client
main thread. Say and shout use 0x0E modes 0 and 1; whisper uses 0x19
with length-prefixed recipient and content strings. Guild and group messages
use whisper recipients ! and !!. A say beginning with / is escaped while
passing through the local command interceptor so the server receives the
requested slash-prefixed text unchanged.
Assail commands submit the one-byte 0x13 basic-attack packet through that
same confirmed client packet function. Server responses remain responsible for
the observed player animation and sound events.
Skill use resolves the live lower-tray root, skill inventory, pointer table, and one-based entry on the main thread, then calls the client’s normal skill activation routine. These pane objects exist independently of the visible tab; daRPC does not select the skill page, synthesize input, or disturb focus. A missing or changed entry fails closed. The native routine retains its ordinary action-delay checks and configured skill-text behavior.
Spell casting resolves the equivalent live spell entry and checks its expected argument type, action delay, denial state, object or map target, and bounded text before calling the matching native routine. It supports no-argument, object-target, tile-target, and text-input spells without selecting the spell page or synthesizing input. A new cast is allowed to replace a delayed cast in progress; ordered outbound observations identify the interrupted and new spells separately.
Item use resolves the live inventory pane and calls its ordinary activation routine. Item and gold transfers, pickup, equipment removal, and emotes build their small confirmed opcode-first request bodies and submit them through the client’s normal plaintext network boundary on the main thread. Tile actions check the current map bounds. Item actions revalidate the live slot, retained slot number, stackability, and quantity immediately before submission.
Operational boundaries
Hook timing is compiled into the production DLL but disabled by default. When enabled, the tick detour times the complete daRPC observation and its movement, command, player, state, and snapshot stages. The incoming event detour also times packet parsing and state updates as one event stage. The hot paths only read or update atomics and the monotonic clock. They do not allocate or format logs for diagnostics, perform IPC, or block. The IPC worker creates query responses and resets counters.
The tick budget is 10,000 microseconds; each child stage budget is 5,000 microseconds. Budget crossings increment a counter rather than writing from the hook. Runtime reset can race with one in-flight observation, so a queried snapshot is diagnostic telemetry rather than a transactional accounting record.
- Client layouts and addresses are version-specific.
- Hooks must not be installed until the executable version and required invariants have been validated.
- Hook paths must avoid unbounded work, blocking IPC, and daemon-owned lifetimes.
- Initialization and cleanup must not perform substantial work under the Windows loader lock.
- Failures must be contained at foreign function and hook boundaries without unwinding into the client.
Binary protocol
This is the wire-level reference between darpc.dll, darpc.exe, and
darpcd.exe. Web API consumers normally do not need it. Read this chapter when
implementing a direct pipe client, changing protocol messages, or debugging
compatibility.
darpc.dll communicates with a controller using a purpose-built binary
protocol. The codec is platform-independent; the Windows transport carries one
frame at a time over a process-specific named pipe.
All integers are unsigned and little-endian. The definitions below resemble Rust for readability, but they describe serialized fields in order without compiler padding. Rust memory layout is never copied directly onto the wire.
Frame
Every frame begins with this fixed 20-byte header:
struct FrameHeader {
magic: [u8; 4], // offset 0: ASCII "DRPC"
frame_version: u16, // offset 4: currently 1
message_type: u16, // offset 6: MessageType discriminant
sequence: u16, // offset 8: per-sender wrapping counter
flags: u16, // offset 10: reserved, must be zero
sender_tick_ms: u32, // offset 12: wrapping Windows millisecond tick
payload_len: u32, // offset 16: bytes after this header
}
Payloads are limited to 4 MiB, making the largest complete frame 4 MiB plus 20 bytes. A receiver reads the header into a fixed-size buffer, validates it, and only then reads the declared payload. A frame must contain exactly that payload length. Truncation and trailing bytes are errors.
The frame version describes the envelope above and remains the simple integer
1. It is independent from the negotiated protocol version carried by
Hello.
Protocol versions
A negotiated protocol version is one u16 split into major and minor bytes:
let version: u16 = ((major as u16) << 8) | minor as u16;
const VERSION_1_0: u16 = 0x0100;
const VERSION_1_1: u16 = 0x0101;
const VERSION_1_2: u16 = 0x0102;
const VERSION_1_3: u16 = 0x0103;
const VERSION_1_4: u16 = 0x0104;
const VERSION_1_5: u16 = 0x0105;
const VERSION_1_6: u16 = 0x0106;
const VERSION_1_7: u16 = 0x0107;
const VERSION_1_8: u16 = 0x0108;
const VERSION_1_9: u16 = 0x0109;
The protocol number is a wire-schema revision, not a Semantic Versioning compatibility promise. Each peer advertises an inclusive, continuous range of versions it can decode, and the controller selects the highest version in the overlap. No overlap rejects the connection.
The only currently supported version is 1.9 (0x0109). Version 1.9 adds
bulletin-board and player-mail state, events, and main-thread commands. Peers
advertise only 1.9, so deploy the DLL and its controller or daemon together
when adopting this change.
Protocol 1.8 added action source to character turns, walking lifecycle updates, planned routes, and active movement snapshots. Protocol 1.7 added palette dye color, retired collection-wide object clearing, and later gained spell-result failures and Look updates. Those schemas are retained in repository history but are not accepted by protocol 1.9 peers.
Message types
enum MessageType: u16 {
Hello = 1,
HelloAck = 2,
Ping = 3,
Pong = 4,
EchoRequest = 5,
EchoResponse = 6,
TickHealthRequest = 7,
TickHealthResponse = 8,
SnapshotRequest = 9,
SnapshotResponse = 10,
EventPollRequest = 11,
EventPollResponse = 12,
CommandRequest = 13,
CommandResponse = 14,
DiagnosticsRequest = 15,
DiagnosticsResponse = 16,
}
The normal request direction is controller to DLL. Responses travel from DLL to
controller. Hello starts in the opposite direction because the DLL announces
its identity and capabilities immediately after a connection is established.
Diagnostics messages are an additive protocol 1.5 capability implemented by components version 1.5.2 and later. A 1.5 controller checks the DLL component version before sending them, preserving compatibility with earlier 1.5 DLLs.
Hello and HelloAck
struct Hello {
protocol_min: u16, // offset 0: inclusive 0xMMmm version
protocol_max: u16, // offset 2: inclusive 0xMMmm version
dll_instance_id: [u8; 16], // offset 4: one initialized DLL lifetime
process_id: u32, // offset 20
process_creation_time: u64, // offset 24: raw Windows FILETIME
architecture: Architecture, // offset 32: encoded as u8
dll_version_major: u16, // offset 33
dll_version_minor: u16, // offset 35
dll_version_patch: u16, // offset 37
executable_fingerprint: [u8; 32], // offset 39: SHA-256
client_version: u32, // offset 71
} // 75 bytes
enum Architecture: u8 {
X86 = 1,
X86_64 = 2,
}
struct HelloAck {
selected_version: u16, // offset 0: negotiated 0xMMmm version
dll_instance_id: [u8; 16], // offset 2: copied from Hello
} // 18 bytes
The instance ID identifies one initialized DLL lifetime. The process ID and raw creation time together distinguish PID reuse. The executable fingerprint, architecture, and client version identify the supported client contract.
HelloAck copies the instance ID so an acknowledgement for one DLL instance
cannot complete another instance’s handshake. Application messages are invalid
until this exchange completes:
DLL controller
|---- Hello (version range, identity) ------>|
|<--- HelloAck (selected version, identity) -|
| ready |
These identity fields protect against stale or accidental connections. They are not authentication against a malicious local process; transport-level peer validation remains a separate responsibility.
Ping, Pong, and echo
struct Ping {
request_id: u32, // offset 0
} // 4 bytes
struct Pong {
request_id: u32, // offset 0: copied from Ping
} // 4 bytes
struct EchoRequest {
request_id: u32, // offset 0
text_len: u16, // offset 4: UTF-8 byte count, at most 4,096
text: utf8[text_len], // offset 6
}
struct EchoResponse {
request_id: u32, // offset 0: copied from EchoRequest
text_len: u16, // offset 4: copied from EchoRequest
text: utf8[text_len], // offset 6: copied from EchoRequest
}
Request IDs wrap as u32 and provide request/response correlation. They are
separate from frame sequence numbers because unsolicited events may be
interleaved and one future request may produce more than one frame.
Tick-hook health
struct TickHealthRequest {
request_id: u32, // offset 0
} // 4 bytes
struct TickHealthResponse {
request_id: u32, // offset 0: copied from TickHealthRequest
installed: bool, // offset 4: u8, exactly 0 or 1
relocated_bytes: u8, // offset 5: complete target bytes in trampoline
tick_count: u32, // offset 6: wrapping observation counter
} // 10 bytes
The response is a worker-thread snapshot of atomic hook state. Comparing two
responses with wrapping_sub shows whether the client dispatcher advanced
during the sample window without performing IPC or logging in the hook itself.
While connected, darpcd samples this response once per second. It logs a
degraded transition after three consecutive samples below 60 observed ticks
per second and logs recovery after the rate returns to at least that threshold.
Runtime diagnostics
DiagnosticsRequest contains a u32 request ID followed by one u8
operation: query 0, enable hook timing 1, disable 2, or reset counters
3. Reset does not change the active mode.
DiagnosticsResponse contains the request ID, a u8 mode (0 disabled or 1
hook timing), then exactly seven fixed records in tick, movement, commands,
player, state, snapshot, and incoming event order. Each record carries its u8
stage, u32 budget in microseconds, u64 call count, u64 total duration,
u32 maximum duration, u64 over-budget count, and u32 last duration. The
fixed record count keeps decoding and allocation bounded.
Counters are atomic snapshots and may change while the IPC worker serializes a response. Timing is disabled by default. Component 1.5.2 controllers do not send these messages to older protocol 1.5 DLL components.
Client snapshot
struct SnapshotRequest {
request_id: u32;
}
enum SnapshotResult {
Unavailable(SnapshotUnavailableReason),
Ready(ClientSnapshot),
}
struct SnapshotResponse {
request_id: u32;
result: SnapshotResult;
}
enum ClientLifecycle: u8 {
Unknown = 0,
Title = 1,
Transition = 2,
InGame = 3,
Disconnected = 4,
}
struct ClientSnapshot {
revision: u32;
event_sequence: u32;
captured_tick_ms: u32;
updated_tick_ms: u32;
capture_duration_us: u32;
world_generation: u32;
lifecycle: ClientLifecycle;
character: Option<CharacterSnapshot>;
objects: Option<Vec<WorldObject>>;
dialog: Option<DialogState>;
group: Option<GroupState>;
exchange: Option<ExchangeState>;
legend: Option<Vec<LegendMark>>;
planned_route: Option<PlannedRoute>;
active_field_map: Option<FieldMapState>;
message_dialogs: MessageDialogsState;
active_bulletin: Option<BulletinState>;
}
struct FieldMapState {
revision: u32;
field_name: string8; // maximum 255 UTF-8 bytes
current_node_index: Option<u8>;
destinations: Vec<FieldMapDestination>; // u8 count, maximum 255
selection: Option<FieldMapSelection>;
}
struct FieldMapDestination {
index: u8; // contiguous, zero-based
screen_x: u16;
screen_y: u16;
name: string8; // maximum 255 UTF-8 bytes
checksum: u16;
map_id: u16;
map_x: u16;
map_y: u16;
}
struct FieldMapSelection {
destination_index: u8;
}
struct PlannedRoute {
source: ActionSource;
generation: u32;
tiles: Vec<TilePosition>; // u32 count, maximum 160,001
}
struct LegendMark {
icon: u8; // 0 through 8 are named icons
color: u8;
tag: string16; // maximum 255 UTF-8 bytes
text: string16; // maximum 255 UTF-8 bytes
}
struct ExchangeState {
id: u32;
partner: string16; // maximum 255 UTF-8 bytes
local: ExchangeOffer;
other: ExchangeOffer;
}
struct ExchangeOffer {
items: Vec<ExchangeItem>; // u8 count, maximum 8
gold: u32;
accepted: bool;
}
struct ExchangeItem {
index: u8; // zero-based, 0 through 7
sprite: u16;
dye_color: u8;
quantity: Option<u8>;
name: string16; // maximum 255 UTF-8 bytes
}
struct GroupState {
members: Vec<GroupMember>; // u8 count, maximum 64
invitations: Vec<GroupInvitation>; // u8 count, maximum 8
is_group_open: Option<bool>;
auto_accept: Option<bool>;
}
struct GroupMember {
name: string8; // 1 through 64 UTF-8 bytes
is_leader: bool;
}
struct GroupInvitation {
id: u32; // nonzero DLL-lifetime identifier
inviter: string8; // 1 through 64 UTF-8 bytes
received_tick_ms: Option<u32>;
}
struct CharacterSnapshot {
id: Option<u32>;
name: Option<utf8>;
identity: Option<PlayerIdentity>;
appearance: Option<CharacterAppearance>;
class: CharacterClass;
is_hidden: bool;
is_action_restricted: bool;
is_blinded: bool;
is_walking: bool;
movement_source: Option<ActionSource>;
is_casting: bool;
gold: u32;
weight: u32;
max_weight: u32;
progression: CharacterProgression;
stats: CharacterStats;
vitals: CharacterVitals;
modifiers: Option<CharacterModifiers>;
location: Option<MapLocation>;
inventory: Option<Vec<InventoryItem>>;
equipment: Option<Vec<EquipmentItem>>;
spellbook: Option<Vec<Spell>>;
skillbook: Option<Vec<Skill>>;
effects: Option<Vec<Effect>>;
}
struct PlayerIdentity {
nation: u8; // exact Nation value 0 through 13
title: string16;
guild_rank: string16;
display_class: string16;
guild: string16;
}
struct PlayerProfile {
identity: PlayerIdentity;
user_state: u8;
is_group_open: bool;
equipment: Vec<PlayerEquipmentItem>; // maximum 18
legend: Vec<LegendMark>; // maximum 255
inspected_tick_ms: u32;
}
struct CharacterAppearance {
gender: Gender;
hair_style: u16;
hair_color: u8;
body_sprite: u16;
}
struct InventoryItem {
slot: u8;
sprite: u16;
dye_color: u8;
name: Option<utf8>;
quantity: u32;
can_stack: bool;
durability: u32;
max_durability: u32;
}
enum EquipmentSlot: u8 {
Weapon = 1,
Armor = 2,
Shield = 3,
Helmet = 4,
Earrings = 5,
Necklace = 6,
LeftRing = 7,
RightRing = 8,
LeftGauntlet = 9,
RightGauntlet = 10,
Belt = 11,
Greaves = 12,
Boots = 13,
Accessory1 = 14,
Overcoat = 15,
OverHelm = 16,
Accessory2 = 17,
Accessory3 = 18,
}
struct EquipmentItem {
slot: EquipmentSlot;
sprite: u16;
dye_color: u8;
name: Option<utf8>;
durability: u32;
max_durability: u32;
}
struct Spell {
slot: u8;
icon: u16;
name: Option<utf8>;
level: u8;
max_level: u8;
lines: u8;
target_type: u8;
prompt: Option<utf8>;
cooldown: CooldownStatus;
}
struct CooldownStatus {
active: bool;
cooldown_ms: Option<u32>;
remaining_ms: Option<u32>;
}
struct Effect {
icon: u16;
duration: EffectDuration;
}
enum Direction: u8 {
North = 0,
East = 1,
South = 2,
West = 3,
}
enum CreatureKind: u8 {
Monster = 1,
Npc = 2,
}
enum WorldObject: u8 {
Player = 1 {
id: u32;
x: i32;
y: i32;
direction: Direction;
is_hidden: bool;
visual: Option<PlayerVisual>;
name: Option<utf8>;
profile: Option<PlayerProfile>; // stored in the appended profile table
},
Creature = 2 {
id: u32;
x: i32;
y: i32;
direction: Direction;
kind: CreatureKind;
sprite: Option<u16>;
name: Option<utf8>;
},
Item = 3 {
id: u32;
x: i32;
y: i32;
sprite: u16;
dye_color: u8;
z_index: u16;
},
}
enum PlayerVisual: u8 {
Human = 1 HumanVisual,
Creature = 2 {
sprite: u16;
color: u8;
boots_color: u8;
pants_color: u8;
},
}
struct HumanVisual {
gender: u8;
head_sprite: u16;
body_sprite: u16;
arms_sprite: u16;
boots_sprite: u16;
pants_sprite: u16;
armor_sprite: u16;
weapon_sprite: u16;
shield_sprite: u16;
overcoat_sprite: u16;
accessory1_sprite: u16;
accessory2_sprite: u16;
accessory3_sprite: u16;
hair_color: u8;
skin_color: u8;
boots_color: u8;
pants_color: u8;
overcoat_color: u8;
accessory1_color: u8;
accessory2_color: u8;
accessory3_color: u8;
rest_position: u8;
face_shape: u8;
is_translucent: bool;
}
enum EffectDuration: u8 {
Blue = 1,
Green = 2,
Yellow = 3,
Orange = 4,
Red = 5,
White = 6,
}
movement_source is encoded only when is_walking is true. An active walk
always carries a source; Unknown represents an origin unavailable at snapshot
time. An idle character has no movement source on the wire.
The dialog, group, exchange, legend, local identity, and visible-player profile fields were appended during protocol 1.0 development. A 1.0 decoder accepts an older snapshot ending at any supported tail boundary and treats missing values as unavailable. New encoders append local identity and a profile table keyed by visible player ID after the original object records.
Optional values begin with a strict boolean byte. Strings use a u16 UTF-8
byte length. Character collections use a u8 count followed by occupied
entries; inventory permits at most 60 entries, equipment 18, each ability book
90, and effects 10. World objects use a u16 count and permit at most 512
entries. Collection names are limited to 127 bytes, character names to 15 bytes,
world-object names to 63 bytes, and map names to 255 bytes. Slots are one-based,
unique within a slotted collection, and strictly range checked. World-object IDs
are unique. Directions accept only 0 through 3, effect icons are unique, and
duration values outside 1 through 6 are rejected. The overall 4 MiB frame
payload cap still applies even when every individual collection count is valid.
Snapshot scalars use explicit little-endian integer widths. Collection entries carry their slot, appearance identifier, optional name, and their domain fields: inventory quantity, stackability, and durability, equipment durability, spell levels, lines, target type, optional text-input prompt, and cooldown, or skill levels and cooldown. Equipment slots use numeric values 1 through 18 on the wire and typed names in public presentation. A cooldown contains an active flag, an optional total duration in milliseconds, and an optional remaining duration in milliseconds.
Unavailable reason values distinguish an absent hook, a bounded capture
timeout, and a failed state walk. A ready response may still contain absent
groups when the client lifecycle or validated pointers do not expose them.
Disconnected means that the client has an active reconnect dialog. It may
still contain character state when the underlying world remains valid.
Earlier snapshot-tail additions remain decodable when absent from old payloads.
The command and event additions documented below require protocol
1.1. Total cooldown duration requires protocol 1.2. Local-character and
player-object hidden-state fields require protocol 1.3. Player visual blocks
require protocol 1.4. Character stat points and stat spending also require
protocol 1.4. Field-map state and interaction require protocol 1.5. Bulletin
state, updates, and commands require protocol 1.9.
Event polling and state updates
The daemon uses bounded long polling rather than unsolicited pipe writes. This keeps the DLL pipe worker in a simple request and response loop while still delivering active updates immediately and limiting idle polling to one request every 50 milliseconds.
struct EventPollRequest {
request_id: u32;
after_sequence: u32;
max_events: u16; // 1 through 192
wait_ms: u16; // 0 through 1,000
}
enum EventPollResult {
Events(Vec<StateEvent>),
ResyncRequired {
missing_sequence: u32,
latest_sequence: u32,
},
}
struct EventPollResponse {
request_id: u32;
result: EventPollResult;
}
struct StateEvent {
sequence: u32;
revision: u32;
tick_ms: u32;
update: StateUpdate;
}
enum StateUpdate: u8 {
Status(StatusUpdate) = 1,
Location(LocationUpdate) = 2,
Effect(EffectUpdate) = 3,
Object(ObjectUpdate) = 4,
Message(ClientMessage) = 5,
Inventory(SlotUpdate<InventoryItem>) = 6,
Spellbook(SlotUpdate<Spell>) = 7,
Skillbook(SlotUpdate<Skill>) = 8,
Movement(MovementUpdate) = 9,
Ability(AbilityUpdate) = 10,
Action(ActionUpdate) = 11,
Entity(EntityUpdate) = 12,
Dialog(DialogUpdate) = 13,
Group(GroupUpdate) = 14,
Exchange(ExchangeUpdate) = 15,
Legend(LegendUpdate) = 16,
Lifecycle(LifecycleUpdate) = 17,
Audio(AudioUpdate) = 18,
Command(ClientCommand) = 19,
Player(PlayerUpdate) = 20,
CharacterProfile(CharacterProfileUpdate) = 21,
PlannedRoute(PlannedRoute) = 22,
// 23 is retired.
FieldMap(FieldMapUpdate) = 24,
MessageDialogs(MessageDialogsState) = 25,
MapDownload(MapDownloadUpdate) = 26,
Look(LookResult) = 27,
Bulletin(BulletinUpdate) = 28,
}
struct LookResult {
command_id: u32; // nonzero typed command ID
target: LookResultTarget;
text: string16; // 1 through 4096 UTF-8 bytes
}
enum LookResultTarget: u8 {
Ahead { x: u16, y: u16 } = 0,
Tile { x: u16, y: u16 } = 1,
}
enum LookTarget: u8 {
Ahead = 0,
Tile { x: u16, y: u16 } = 1,
}
enum MapDownloadUpdate: u8 {
Requested(MapDownload) = 1,
Downloaded(MapDownload) = 2,
}
struct MapDownload {
map_id: u32;
width: u8;
height: u8;
}
struct MessageDialogsState {
revision: u32;
dialogs: Vec<MessageDialog>; // u8 count, maximum 8
}
struct MessageDialog {
id: u32;
text: Option<string16>; // maximum 4096 UTF-8 bytes
truncated: bool;
}
struct BulletinState {
revision: u32;
pending: Option<BulletinOperation>;
last_operation_result: Option<BulletinOperationResult>;
can_go_back: bool;
can_go_forward: bool;
view: BulletinView;
}
enum BulletinView: u8 {
Sections {
heading: string16; // maximum 255 UTF-8 bytes
sections: Vec<BulletinSection>; // u8 count, maximum 64
selected_section_id: Option<u16>;
viewport: BulletinViewport;
truncated: bool;
} = 1,
Entries {
section: BulletinSection;
entries: Vec<BulletinEntrySummary>; // u16 count, maximum 128
selected_entry_id: Option<i16>;
viewport: BulletinViewport;
pagination: BulletinPagination;
truncated: bool;
} = 2,
Entry {
section: BulletinSection;
entry: BulletinEntry;
viewport: BulletinViewport;
} = 3,
BoardPost {
section: BulletinSection;
author: string16; // maximum 255 UTF-8 bytes
subject: string16; // maximum 255 UTF-8 bytes
body: string16; // maximum 32,767 UTF-8 bytes
} = 4,
PlayerMail {
mailbox: BulletinSection;
recipient: string16; // maximum 255 UTF-8 bytes
recipient_editable: bool;
subject: string16; // maximum 255 UTF-8 bytes
body: string16; // maximum 32,767 UTF-8 bytes
} = 5,
}
struct BulletinSection {
id: u16;
kind: u8; // unknown=0, board=1, mailbox=2
source: u8; // global=1, clicked=2, mail=3, otherwise preserved
name: string16; // maximum 255 UTF-8 bytes
}
struct BulletinEntrySummary {
id: i16;
flags: u8;
month: u8;
day: u8;
author: string16; // maximum 255 UTF-8 bytes
subject: string16; // maximum 255 UTF-8 bytes
}
struct BulletinEntry {
id: i16;
flags: Option<u8>;
month: u8;
day: u8;
navigation_flags: u8;
unknown_before_id: u8;
author: string16; // maximum 255 UTF-8 bytes
subject: string16; // maximum 255 UTF-8 bytes
body: string16; // maximum 32,767 UTF-8 bytes
}
struct BulletinViewport {
position: i32;
maximum: i32;
}
enum BulletinPagination: u8 {
Unknown = 0,
Ready = 1,
Loading = 2,
Exhausted = 3,
}
struct BulletinOperationResult {
operation: BulletinOperation;
raw_status: u8;
message: Option<string16>; // maximum 255 UTF-8 bytes
}
enum BulletinOperation: u8 {
Unknown = 0,
OpenSections = 1,
OpenWorldBoard = 2,
OpenSection = 3,
LoadOlder = 4,
OpenEntry = 5,
PreviousEntry = 6,
NextEntry = 7,
PostArticle = 8,
DeleteEntry = 9,
SendMail = 10,
HighlightArticle = 11,
SelectSection = 12,
SelectEntry = 13,
Scroll = 14,
Back = 15,
Forward = 16,
BeginBoardPost = 17,
BeginPlayerMail = 18,
BeginReply = 19,
UpdateCompose = 20,
Close = 21,
}
enum FieldMapUpdate: u8 {
Opened(FieldMapState) = 1,
Changed(FieldMapState) = 2,
SelectionSubmitted(FieldMapState) = 3,
Closed { previous: FieldMapState } = 4,
}
enum BulletinUpdate: u8 {
Opened(BulletinState) = 1,
Changed(BulletinState) = 2,
ActionSubmitted {
operation: BulletinOperation,
state: Option<BulletinState>,
} = 3,
OperationResult {
state: BulletinState,
result: BulletinOperationResult,
} = 4,
Closed { previous: BulletinState } = 5,
}
struct ClientCommand {
command: string16;
arg_count: u8;
args: [string16; arg_count];
}
struct LifecycleUpdate {
previous: ClientLifecycle;
current: ClientLifecycle;
}
enum AudioUpdate: u8 {
SoundPlayed { effect: u8 } = 0,
MusicStarted { track: u8 } = 1,
MusicStopped = 2,
}
enum LegendUpdate: u8 {
MarkAdded { mark: LegendMark } = 1,
MarkChanged { previous: LegendMark, current: LegendMark } = 2,
MarkRemoved { mark: LegendMark } = 3,
}
enum ExchangeUpdate: u8 {
Opened(ExchangeState) = 1,
ItemAdded { state: ExchangeState, party: ExchangeParty, item: ExchangeItem } = 2,
GoldChanged { state: ExchangeState, party: ExchangeParty, gold: u32 } = 3,
Accepted { state: ExchangeState, party: ExchangeParty, message: string16 } = 4,
Completed { state: ExchangeState, message: string16 } = 5,
Cancelled { state: ExchangeState, message: string16 } = 6,
}
enum ExchangeParty: u8 {
Local = 0,
Other = 1,
}
enum GroupUpdate: u8 {
InvitationSent { target: string } = 1,
InvitationReceived { invitation: GroupInvitation, state: GroupState } = 2,
InvitationClosed {
invitation: GroupInvitation,
reason: GroupInvitationCloseReason,
state: GroupState,
} = 3,
Joined { state: GroupState } = 4,
MemberJoined { member: GroupMember, state: GroupState } = 5,
MemberLeft { member: GroupMember, state: GroupState } = 6,
Disbanded { state: GroupState } = 7,
SettingsChanged { state: GroupState } = 8,
}
enum DialogUpdate: u8 {
Opened(DialogState) = 1,
Changed(DialogState) = 2,
Submitted {
state: DialogState,
previous_revision: u32,
submission: DialogSubmission,
} = 3,
Closed {
previous: Option<DialogState>,
reason: DialogCloseReason,
} = 4,
}
enum EntityUpdate: u8 {
Animated {
entity: WorldObject,
animation: u8,
duration_10ms: u16,
} = 1,
Effect {
entity: WorldObject,
effect: u16,
source: Option<WorldObject>,
frame_interval_ms: Option<i16>,
} = 2,
Damaged {
entity: WorldObject,
health_percent: u8,
} = 3,
}
enum CollectionChange: u8 {
Added = 1,
Removed = 2,
Changed = 3,
}
struct SlotUpdate<T> {
batch_index: u8; // zero-based position in the batch
batch_count: u8; // nonzero total batch size
change: CollectionChange;
slot: u8; // one-based collection slot
before: Option<T>; // field bit 0
after: Option<T>; // field bit 1
}
enum MessageKind: u8 {
Say = 1,
Shout = 2,
Whisper = 3,
Guild = 4,
Group = 5,
System = 6,
World = 7,
Chant = 8,
}
struct ClientMessage {
kind: MessageKind;
sender: Option<String>; // presence byte, then bounded u16 UTF-8 string
recipient: Option<String>; // presence byte, then bounded u16 UTF-8 string
text: String; // bounded u16 UTF-8 string
}
enum EffectUpdate: u8 {
Added(Effect) = 1,
Removed { icon: u16 } = 2,
Changed(Effect) = 3,
}
enum ObjectUpdate: u8 {
Appeared(WorldObject) = 1,
Disappeared(WorldObject) = 2,
Moved(WorldObject) = 3,
DirectionChanged(WorldObject) = 4,
// 5 is retired.
}
struct StatusUpdate {
core: Option<CoreStatus>; // field bit 0
vitals: Option<CurrentVitals>; // field bit 1
progression: Option<ProgressionStatus>; // field bit 2
gold: Option<u32>; // field bit 3
modifiers: Option<CharacterModifiers>; // field bit 4
is_blinded: Option<bool>; // field bit 5
is_action_restricted: Option<bool>; // field bit 6
is_casting: Option<bool>; // field bit 7
}
enum AbilityUpdate: u8 {
SkillUsed { slot: u8 } = 1,
SpellBegin { slot: u8, total_lines: u8 } = 2,
SpellChant { slot: u8, line: u8, total_lines: u8 } = 3,
SpellCast { slot: u8, arguments: SpellCastArguments } = 4,
SpellCancelled { slot: u8, source: SpellCancellationSource } = 5,
}
enum ActionUpdate: u8 {
ItemUsed { slot: u8 } = 1,
ItemDropped { slot: u8, quantity: u32, position: TilePosition } = 2,
ItemGiven { slot: u8, quantity: u32, object_id: u32 } = 3,
GoldDropped { amount: u32, position: TilePosition } = 4,
GoldGiven { amount: u32, object_id: u32 } = 5,
ItemPickedUp { destination_slot: u8, position: TilePosition } = 6,
EquipmentUnequipped { slot: u8 } = 7,
Emoted { code: u8 } = 8,
Turned { source: ActionSource, direction: Direction } = 9,
Resync { resync_id: u32 } = 10,
ResyncCompleted { resync_id: u32 } = 11,
ResyncTimedOut { resync_id: u32 } = 12,
}
enum SpellCastArguments: u8 {
None = 0,
Target { id: Option<u32>, x: i32, y: i32 } = 1,
Input(String) = 2,
Values(Vec<u16>) = 3,
Unknown = 4,
}
enum SpellCancellationSource: u8 {
Client = 1,
Server = 2,
Replaced = 3,
}
struct TilePosition {
x: i32;
y: i32;
}
enum MovementUpdate: u8 {
Started {
source: ActionSource;
current: TilePosition;
destination: Option<TilePosition>;
} = 1,
Stopped {
source: ActionSource;
current: TilePosition;
destination: Option<TilePosition>;
reached_destination: Option<bool>;
reason: MovementStopReason;
} = 2,
Obstructed {
source: ActionSource;
map_id: u32;
current: TilePosition;
attempted: TilePosition;
direction: Direction;
destination: Option<TilePosition>;
mode: WalkMode;
} = 3,
}
enum ActionSource: u8 {
Unknown = 0,
Client = 1,
Command { command_id: nonzero u32 } = 2,
}
enum WalkMode: u8 {
Direct = 0,
NativeRoute = 1,
ExactRoute = 2,
Pursuit = 3,
}
enum MovementStopReason: u8 {
Completed = 1,
Obstructed = 2,
Replaced = 3,
Cancelled = 4,
PositionCorrected = 5,
}
struct CoreStatus {
level: u8;
ability_level: u8;
max_health: u32;
max_mana: u32;
weight: u32;
max_weight: u32;
stats: CharacterStats;
}
struct LocationUpdate {
x: i32;
y: i32;
map: Option<MapChange>;
}
struct MapChange {
id: u32;
name: Option<utf8>;
width: i32;
height: i32;
}
PlannedRoute encodes its source before its generation and tile count. The
generation and count are little-endian u32 values. Each tile uses two
little-endian u16 coordinates on the wire and is expanded to the public
signed coordinate type after validation. The maximum 160,001 tiles matches the
supported client’s 400 by 400 pathfinder grid plus the starting tile.
Within an ability update, fields specific to its discriminant are encoded
before the final one-byte slot. Ability slots are strict one-based values from
1 through 90. Spell input is bounded to 100 UTF-8 bytes on observed events;
values contains from one through four u16 entries.
Message participant names are limited to 15 UTF-8 bytes and message text is limited to 4 KiB at the protocol boundary. The DLL’s observed game messages are smaller still: the game-thread event queue reserves a fixed 256-byte text field and ignores a longer displayed line. Invalid UTF-8, unknown message kinds, and oversized fields reject the containing frame.
Client command names and individual arguments are limited to 255 UTF-8 bytes.
The originating public-speech packet is smaller still because its complete text
uses a one-byte length. The command argument count is encoded as u8.
Every included group is an absolute replacement value, not a delta. Most
decoded server packets produce one atomic StateEvent. Inventory and ability
packets can affect several slots, so they produce a complete ordered batch of
StateEvent values. The DLL never splits one collection batch across poll
responses, and the daemon validates and reduces the full batch before
publishing its new REST state.
Collection updates reuse the snapshot entry encodings. before and after
describe the exact occupied value on each side of the change; at least one must
be present, and any present entry must match slot. A move therefore has one
changed source slot and one changed destination slot. A swap has two changed
slots. A same-slot packet whose resulting value is identical produces no event.
Stack increases and decreases use Added and Removed; splitting, merging, or
moving an unchanged total uses Changed.
The public Server-Sent Events view emits one frame per changed collection slot.
Its batch_index and batch_count fields preserve the atomic relationship even
though the frames remain individually routable.
Ability cooldown-only transitions are presented as semantic skill.cooldown,
skill.ready, spell.cooldown, or spell.ready frames instead of collection
changed frames. A simultaneous ability-metadata and cooldown transition emits
both frames with the same event sequence. The DLL watches only submitted or
already-active ability slots. It rereads skills at their exact retained expiry
when available and otherwise polls the watched active slot until it becomes
ready.
A location update contains an absolute accepted position. map is absent for
ordinary movement and present when the position completes a map transition.
The latter replaces the map identity, name, dimensions, and coordinates in one
reducer operation, so consumers never observe a new map paired with the prior
map’s position.
A movement update describes the native queued-route lifecycle rather than a
single directional step. current is copied from the DLL’s accepted-position
cache. A destination requested through daRPC is retained until the route stops;
routes started directly through the game may have no known destination. A
stopped update carries reached_destination only when the destination is
known. It is true only when the stopped position equals that destination.
Destination presence uses a strict Boolean byte. The stopped outcome uses 0
for unavailable, 1 for false, and 2 for true, and its presence must match
the destination.
Object updates also carry absolute values. Appeared, disappeared, moved, and
direction-changed updates include the complete object at that boundary. Treat
Appeared as an upsert by object ID because a redraw can replace the retained
snapshot for an existing ID. Treat Disappeared as removal by ID.
Map transitions and refresh reconciliation publish one disappeared update for
each retained object that leaves the observed collection; there is no
collection-reset update.
ClientSnapshot.event_sequence is the event boundary already represented by
the snapshot. A controller discards queued events at or before that boundary
and applies only consecutive later events. updated_tick_ms initially equals
captured_tick_ms and advances with each applied event, while the capture tick
and duration continue to describe the last complete memory walk.
The DLL stores at most 1 MiB of pointer-free events. Overflow, a nonconsecutive
event sequence, or a nonconsecutive revision yields ResyncRequired. The
daemon then requests a fresh snapshot and resumes polling from its new boundary.
No unbounded outage replay log exists. Reconnect always starts with current
state from a fresh snapshot.
Main-thread commands
Commands use one bounded envelope. The diagnostic records execution metadata
without changing client state. Movement, skill use, and spell cast commands
carry bounded pointer-free arguments and execute through confirmed native
client functions.
struct CommandRequest {
request_id: u32;
operation: CommandOperation;
}
enum CommandOperation: u8 {
Submit {
kind: CommandKind;
timeout_ms: u16; // 1 through 5,000
wait_ms: u16; // 0 through 1,000
} = 0,
Query {
command_id: u32; // nonzero, local to one DLL instance
wait_ms: u16; // 0 through 1,000
} = 1,
Cancel {
command_id: u32;
} = 2,
}
enum CommandKind: u8 {
Diagnostic = 0,
Turn(Direction) = 1,
Walk(WalkTarget) = 2,
UseSkill { slot: u8 } = 3, // one-based, 1 through 90
CastSpell(SpellCast) = 4,
UseItem { slot: u8 } = 5, // one-based, 1 through 59
DropItem(ItemTransfer) = 6,
DropGold(GoldTransfer) = 7,
PickupItem(TilePosition) = 8,
Unequip { slot: u8 } = 9, // one-based, 1 through 18
Emote { code: u8 } = 10,
GiveItem(ItemTransfer) = 11,
GiveGold(GoldTransfer) = 12,
SwapSlots(SlotSwap) = 13,
Interact { id: u32 } = 14, // nonzero visible Mundane object ID
Dialog(DialogCommand) = 15,
Group(GroupCommand) = 16,
Who = 17,
Exchange(ExchangeCommand) = 18,
Chant { text: string8 } = 19, // 1 through 255 ASCII bytes
Legend = 20,
Raw {
direction: u8; // 0 = client to server, 1 = server to client
command: u8;
payload_length: u8;
payload: [u8; payload_length];
} = 21,
Assail = 22,
InspectPlayer { id: u32 } = 23, // nonzero visible player object ID
Resync = 24,
Message(MessageCommand) = 25,
// 26 through 28 are retired.
AddStat { flag: u8 } = 29, // strength=1, dexterity=2, intelligence=4,
// wisdom=8, constitution=16
SelectFieldMapDestination {
revision: u32;
destination_index: u8;
} = 30,
DismissMessageDialog {
revision: u32;
id: u32;
} = 31,
Look(LookTarget) = 32,
Bulletin(BulletinCommand) = 33,
}
struct BulletinCommand {
revision: u32; // zero only for Open actions
action: BulletinAction;
}
enum BulletinAction: u8 {
OpenServerList = 1,
OpenWorldBoard { x: u16, y: u16 } = 2,
OpenSection { section_id: u16 } = 3,
SelectSection { section_id: u16 } = 4,
OpenEntry { entry_id: i16 } = 5,
SelectEntry { entry_id: i16 } = 6,
LoadOlder = 7,
Scroll { position: i32 } = 8,
Navigate { direction: u8 } = 9, // back=1, forward=2, previous=3, next=4
BeginCompose { kind: u8 } = 10, // board=1, mail=2, reply=3
UpdateBoardPost {
subject: string8; // maximum 60 ASCII bytes
body: string16; // maximum 3,000 ASCII bytes
} = 11,
UpdatePlayerMail {
recipient: string8; // maximum 15 ASCII bytes
subject: string8; // maximum 60 ASCII bytes
body: string16; // maximum 3,000 ASCII bytes
} = 12,
SubmitCompose = 13,
DeleteEntry { entry_id: i16 } = 14,
HighlightEntry { entry_id: i16 } = 15,
Close = 16,
}
enum MessageCommand: u8 {
Say { content: string8 } = 0,
Shout { content: string8 } = 1,
Whisper { recipient: string8, content: string8 } = 2,
Guild { content: string8 } = 3,
Group { content: string8 } = 4,
}
`Look(Ahead)` submits the native client packet `0x09`. `Look(Tile)` submits
`0x0A x:u16be y:u16be`; these coordinates use the game packet's network byte
order even though the surrounding daRPC protocol remains little-endian. The
response carries no request ID, so the DLL permits only one typed look request
at a time and correlates the next bounded popup response with its command ID.
enum ExchangeCommand: u8 {
AddItem { slot: u8, quantity: u8 } = 1,
SetGold { amount: u32 } = 2,
Accept = 3,
Cancel = 4,
}
`Chant` becomes the client packet body `0x0E 0x02 string8`, where mode `2` is
the spell-chant channel. Text bytes are preserved exactly. The convenience NPC
actions are controller-side formatters and use this same typed command.
`Message` accepts 1 through 100 ASCII content bytes. Whisper recipients accept
1 through 15 non-whitespace ASCII bytes. Say and shout become `0x0E` packets
with modes `0` and `1`. Whisper becomes `0x19 string8-recipient string8-content`.
`SelectFieldMapDestination` accepts only the current field-map revision and a
zero-based retained destination index. The DLL revalidates both against a live,
registered, visible `FieldMapPane` and constructs client packet `0x3F` from the
retained checksum, map ID, and map coordinates. Callers cannot supply those
four travel fields. The resulting `SelectionSubmitted` update is emitted only
when the outgoing packet is observed; it does not imply server acceptance or
close the field map.
`DismissMessageDialog` accepts only the current message-dialog revision and
an opaque dialog ID. The DLL maps the ID to retained client-local state, then
revalidates the live pane before invoking the native close operation.
`Bulletin` accepts revision zero for open actions and otherwise requires the
current bulletin revision. Server-list and world-tile open, section and entry
requests, older-page loading, composition submission, deletion, and highlight
actions construct the observed client packets. Selection, scrolling, history,
composer opening and editing, and close revalidate the exact native bulletin
dialog and controls before invoking their client functions. Bulletin text is
fixed-capacity in command storage; no command or hook path allocates from the
heap. Operation status bytes and currently unknown packet fields are preserved
without inferred semantics.
Guild and group use that same directed-message packet with fixed recipients `!`
and `!!`.
`Raw` carries a bounded plaintext packet body split into a command byte and up
to 255 payload bytes. It is an intentionally unsafe semantic escape hatch: the
codec validates its direction and bounds, but it cannot validate arbitrary
game packet contents.
`Assail` submits the one-byte client packet body `0x13` through the confirmed
client packet function.
`Resync` schedules the opcode-only client refresh packet `0x38`, matching the
physical F5 behavior. Both origins enter one DLL-local coordinator. It cancels
queued route movement and defers packet submission while the native local
object reports an active visual step. Submission resumes after the staged tile
is committed, or after a changed committed tile remains stable for one
additional tick. The command's terminal status means the request was accepted
by this coordinator; it does not mean the packet or server response was
observed.
The actual outgoing packet publishes `Resync` with a nonzero DLL-local
identifier. An HTTP-triggered refresh uses its command ID as the resync ID. A
payload-free server `0x22` `RefreshUserOK` packet publishes `ResyncCompleted`
with the matching identifier after authoritative refresh activity. If that
packet is absent, the DLL publishes `ResyncCompleted` after the one-second
refresh window instead. The `ResyncTimedOut` wire discriminant remains reserved
for 1.7 compatibility, but the 1.7.0 DLL does not emit it. The daemon maps that
legacy update to the public completion event.
Only one refresh can be active. Physical and command requests received during
that transaction are coalesced and do not create another packet. See
[Refresh and resynchronization](resync.md) for object reconciliation, public
events, and consumer behavior.
enum GroupCommand: u8 {
Invite { target: string8 } = 1,
Accept { invitation_id: u32 } = 2,
Decline { invitation_id: u32 } = 3,
Toggle = 4,
}
struct DialogCommand {
revision: u32;
action: DialogAction;
}
enum DialogAction: u8 {
Select { index: u16, quantity: u8 } = 0,
Input(String) = 1, // 1 through 255 ASCII bytes
Previous = 2,
Next = 3,
Close = 4,
}
struct ItemTransfer {
slot: u8;
quantity: u32;
target: TransferTarget;
}
struct GoldTransfer {
amount: u32;
target: TransferTarget;
}
enum TransferTarget: u8 {
Tile(TilePosition) = 0,
Object { id: u32 } = 1, // nonzero
}
enum SlotSwap: u8 {
Inventory { source: u8, destination: u8 } = 0, // 1 through 59
Spellbook { source: u8, destination: u8 } = 1, // 1 through 90
Skillbook { source: u8, destination: u8 } = 2, // 1 through 90
}
struct SpellCast {
slot: u8; // one-based, 1 through 90
arguments: SpellArguments;
}
enum SpellArguments: u8 {
None = 0,
ObjectTarget { id: u32 } = 1, // nonzero
TileTarget { x: i32, y: i32 } = 2,
Input(String) = 3, // 1 through 100 ASCII bytes
}
enum WalkTarget: u8 {
Direction(Direction) = 0,
Destination { x: i32, y: i32 } = 1,
Route {
map_id: u32;
tile_count: u16; // 1 through 256
tiles: [RouteTile; tile_count];
} = 2,
Cancel = 3,
}
struct RouteTile {
x: u16;
y: u16;
}
enum CommandState: u8 {
Accepted = 0,
Executed = 1,
Failed = 2,
Cancelled = 3,
TimedOut = 4,
}
struct CommandStatus {
command_id: u32;
kind: CommandKind;
state: CommandState;
enqueued_tick_ms: u32;
deadline_tick_ms: u32;
started_tick_ms: Option<u32>;
completed_tick_ms: Option<u32>;
execution_us: Option<u32>;
main_thread_id: Option<u32>;
failure: Option<CommandFailure>;
}
enum CommandFailure: u8 {
Internal = 0,
InvalidState = 1,
InvalidDestination = 2,
Rejected = 3,
NoPath = 4,
InvalidSkill = 5,
InvalidSpell = 6,
InvalidArguments = 7,
InvalidTarget = 8,
InsufficientMana = 9,
Resist = 10,
NotAllowed = 11,
}
struct CommandResponse {
request_id: u32;
result: CommandResult;
}
enum CommandResult: u8 {
Status(CommandStatus) = 0,
Busy = 1,
NotFound = 2,
Unavailable = 3,
Who { status: CommandStatus, list: WhoList } = 4,
Legend { status: CommandStatus, marks: Vec<LegendMark> } = 5,
Player { status: CommandStatus, id: u32, profile: PlayerProfile } = 6,
ExactRouteInvalidState {
status: CommandStatus,
diagnostics: ExactRouteInvalidState,
} = 7,
}
struct ExactRouteInvalidState {
reason: ExactRouteInvalidStateReason;
route_map_id: u32;
packet_map_id: Option<u32>;
native_map_id: Option<u32>;
packet_position: Option<TilePosition>;
native_position: Option<TilePosition>;
staged_position: Option<TilePosition>;
transition_active: Option<bool>;
route_mode: Option<WalkMode>;
current_destination: Option<TilePosition>;
}
struct WhoList {
world_count: u16;
country_count: u16;
players: Vec<WhoPlayer>; // u16 count, maximum 768
}
struct WhoPlayer {
name: string8; // at most 24 UTF-8 bytes
title: string8; // at most 48 UTF-8 bytes
class: CharacterClass;
state: UserState;
color: u8;
is_master: bool;
is_guildmate: bool;
}
InvalidState includes exact-route installation while the packet-confirmed map
or position disagrees with the client’s effective native origin. That origin is
the committed tile while idle and the staged destination during an active
transition. Result tag 7 carries the rejected state without changing the
existing route.
Each optional field is encoded as a strict Boolean followed by its u32 value
when present. Submission only validates and copies bounded scalar values on the
IPC worker. Execution occurs later through the client tick hook. Directions use
the same strict discriminants as object facing. Destination coordinates are
signed wire values and must satisfy the live zero-based map bounds before native
pathfinding. Skill slots are strict one-based values from 1 through 90; the DLL
also requires the live entry to retain the requested slot before activation.
Spell slots use the same range. The DLL checks the live spell’s expected
argument type, current map or object target, action delay, and denial state
before calling the native spell routine. A new spell may replace a delayed cast
already in progress.
Drop commands accept only tile transfers and give commands accept only object
transfers. Slot swaps submit the client’s normal 0x30 rearrangement packet
with the collection discriminator followed by source and destination slots.
Busy is an
immediate response when all fixed queue entries are pending, and Unavailable
means the tick execution path is not installed. Terminal results are retained
for bounded status queries and may be evicted under command pressure.
Who submits the client’s ordinary server request on the main thread and stays
accepted until its matching response arrives. The result preserves server row
order. Requests share an in-flight or completed command for one second and use
a three-second command deadline. The DLL suppresses the stock Who panel only
for a correlated daRPC request. A player-started request remains untouched.
InspectPlayer submits 43 01 <u32be id> and stays accepted until the matching
0x34 response arrives. Internal requests are correlated by object ID and send
order. Only their responses skip the stock other-player pane; player-started
requests run the original handler and still refresh the profile cache. Its
terminal command result carries the complete player object captured with the
profile, rather than requiring the caller to merge data from a separate
snapshot.
Command deadlines and queue delay use the same wrapping millisecond tick as
frame timestamps. execution_us uses a higher-resolution local duration so a
short diagnostic can still report sub-millisecond work. A disconnect drops no
pointer because queued commands contain no client or controller addresses.
Ordering and time
Each sender maintains its own sequence counter for each connection. It starts at zero, increments for every frame, and wraps from 65,535 to zero. A receiver expects the same progression. A mismatch is a connection-level protocol error; it does not silently resynchronize.
State-event sequence and revision counters are separate wrapping nonzero u32
values. The event sequence orders state mutations; the revision orders both
full snapshots and mutations. They wrap from u32::MAX to one. A gap in either
counter causes a fresh snapshot instead of attempting to infer a lost value.
On Windows, the sender tick comes from
timeGetTime,
the millisecond clock also used by the supported client’s dispatchers. It is
elapsed time since Windows started, not wall-clock time. It wraps every 2^32
milliseconds, about 49.71 days, so elapsed time is calculated as
end.wrapping_sub(start).
The tick is diagnostic metadata for event comparison, sequencing, round-trip time, and other observability. Millisecond resolution is sufficient for these uses. daRPC does not change the system multimedia timer resolution merely to stamp frames. The codec accepts the tick from its caller and has no Windows dependency.
Samples carried by different processes are coarse observations and must not be
used alone to assert strict event ordering. darpc.exe measures round-trip time
from two timeGetTime samples in its own process; the remote request and
response ticks remain visible for comparison and diagnosis.
Validation rules
Protocol handling is deliberately strict. The codec rejects invalid magic, unsupported frame versions, unknown message types, nonzero flags, invalid architecture values, invalid version ranges, invalid UTF-8, truncated fields, invalid boolean bytes, command discriminants, command limits, zero command identifiers, oversized lengths, arithmetic overflow, and trailing bytes. Lengths are checked before allocation. The session layer rejects unsupported negotiated versions, invalid message order, mismatched instance IDs, and sequence gaps.
Malformed input returns a structured error. It must never panic, read past the provided bytes, guess where another frame begins, or silently accept a value it does not understand.
Golden Hello frame
The codec tests use this exact 95-byte frame: a 20-byte header followed by a
75-byte Hello payload.
44 52 50 43 01 00 01 00 34 12 00 00 12 34 56 78
4b 00 00 00 00 01 00 01 00 01 02 03 04 05 06 07
08 09 0a 0b 0c 0d 0e 0f 44 33 22 11 08 07 06 05
04 03 02 01 01 01 00 00 00 00 00 a0 a1 a2 a3 a4
a5 a6 a7 a8 a9 aa ab ac ad ae af b0 b1 b2 b3 b4
b5 b6 b7 b8 b9 ba bb bc bd be bf e5 02 00 00
The fixture uses protocol range 1.0 through 1.0, sequence 0x1234, sender tick
0x78563412, process ID 0x11223344, process creation time
0x0102030405060708, DLL version 1.0.0, and client version code 741. Tests both encode
to these bytes and decode them back to the expected values.
Accepted design decisions
- The 20-byte header is intentionally fixed. No current field justifies making it variable or larger.
- The 4 MiB payload cap is bounded but large enough for four maximum native
route revisions in one event poll. Route coordinates use compact
u16wire values; public API coordinates remain signed integers for consistency. - Hello identity is sufficient for stale and accidental pipe connections. It is not treated as security authentication.
- The
u16sequence supports ordering diagnostics now and can support a future bounded buffer or replay design. Request correlation remains a separateu32value. timeGetTimeis the shared diagnostic clock because it matches the client and provides adequate millisecond resolution for round-trip and sequencing data.- Unknown values, gaps, and trailing bytes remain strict errors unless real
interoperability evidence shows that a specific rule should be relaxed. The
0x34player response is one documented exception: the stock client accepts a presence-only portrait marker and ignores extension bytes after the bounded known fields. - Echo text remains limited to 4 KiB. A future domain field with a known larger
bound, such as message-board content near
0x8000bytes, should receive an explicit field-specific limit or chunking design rather than silently lifting every string limit.
The implementation maps directly to this chapter: framing is in
crates/protocol/src/frame/mod.rs, command messages are in command/mod.rs,
remaining message fields are in message/mod.rs, handshake and sequence rules
are in session/mod.rs, and the exact fixture and malformed-input coverage are
under crates/protocol/tests/.
Roadmap
The original milestone roadmap guided daRPC from an empty workspace to its first stable release. That sequence is complete and has been retired. This page now records work that may follow 1.0 without implying that it is required for the supported 1.0 feature set.
The Web API, Live events, and Game data chapters describe current behavior. The architecture, protocol, and safety requirements remain the design sources of truth.
1.0 foundation
Version 1.0 supports the exact Dark Ages 7.41 client build documented in the README. Its stable foundation includes:
- validated x86 injection, launch, initialization, shutdown, and unload;
- direct typed commands over the versioned named-pipe protocol;
- multi-client discovery and aggregation through the local daemon;
- current state through REST and ordered changes through Server-Sent Events;
- bounded main-thread actions for movement, abilities, inventory, dialogs, groups, exchanges, communication, Who, and legend data;
- generated OpenAPI, vendored Swagger UI, and a versioned Windows binary release with SHA-256 checksums; and
- native Windows integration checks for the loader, hooks, protocol, daemon, and supported process lifecycle.
These capabilities define the 1.0 release. Later roadmap items extend or harden them and do not change what 1.0 claims to support.
Post-1.0 hardening
Potential hardening work includes:
- longer multi-client and daemon-restart soak tests;
- malformed-protocol corpus testing and parser fuzzing;
- dependency advisory and license checks in continuous integration;
- privacy-preserving crash diagnostics;
- a documented security-reporting process;
- signed Windows release binaries; and
- qualification of additional Dark Ages client builds without weakening exact executable validation.
Post-1.0 capabilities
Potential capability work includes:
- immutable bounded local rules that always fail open;
- a derived shared-world view that preserves source, age, and uncertainty;
- additional direct CLI views where they improve automation;
- richer event replay only when a bounded consumer requirement is proven; and
- new typed actions built on confirmed client and game terminology.
Remote access
The daemon defaults to loopback and supports an explicit unauthenticated IPv4 bind for trusted host-to-VM and local-network development. A general remote access mode remains deferred until it has authentication, authorization, request limiting, and transport security. The current listener must not be exposed to an untrusted network or the public internet.
Prioritization
Post-1.0 work should remain small and evidence-driven. A proposed feature should identify the concrete user workflow, its safety boundary, and how it will be tested before it expands the protocol or public API.
Safety and security
Read this chapter before exposing the daemon beyond the local machine, automating client actions, or changing injection, hook, and memory code.
daRPC crosses several sensitive boundaries: injected code, client memory, Windows application binary interfaces, local IPC, and potentially remote web access. Those boundaries must remain explicit and small.
Unsafe Rust and client memory
- Put unsafe operations behind audited interfaces and use explicit
unsafeblocks. - Document every unsafe block with the invariants that make it valid.
- Validate address, alignment, lifetime, size, and readability assumptions before constructing references from client memory.
- Model client layouts as version-specific. Never reuse offsets or relative virtual addresses for an unverified executable.
- Check pointer chains and lengths at every trust boundary.
- Define calling conventions, integer widths, packing, and ownership for every foreign or client ABI boundary.
- Do not unwind across a foreign function or hook boundary.
Hooks and process stability
Hook installation and removal must be transactional and safe to repeat. Original client behavior should be preserved unless a valid request explicitly blocks an event. Injected code must not retain daemon-owned resources after a disconnect or unload.
DllMain does not provide a process-wide pause. The Windows loader lock
serializes loader activity, but unrelated client threads may continue executing.
Hook installation must therefore remain outside DllMain and occur through
explicit initialization.
Prepare and validate the complete hook plan before changing client code. Decode the replaced instructions, allocate and populate every trampoline, and prepare rollback data while client threads remain runnable. The commit phase should then be as short as possible:
- Suspend or enlist the affected threads.
- Reject or safely redirect instruction pointers within a replaced range.
- Change page protections and apply the complete patch set.
- Flush the instruction cache and restore page protections.
- Roll back every changed entry point if any commit step fails.
- Resume every thread suspended by the transaction.
Do not suspend client threads across general allocation, logging, IPC, or other unbounded initialization. A suspended thread may own a heap or synchronization lock needed by the initialization thread. A hook-enabled launch should keep the new process’s primary thread suspended until the hook transaction commits. A late attach requires the short transactional commit above.
Hook removal follows the same rules in reverse. Shutdown must prevent new hook
entries, drain in-flight callbacks, restore original code transactionally, and
prove that no thread can return through DLL-owned code before FreeLibrary.
Substantial allocation, logging, IPC, or cleanup must not occur inside time-sensitive hooks or under the Windows loader lock. A daemon, consumer, or protocol failure must not terminate the game process.
IPC and web boundaries
Named pipes should be local-only and restricted through an explicit security descriptor. Protocol and API inputs require size limits, validation, bounded queues, and useful errors. Logs must not expose credentials, authentication material, private chat, or complete sensitive packet payloads by default.
Remote web access requires an explicit security model. Listening beyond the local machine without authentication and transport protection should not be a default configuration.
The raw packet interface validates representation and size, not game protocol semantics. It can deliberately feed arbitrary bytes into native client packet paths. Malformed input can disconnect the session, corrupt client state, or crash the client or server, so raw sends must never be treated like ordinary validated API actions.
Test data
Do not commit copyrighted client binaries, game assets, secrets, personal data, or live-server captures containing private information. Prefer synthetic fixtures for client layouts, state transitions, and protocol parsing.
Development
This chapter is for contributors building, testing, documenting, or reviewing daRPC. Player-facing API usage is documented under Using daRPC.
The Rust workspace separates runtime components from shared domain and platform boundaries:
| Package | Role |
|---|---|
darpc-model | Shared domain state, actions, and updates. |
darpc-protocol | Versioned binary interprocess communication framing and codecs. |
darpc-win32 | Shared Windows platform boundaries. |
darpc-game-client | Supported game-client layouts and application binary interface boundaries. |
rpc-client | Direct single-client binary protocol command-line interface. |
rpc-dll | Injected client component. |
loader | Client launcher and injector. |
rpc-daemon | Client aggregator and web API daemon. |
The project supports one exact game-client build at a time. darpc-game-client
keeps its verified fingerprint, layouts, addresses, and application binary
interface assumptions together. Supporting another build requires updating or
forking that contract rather than adding parallel version-named crates.
The runtime targets are:
| Component | Rust target |
|---|---|
darpc.dll | i686-pc-windows-msvc |
loader.exe | i686-pc-windows-msvc |
darpc.exe | x86_64-pc-windows-msvc |
darpcd.exe | x86_64-pc-windows-msvc |
The shared crates can be checked independently of the Windows components:
cargo check -p darpc-model -p darpc-protocol
Component builds and checks should specify their intended target.
On Windows Arm, a native Arm64 Rust toolchain needs the matching Arm64 MSVC libraries to link dependency build scripts and procedural macros. When the VM has only the x64 MSVC tools, install Rust’s x64 host toolchain and run it under Windows x64 emulation from an x64 Developer Command Prompt:
rustup toolchain install stable-x86_64-pc-windows-msvc `
--profile minimal `
--force-non-host
rustup +stable-x86_64-pc-windows-msvc target add i686-pc-windows-msvc
cargo +stable-x86_64-pc-windows-msvc build -p rpc-daemon -p rpc-client
cargo +stable-x86_64-pc-windows-msvc build `
-p loader -p rpc-dll -p injection-target `
--target i686-pc-windows-msvc
This workaround is unnecessary on native x64 Windows or when the Arm64 MSVC workload is installed.
The controlled IPC integration test requires both architectures. Keep build outputs under one stable Windows-local target root and reuse it across builds. Cargo separates the explicit target architectures below that root, so milestone- or test-specific target trees are unnecessary. Then pass the two artifact directories to the script:
$env:CARGO_TARGET_DIR = "C:\cargo-target\da-rpc"
cargo build -p loader -p rpc-dll -p injection-target `
--target i686-pc-windows-msvc
cargo build -p rpc-client --target x86_64-pc-windows-msvc
& .\tools\test-ipc.ps1 `
-X86TargetDir "$env:CARGO_TARGET_DIR\i686-pc-windows-msvc\debug" `
-X64TargetDir "$env:CARGO_TARGET_DIR\x86_64-pc-windows-msvc\debug"
The script uses the inert injection-target.exe and a debug-only unsupported
client bypass. It verifies hello, ping, byte-exact echo, tick-hook
health, missing and busy pipe errors, malformed-client isolation, reconnect,
and bounded cancellation during shutdown. The controlled target reports the
hook as not installed. Its DLL log must contain the skipped-hook and health
sample records. The bypass is unavailable in release builds and is never a
substitute for validation against the supported client.
The daemon registry integration test uses two controlled targets and both runtime architectures:
cargo build -p loader -p rpc-dll -p injection-target `
--target i686-pc-windows-msvc
cargo build -p rpc-client -p rpc-daemon `
--target x86_64-pc-windows-msvc
& .\tools\test-daemon.ps1 `
-X86TargetDir "$env:CARGO_TARGET_DIR\i686-pc-windows-msvc\debug" `
-X64TargetDir "$env:CARGO_TARGET_DIR\x86_64-pc-windows-msvc\debug"
It starts the daemon before injection, connects both targets, verifies exclusive
pipe ownership, inspects both identities through /clients, checks
/health, OpenAPI 3.1, and vendored Swagger assets, and exercises the default
and overridden HTTP ports. It then restarts the daemon, replaces one DLL
instance, confirms the other client stays connected, and verifies occupied-port
failure. Incompatible negotiation is exercised by the native Windows
controller-session test.
Releases
Push a vMAJOR.MINOR.PATCH tag, such as v1.0.0, to create a Windows release.
The tag must match the shared Cargo workspace package version. The Windows
integration job must pass before the release job builds and publishes the
runtime artifacts. The versioned archive contains:
darpc.dllandloader.exefori686-pc-windows-msvcdarpc.exeanddarpcd.exeforx86_64-pc-windows-msvcopenapi.json, exported from the packageddarpcd.exeand validated as the daRPC OpenAPI 3.1 document- the README, license, and per-file SHA-256 checksums
The GitHub Release also includes a SHA-256 checksum for the complete archive.
The release job uses tools/package-release.ps1 to reject missing or
wrong-architecture binaries before packaging. Test harnesses and debug builds
are not release artifacts.
Release binaries are currently unsigned. Microsoft Defender SmartScreen may show an unrecognized-app warning until a code-signing process is introduced. Users should verify the published SHA-256 checksum and must not be instructed to disable antivirus protection.
Documentation
The repository pins mdBook 0.5.4 for reproducible local and CI builds.
cargo install mdbook --version 0.5.4 --locked
mdbook build docs
mdbook serve docs --open
Pull requests that change the book run the documentation build. Pushes to
main build the same sources and deploy the generated artifact to GitHub
Pages.
Collaboration
Agents may implement requested changes and also act as reviewers, mentors, debugging partners, and pair-programming partners. The project owner sets product direction and retains ownership of the repository. See the repository’s AGENTS.md for the full guidance.
Commits
Use Conventional Commits with short, focused imperative summaries:
feat(protocol): add handshake negotiation
fix(loader): validate target process architecture
docs(book): explain daemon recovery
test(state): cover incomplete initial snapshots
Do not use emoji or em dashes in code, documentation, or commit messages.
Legal
daRPC is available under the MIT License.
Dark Ages is copyright Nexon Korea Corporation and is licensed to KRU Interactive in the United States and Canada. All rights reserved.
daRPC is an independent project for educational, research, and interoperability purposes. It is not affiliated with or endorsed by Nexon Korea Corporation or KRU Interactive.