View on GitHub

FOnline: The Life After

Fallout-like game based on the FOnline Engine

AI Control Bridge (TLA)

The AI control bridge lets an external tool (an MCP host / model) observe and control a real TLA client over a localhost TCP socket, without giving server/admin powers. One bridge connection controls one client; commands become ordinary client actions and are still validated server-side through the normal RPC flow (cooldowns, visibility, range, item ownership, dialog state).

This is a foundation port adapted from the sibling project (H:/lf-29). The native bridge is shared verbatim; the script layer (Scripts/AiControl.fos) was rewritten against TLA’s own client API. It covers observation, events, the core player-control commands, register/login, and dialog observation, all verified end-to-end against a running client. Higher-level features that depend on systems TLA does not yet expose the same way (roster, admin prep, factions, overwatch, global-map interests, the advisory/agent-runtime layer, content-specific playtest runners) are added incrementally — see the roadmap at the end.

Architecture

Two layers:

Tools/AiControlMcp/ai_control_mcp.py wraps that TCP protocol as a stdio MCP server for model hosts. The embedded-client index used for multi-client port spacing comes from Game.GetEmbeddedClientIndex() (SourceExt/ClientExtension.cpp).

The server still validates every resulting gameplay request normally; the bridge is not a server-authority bypass.

Configuration

Declared in Scripts/AiControl.fos (///@ Setting Client ...) with defaults in TLA.fomain:

Setting Default Purpose
AiControl.Enabled False Start the client TCP bridge when the client boots (Game.OnStart)
AiControl.Host 127.0.0.1 Bind address for the TCP listener
AiControl.Port 43011 TCP port used by external adapters
AiControl.PortStride 1 Port spacing for embedded multi-client launches: client index N binds Port + (N-1)*PortStride
AiControl.Token empty Optional shared token required before non-auth bridge calls
AiControl.MaxQueuedCommands 64 Back-pressure limit for queued commands
AiControl.MaxEvents 512 Ring-buffer size for the client event history
AiControl.ObservationInterval 250 Milliseconds between observation snapshots
AiControl.MaxObservedEntities 80 Per-snapshot cap for visible critters / map items / inventory
AiControl.SkipRosterOnLogin False Reserved (roster not ported in v1)
AiControl.ProfileObservationThresholdMs 0 Reserved (profiling not ported in v1)
AiControl.CaptureRawInputEvents False Reserved (raw input capture not ported in v1)
AiControl.AllowQaCommands False Gate (Common, read on client+server) for the qa_* setup/fixture commands. Keep off outside test harnesses.

Keep the listener on loopback. It binds the local client as a remotely controllable process if exposed.

Native bridge protocol

ClientAiBridge.cpp listens for newline-delimited JSON-RPC-like messages. Request shape:

{"jsonrpc":"2.0","id":1,"method":"observe","params":{}}
Method Params Result
auth { "token": "..." } { "authorized": true/false }
ping {} { "ok": true }
status {} Queue sizes, host/port, observation sequence, last native error
observe {} Latest structured observation snapshot
events { "afterSeq": 0, "limit": 100 } Client events after a sequence number
act command object Enqueues a command for the next client loop

If AiControl.Token is non-empty, call auth first on every connection. The MCP adapter does this automatically when TLA_AI_TOKEN / --token is supplied.

Observation (schemaVersion 1)

Top-level keys: schemaVersion, seq, clientIndex, connected, hasMap, hasChosen, account, screen, mouse, chosen, map, critters, mapItems, inventory, quests, dialog, availableActions.

Consumers must branch on connected, hasMap, hasChosen instead of assuming a current player/map/chosen.

Events

Pushed for client lifecycle/world changes: bridge_started / bridge_stopped, connecting / connecting_failed / connected / disconnected, login_success, info_message, input_lost, map_load / map_unload, critter_in / critter_out, item_map_in / item_map_out, item_inv_in / item_inv_out, receive_items, screen_change, console_message, and the native command_completed. The native bridge also emits runtime_exception events when the engine log shows script exceptions, assertions, or fatal/error markers.

The event buffer is in-memory only. Keep the latest seq from events and ask for events after it.

Commands

Queued through act, consumed by the client loop, routed through normal player intent:

Type Main params Behavior
register optional stringArg (name) Register a fresh character and enter the game through PlayerRegistration (default name TestBot<clientIndex>; builds a valid SPECIAL/skills/traits set). Drives the normal Game.Connect + RegisterNewPlayer path without TryExit.
login optional stringArg (name) Log in an existing character through LoginExistingPlayer.
move_to_hex x, y, optional intArg (cut distance), append Tla::ChosenMove
talk_to targetId, append Tla::ChosenTalkNpc
loot_critter targetId, append Tla::ChosenPickCrit
attack_entity targetId, intArg (mode) CurPlayer.ServerCall.Attack
pick_item itemId, append Tla::ChosenPickItem (proto/hex resolved from the item)
pick_hex x, y, append Pick a visible map item at the hex
use_item itemId, optional exactly one targetId/auxId, optional stringArg (timer:<seconds>) Tla::ChosenUseItem on self, one critter, or one item. Timer mode is self-only and accepts only the canonical range timer:1 through timer:599 (no sign, whitespace, leading zeroes, target, or trailing text).
use_skill stringArg (skill CritterProperty name, e.g. SkillFirstAid/SkillLockpick/SkillRepair), optional targetId/itemId/x/y; static scenery also requires sceneryProtoId CurPlayer.ServerCall.UseSkill. With no target it applies to the chosen (self-heal etc.); scenery proto and hex are transported separately.
reload itemId, auxId (ammo) CurPlayer.ServerCall.ReloadWeapon
unload itemId Tla::ChosenUnloadWeapon
move_item itemId, intArg (slot) CurPlayer.ServerCall.MoveInvItem
drop_item itemId, intArg (count) CurPlayer.ServerCall.DropInvItem
operate_container itemId, intArg (bit0 take, bit1 all, >>2 count) CurPlayer.ServerCall.OperateContainer (loot/container grid: take items)
craft intArg (craft id) CurPlayer.ServerCall.Rpc_CraftItem(FixboyButton, id, 0) — runs a FixBoy recipe (server checks skill/materials/tools, consumes resources, grants the item)
toggle_sneak append Tla::ChosenSneaksneak is stubbed in TLA, returns sneak_not_implemented_in_game
dialog_answer intArg (answer index; special links: 0xF1=close, 0xF2=barter) CurPlayer.ServerCall.SpeechAnswer
close_dialog none Semantically close the active dialog with SpeechAnswer(0xF1)
say stringArg, intArg (SayType) CurPlayer.ServerCall.ReceiveChosenSay
barter_transfer itemId, stringArg source, positive intArg count Move a stack between player_inventory/player_offer or trader_inventory/trader_offer in the active Barter UI
barter_offer none Submit the assembled active barter offer through the normal client/server path
barter_return_dialog none Return from active NPC barter to its dialog through the current validated transfer session
clear_actions none Clear queued client actions
close_screen / show_screen / hide_screen stringArg (GuiScreen name) GUI screen control
show_context_screen stringArg (Aim, Use, Timer, Split, or SkillBox), real item/target ids as required Open a contextual screen with its normal OnShow parameter contract after validating the observed target, item ownership, stack, and timer/use capabilities.
ui_answer intArg (index) or stringArg (answer_N/level_N); auxId is the expected DialogBox session Answer a semantic DialogBox/elevator prompt. DialogBox requires the session from the same observation and rejects a missing/stale session; elevator answers reject a DialogBox session.
save_screenshot stringArg (path) Game.SaveScreenshot (framebuffer readback)
set_resolution x, y Game.SetResolution
toggle_fullscreen none Game.ToggleFullscreen
set_mouse_pos screenX, screenY Game.SetForcedMousePos
mouse_click screenX, screenY, intArg (MouseButton) Game.SimulateMouseClick
key_press intArg (KeyCode), stringArg Game.SimulateKeyboardPress
environment_query intArg (queryId), screenX/screenY (from-hex, -1 = chosen), x/y (to-hex), stringArg (options) Client geometry/path query; publishes an environment_query_result event by queryId.

Commands not yet supported in TLA return unsupported:<type> and success=false.

QA setup commands (test-only)

Gated by AiControl.AllowQaCommands = True (off by default, checked on both client and server). They route through server RemoteCalls that move the player’s controlled critter; they are a test-harness setup surface, not normal player abilities. Use them to reach content for mechanics/quest runs.

Type Main params Behavior
qa_teleport_hex x, y Same-map reposition (Critter.TransferToHex); the engine snaps to the nearest movable hex. Useful to reach otherwise-blocked spots.
qa_teleport_map stringArg (locationPid or locationPid/mapProto), optional x/y Teleport to a content map. The client validates both proto ids and sends them as separate known hstring values; the server resolves the requested map inside that location, creates a valid unloaded location when needed, and uses entry "0" when no hex is given. Unknown targets fail as unknown_map_target without a server exception.
qa_teleport_global none Critter.TransferToGlobal. Note: a no-op from the repl1 replication/limbo start map (it has no global coordinates); use qa_teleport_map to reach content.
qa_set_prop stringArg (CritterProperty name), intArg (value), optional x request id Set an int CRITTER property (cr.SetAsInt) — reputation/loyalty values, a prior quest stage, CurrentHp, limb-damage flags, etc. A successful script-side write publishes qa_prop_set ({prop, value, requestId}); a missing/delayed matching event is retried by the caller.
qa_set_game_prop stringArg (GameProperty name), intArg (value) Set an int GAME property (Game.SetAsInt) — world/quest flags that live on the game singleton, not the critter (e.g. DenVirginIsAway). Many dialog demands gate on these, so qa_set_prop alone can’t satisfy them.
qa_give_item stringArg (item proto), intArg (count) Give an item (cr.AddItem) — for quest items the giver doesn’t hand out, or starting gear.
qa_get_prop stringArg (CritterProperty name), optional intArg request id Authoritative SERVER-side read of an int critter property. The server reads cr.GetAsInt and calls the client back (AiControlReceiveQaProp), which publishes a qa_prop_value event ({prop, value, requestId}). Needed because ~1/3 of quest flags are Server-scope (not OwnerSync) and therefore never appear in the client observation’s quests — the only way to verify them is this round-trip.
qa_get_text stringArg (dialog id), intArg (string number) Resolve a numbered NPC floating-text via MsgStr::DialogTextKey and return it in the command message (text=<…>). Debug tool for the numbered-text fix below; empty means the string isn’t authored for that dialog.
qa_get_text_pack intArg (numeric id) Resolve a row from the baked general Text pack for the client’s active language via TextPackKey(TextPackName::Text, …) and return it as text=<…>. This verifies the runtime localization surface rather than only the authored .fotxt file.
qa_format_tags stringArg (text with tags) Run a raw string through Game.FormatTags (resolves @text/@arg/@sex/… client-side) and return the result (result=<…>). Used to verify the three-token @text Dialogs <dialogName> <key>@ path end-to-end.
qa_show_dialog_box none Request a real server-backed AskFollowGlobalGroupRuler DialogBox with two answers for semantic/session and screenshot tests. The typed MCP wrapper is tla_qa_show_dialog_box; answer_1 is the safe no-op choice.

qa_get_prop is asynchronous: the value arrives as a qa_prop_value event, not in the command’s own completion message. Snapshot the event cursor (max seq), send qa_get_prop with a non-zero request id, then poll events for the qa_prop_value whose prop and requestId both match. The correlation is required under load because a delayed response to an earlier read may arrive after the new cursor. tla_quest_runner.py’s read_quest does exactly this — it reads the client observation first (fast, for OwnerSync quests) and falls back to a correlated qa_get_prop when the property is absent client-side (Server-scope quests such as KlamVaccination).

qa_set_prop has the same correlation requirement, with the request id transported in x because intArg already carries the property value. tla_quest_runner.py waits for a matching qa_prop_set event and resends the command when no matching acknowledgement arrives. The acknowledgement is sent only after SetAsInt. Entity locking remains an explicit Game.Sync cover in the [[Async]] script callback; no called engine function performs implicit synchronization.

qa_teleport_map accepts locationPid/mapProto (e.g. vault_city/vcity_courtyard) to land on a specific sub-map of a multi-map location. The split is intentional: a synthetic combined hstring is not part of baked metadata and would be rejected by engine inbound validation. qa_set_prop / qa_give_item are the prerequisite-setup surface for quests gated by reputation, faction loyalty, prior quest state, or required items — set those up, then run the quest’s normal dialog flow.

qa_teleport_map is the practical way to put a test character on a real content map (e.g. qa_teleport_map arroyo lands on the arroyo village map with its real NPCs). A freshly created location instance is a sandbox copy of the proto’s maps/NPCs — good for exercising map mechanics/dialogs, but not tied to the canonical world location’s quest state.

Environment queries

environment_query runs advisory geometry/path checks against the currently replicated map and publishes an environment_query_result event matched by queryId. Options are "kind;name=value;...":

The adapter wraps these as tla_env_path / tla_env_obstacles / tla_env_trace.

MCP adapter

Tools/AiControlMcp/ai_control_mcp.py is a dependency-free stdio MCP adapter (ported from the sibling project, renamed to the tla_ / TLA_AI_* / tla:// namespace). Supporting modules: ai_control_runner.py (stdio MCP client + command/observation/launch/log helpers), ai_control_advisory.py, ai_control_guides.py, ai_control_launch.py (launch manifest from TLA.fomain + .vscode/tasks.json), ai_control_protocol.py. smoke_ai_control_mcp.py is the smoke runner.

Environment variables: TLA_AI_HOST (127.0.0.1), TLA_AI_PORT (43011), TLA_AI_TOKEN (empty), TLA_AI_TIMEOUT (3), TLA_WORKSPACE_ROOT (adapter grandparent).

Core MCP tools include tla_ping, tla_status, tla_observe, tla_step, tla_sync, tla_events, tla_next_events, tla_act, tla_clear_actions, the typed command tools (tla_move_to_hex, tla_talk_to, tla_attack_entity, tla_pick_item, tla_use_item, tla_use_skill, tla_craft, tla_barter_transfer, tla_barter_offer, tla_barter_return_dialog, tla_close_dialog, tla_show_context_screen, tla_ui_answer, tla_qa_show_dialog_box, …), launch tools (tla_launch*, tla_processes, tla_logs), and the static schema/guide resources. Many advisory/agent tools were carried over from the sibling project and still assume sibling-project content; treat them as provisional until adapted to TLA content.

TLA observations expose map positions as flat hexX / hexY fields. The navigation adapter normalizes those fields to its generic hex: {x, y} shape for the chosen critter, visible critters, and map items before planning. The current TLA bridge implements path, but not the sibling project’s tactical_path query. tla_nav_plan and tla_find_safe_step therefore fall back to path only when the bridge explicitly reports unknown_environment_query or unsupported_environment_query; the result records queryFallback, while every other environment-query failure is returned unchanged.

Verified engine screenshots

tla_save_screenshot captures the composed render target (world plus GUI). Use a standalone graphical client for visual regression runs: the embedded headless client produced structurally valid TGA files whose framebuffer was entirely black in the verified Windows setup. The headless mode remains useful for non-visual bridge/gameplay automation. The tool always waits for its own command_completed; callers do not need to set waitForCompletion. Before capture it optionally waits settleMs (250 ms by default), removes an old target, and confines the optional .tga path to TLA_WORKSPACE_ROOT. With no path it writes a timestamped file under Workspace/AiControlScreenshots/.

The MCP capture result is a verification record, not just an acknowledgement: absolute and workspace-relative paths, existence/byte size, dimensions, pixel depth, SHA-256, TGA payload boundaries, sampled color/luminance metrics, blankLike, command completion, and verified. verified=true means command completion succeeded, the new file is an uncompressed 24/32-bit true-color TGA with a complete payload, and the sampled frame is not blank-like. Bad input (wrong extension or a path escape) is MCP -32602; command/file/format/blank failures are returned as verified=false with a structured failure.stage and failure.message. This generic check proves that a frame was produced, not that a particular window rendered its controls; the audit runners add content-specific ROI oracles below.

For deterministic screen audits, direct tla_show_screen is safe only for parameterless screens in the right game state:

Audit path Screens Notes
Direct show/capture/hide while in game Options, Inventory, Character, PipBoy, FixBoy, Menu, Credits No required params; inventory/character/PDA/crafting still need a chosen critter for meaningful content
Open through the real mechanic, then capture Dialog, Barter, PickUp, Split, Aim, Radio, Timer, Use, SkillBox, Elevator, TownView, GmTown, Say, SayExtended, DialogBox, InputBox Their screen code consumes a target/item/location/dialog context; direct show can assert or render invalid state
State-owned roots Login, Registration, Game, GlobalMap, Wait Reach these through connection, registration, map transfer, or wait state rather than stacking them over gameplay

The standalone audit runner uses the shared MCP client, waits for show/hide completion, checks that each screen became active, and applies a content oracle: controls for Options, stats for Inventory/Character, PipBoy body, FixBoy recipes, Menu buttons, and Credits text. Unknown screens are not marked verified unless the diagnostic --allow-generic opt-in is supplied. The runner writes a JSON manifest:

python Tools/AiControlMcp/tla_gui_screenshot_test.py
python Tools/AiControlMcp/tla_gui_screenshot_test.py --screens Options,Inventory,PipBoy \
  --output-dir Workspace/AiControlScreenshots/gui-smoke

tla_context_gui_playtest.py covers nine parameterized windows: SkillBox, Aim, Split, Timer, Use, PickUp, Radio, Elevator, and DialogBox. It selects real observed items/targets using the capability metadata above, opens the first five through tla_show_context_screen, opens PickUp and Radio through their normal mechanics, captures Elevator when already active or enters it through an authored trigger hex, and obtains DialogBox from tla_qa_show_dialog_box. Each capture has a window-specific ROI oracle. The Aim oracle checks its authored green labels rather than the gold text used by several other windows. DialogBox is closed with tla_ui_answer using expectedSession; a delayed answer cannot act on a replacement prompt. Its semantic buttons also expose safety metadata: answer_0 is role=confirm, dangerous=true, while answer_1 is the safe role=cancel choice. Agents must not treat an unlabelled first button as a harmless default.

The default run captures every context that is available in the current world state. --require-all turns a missing prerequisite into failure. Elevator needs a known real trigger unless it is already active:

python Tools/AiControlMcp/tla_context_gui_playtest.py \
  --output-dir Workspace/AiControlScreenshots/context-gui
# First use tla_qa_teleport_map with mariposa/mariposa_level1.
python Tools/AiControlMcp/tla_context_gui_playtest.py --screens Elevator \
  --elevator-trigger-hex 101 43 --require-all

A live standalone-client run passed the eight Arroyo contexts available there plus the Mariposa Elevator run (9/9 total). Timer was also verified beyond pixels: timer:599 consumed one inventory dynamite and created one active_dynamite. The elevator exposed level_1 through level_3; answering level_2 through tla_ui_answer transferred the chosen from mariposa_level1 to mariposa_level2.

The runner deliberately does not bypass item safety: Split needs an owned stack, Timer an owned timer-capable item, Use an owned canUseOnSmth item and a real target, PickUp a visible safe unlocked container, and Radio an owned radio. Prepare those prerequisites through ordinary gameplay or gated QA setup commands.

tla_barter_playtest.py covers the contextual Dialog → NPC BarterDialog lifecycle through MCP tools. It requires an in-game chosen critter and AiControl.AllowQaCommands=True; the runner grants caps, teleports to the requested trader, verifies pricing/session metadata and a completed offer refresh, captures all three UI states, and requires the assembled Barter frame to contain all four item panels, both totals, and the header display. It closes the dialog and writes report.json alongside the verified TGA files:

python Tools/AiControlMcp/tla_barter_playtest.py --map arroyo --npc-dialog-id arroyo_doc --hex 77 103 \
  --output Workspace/AiControlBarterPlaytests/arroyo-doc
# Add --hex X Y when the trader is not visible from the map entry.

Use tla_window_screenshot only when the OS window itself matters (window chrome, presentation under a specific desktop compositor, or a renderer diagnostic); it is Windows-only and is not the canonical composed-frame check.

NOTE: The adapter’s launch orchestration reads TLA subconfigs (Unpackaged, LocalTest, PublicGame). TLA does not use the sibling project’s scene system, so startupScenes is empty. For now, launch the game manually (below) and point the adapter at the running bridge.

Running it locally

Build once: Build :: TLA_Client (compiles the native bridge) and Bake Resources.

This is the robust setup for interactive testing — the server stays up regardless of client login state.

# 1) Persistent headless server (no embedded client), stays up:
cmake --build Build/Auto --config RelWithDebInfo --target TLA_ServerHeadless
./Binaries/Server-Windows-win64/TLA_ServerHeadless.exe --ApplySubConfig LocalTest    # wait for "Start server complete!"

# 2) A client that hosts the bridge on 127.0.0.1:43011 (windowed; D3D11 works over RDP):
./Binaries/Client-Windows-win64/TLA_Client.exe --ApplySubConfig LocalTest --AiControl.Enabled True

Then drive it: connect to the bridge, send register (a fresh character spawns and enters the game), and poll observe until hasChosen / hasMap are true. On the same persistent server (in-memory DB) a name can be registered once; use login to re-enter an existing character after a client relaunch.

One-shot headless (embedded client)

./Binaries/Server-Windows-win64/TLA_ServerHeadless.exe \
    --ApplySubConfig LocalTest --AiControl.Enabled True --Server.AutoStartClientOnServer 1

The embedded headless client boots, fires Game.OnStart, and starts the bridge. This mode is one-shot: the headless app quits when its embedded client disconnects (e.g. a failed/finished login), so it is best for a single scripted run, not long iterative sessions. Use the persistent-server setup above for those.

Validation

  1. Build :: TLA_Client — native bridge compiles/links (rebuilds the generated script API for the new ///@ ExportMethod / ///@ EngineHook). For headless smoke also build TLA_ServerHeadless.
  2. Compile AngelScript (or Bake Resources) — Scripts/AiControl.fos sees the generated client exports.
  3. python Tools/AiControlMcp/smoke_ai_control_mcp.py --static-only — schema/tool/resource/prompt discovery.
  4. python -m unittest Tools/AiControlMcp/test_ai_control_screenshot.py — path confinement, raw TGA validation, blank-like detection, event metadata, and typed command payload checks.
  5. Launch a client with AiControl.Enabled = True (above), then python Tools/AiControlMcp/smoke_ai_control_mcp.pytla_ping, tla_status, tla_observe, the event cursor, and one harmless tla_clear_actions round-trip.
  6. With an in-game chosen critter, run python Tools/AiControlMcp/tla_gui_screenshot_test.py and inspect its manifest plus TGA files. Run python Tools/AiControlMcp/tla_context_gui_playtest.py for the nine contextual windows; use --require-all only after preparing every real item/map prerequisite.

Playtest runner

Tools/AiControlMcp/tla_mechanics_playtest.py is a self-contained (no MCP adapter needed) reachability-aware mechanics run over the bridge: it logs in (or --register), summarizes the observation, reachability-probes visible critters/items with environment_query path, navigates to the nearest reachable targets, attempts a talk, and writes a JSON report (--report). Run it against a client started as above:

python Tools/AiControlMcp/tla_mechanics_playtest.py --name TestBot1 --register --report Workspace/run.json
# run on a real content map (needs AiControl.AllowQaCommands=True on server+client):
python Tools/AiControlMcp/tla_mechanics_playtest.py --register --teleport-map arroyo --report Workspace/arroyo.json

Tools/AiControlMcp/tla_quest_runner.py runs a full quest cycle (accept → travel → turn in) end-to-end, asserting the quest property advances at each stage. Quest specs are data-driven (QUESTS dict). Four are verified across three towns and three shapes: cassidy_letter (Arroyo → Vault City delivery), arroyo_mynoc_oil (accept + turn-in on one giver), den_smitty_robot (Den: accept → repair the Mr. Handy at a workbench → report, skill + item gated, four stages) and klam_vaccination (Klamath: accept → vaccinate sub-task → report, a Server-scope quest flag verified via qa_get_prop). Needs AiControl.AllowQaCommands=True and a Russian client (--Client.Language russ) so dialog text matches the Russian answer keywords.

python Tools/AiControlMcp/tla_quest_runner.py --list
python Tools/AiControlMcp/tla_quest_runner.py --quest cassidy_letter --name QuestCassidy1 --register \
  --require-exercised --report Workspace/cassidy.json
python Tools/AiControlMcp/tla_quest_runner.py --quest klam_vaccination --name QuestKlam1 --register \
  --require-exercised --report Workspace/klam.json

The same runner can discover localized answer paths before a quest spec is authored. --trace-dialog replays bounded paths against a visible NPC, reads the requested quest flag through the asynchronous server-authoritative qa_get_prop round-trip, and emits the answers that increase it. Candidates are ranked by stage advance and include the full visible node_path plus stable Russian keyword substrings:

python Tools/AiControlMcp/tla_quest_runner.py --trace-dialog --map arroyo \
  --npc CassidyStage4 --dialog arroyo_cassidy --flag ArroyoCassidyLetter \
  --npc-hex 82 113 --name TraceCass --register --trace-max-candidates 1 \
  --report Build/_artifacts/trace-dialog/cassidy.json

Run the server with --ServerNetwork.InactivityDisconnectTime 0 for a long trace. Use a disposable QA character: the runner restores the requested flag in finally, but arbitrary authored side effects such as items, dialog cooldowns, and other properties cannot be rolled back generically. --setup-json accepts either a JSON array or a path/@path to an array using the same prop / game_prop / item entries as quest specs. The traversal is bounded by --trace-max-depth (12), --trace-max-paths (96), --trace-max-candidates (8), and an internal --trace-max-seconds wall-clock budget (180). It uses priority-guided replay, tolerates one-shot first-meeting roots and randomized greeting/exit text, retries a missing or delayed qa_get_prop response under async load, and correlates delayed replies by request id. Property setup/reset uses the correlated qa_prop_set acknowledgement and resends an unacknowledged write; confirmed same-map teleports and talk_to actions are retried as well. First-time Debug map loads get a 90-second observed transition window, configurable with --map-timeout; success still requires observing the requested map.protoId. Reports expose truncated, time_limit_reached, branch_errors, flag_restored, and restore_error explicitly.

A spec stage can carry a setup list — {"prop"|"game_prop"|"item": name, "value"|"count": n} — applied after the teleport and before talking, to satisfy a giver’s prerequisite demands (an attribute like Intellect, a CurrentHp, a GAME flag, or a required inventory item). The dialog navigator auto-advances single-answer intros, never bails out on an exit-like answer while a fresh one remains, and remembers chosen answers across re-opens so each re-open explores a new branch — which is what makes a topic-tree giver like Mynoc reachable without hand-coding the exact path. Current specs use monotonic stage targets: a completed character reports already_satisfied=true instead of wandering an obsolete dialog branch. Use --require-exercised for regression runs that must verify at least one real state transition.

Verified end-to-end (2026-07-11)

Dialog trace seeds (2026-08-08)

--trace-dialog was verified against a fresh Russian client and the real CassidyStage4 NPC on arroyo. The bounded replay handled the one-shot introduction, found the three-answer path through “work” and the letter offer, and emitted Да, конечно. as the rank-1 candidate for ArroyoCassidyLetter (0 → 1). The JSON report recorded 5 explored paths, 1 root rebase, no branch errors, the complete node path, and a confirmed restore to the original value 0.

The same live workflow covers the real Arroyo Mynoc placement (protoId=EnclaveGuard, dialogId=arroyo_mynoc) with IntellectBase=6 supplied through the acknowledged setup path. Quest-guided ordering found Я заметил... Твоя броня, она несколько поржавела...Я принесу тебе смазку... in 2 explored paths and 13.7 seconds, observed ArroyoMynocOil 0 → 1, reported no branch errors, and confirmed restore to 0.

The Den seed is now live-verified across all four authored stages. The real HomesteaderMale / den_smitty path accepted the job (DenSmittyFixit 0 → 1); MrHandy / den_mr_handy exposed the Repair-85 diagnosis (1 → 2) and accepted pump_parts + oil_can + super_tool_kit (2 → 3); Smitty then accepted the report (3 → 4). Every trace found a ranked candidate without branch errors and restored the original flag value 0. Reports are Build/_artifacts/trace-dialog/smitty-{accept,examine,repair,report}-live.json. This validates 3 of the 7 Stage-0 trace quests.

The fourth seed uses the real Klamath MasterTrader / klam_hish at hex 82,132. With IntellectBase=6, bounded replay found the five-answer path from looking for work through the name joke and vaccination offer to Хорошо, не волнуйся. Я не промахнусь., observed KlamVaccination 0 → 1 in 5 explored paths and 20.97 seconds, reported no branch errors, and restored 0. The report is Build/_artifacts/trace-dialog/klam-hish-accept-live.json.

The three fresh Arroyo traces complete the Stage-0 criterion. The normal Todd talk path first reported no_visible_start_speech despite an alive, talk-enabled, adjacent and mutually visible NPC. The project dialog parser was retaining the @ sigil and full Content::* qualifier in script arguments; normalizing those values restored the authored loyalty demand, after which ArroyoProofOfDeath advanced 0 → 1 without a mechanic bypass. Todd’s follow-up offer advanced ArroyoLetterToLinnett 0 → 1 with ArroyoProofOfDeath=3 as its authored prerequisite. Finally, stable-slot replay handled Mynoc’s randomized @@ wording and exposed an unconditional minimum-group guard that contradicted the solo stage-1 dialog branch. Restricting that guard to stage 2 produced the full 13-answer path and ArroyoMynocDefence 0 → 1. All three runs had no branch errors and restored 0; reports are Build/_artifacts/trace-dialog/arroyo-{todd-proof,letter-linnett,mynoc-defence}-live.json. Stage-0 trace coverage is 7/7.

The next Den trace corrected a stale diagnosis and completed DenMomSlut end to end. The parser already inferred DenVirginIsAway as a Game property; the hidden answer came from comparing integer property value "0" with textual any value "false". Dialog property booleans are now normalized to 0/1, restoring all 51 authored boolean demand/results. Live replay advanced 0 → 1 at Mom in 27 paths, 1 → 2 at Virginia in 2 paths (executing DenVirgin::GoAway), and 2 → 3 at Mom in 1 path. All runs restored the original flag and had no branch/log errors. Reports are Build/_artifacts/trace-dialog/den-{mom-slut-accept,virginia-away,mom-slut-report}-live.json.

Mechanics beyond dialog (verified 2026-06-21)

A battery of non-dialog gameplay mechanics was driven end-to-end on a real client, each with an observable state change in chosen / inventory / mapItems:

Adding more quests (what still needs care)

The upgraded navigator (auto-advance intros, strong exit-avoidance, seen-tracking across re-opens) handles both a simple giver (Cassidy) and a deep topic-tree giver (Mynoc). What remains per-quest is mostly getting the right answers to appear, via stage setup and the right client language:

The QUESTS dict ships three verified flows plus a NOTE documenting these knobs:

Adding a quest is: pick a Group = Quests target, give each stage its setup, and run once against a Russian client to confirm.

Fixed: empty dialog text (key mismatch)

Surfacing dialog text/answers via the bridge exposed a real game bug: all dialog speech/answer strings resolved empty. Root cause — a text-pack key mismatch between the baker and the runtime lookup. The DialogBaker stores each dialog text under a 4-part key FromParts("Dialogs", <dialogName>, <textKey>, ...) (it includes the dialog name), but Dialogs.fos looked it up with only the text key (TextPackKey(TextPackName::Dialogs, "" + textId)), so the keys never matched and Game.GetText returned empty for every dialog (a normal player saw the same).

Fix (in Scripts/Dialogs.fos): build the lookup with the dialog id as the first key part — TextPackKey(TextPackName::Dialogs, dialogId, textId) in ReceiveDialogContext (screen text + answers) and the answerless Say/Info path. Verified: arroyo_laumer now reads its real Russian quest dialog and the agent navigates it answer-by-answer.

Fixed: empty NPC floating text (numbered dialog strings)

The same class of bug hit the numbered dialog strings — the lines NPCs say on their head in combat/ambient (Messaging::Say/SayOnHead/Info), keyed by MsgStr::DialogTextId(dialogId, num). DialogBaker stores these under [Dialogs, <dialogName>, <100000000 + num>] (the authored key base 100000000 is shared by every dialog; the dialog name disambiguates), but the call sites looked them up as TextPackKey(TextPackName::Dialogs, "" + MsgStr::DialogTextId(dialogId, num)), which is wrong twice: it omits the dialog name, and DialogTextId = dialogId.uhash + 12000 + num no longer reproduces the baked key (the 64-bit uhash overflows to a garbage number). So all of these floating lines were empty.

Fix: a single helper MsgStr::DialogTextKey(dialogId, num)TextPackKey(TextPackName::Dialogs, dialogId, "" + (100000000 + num)), and the 101 call sites across 26 files (TextPackKey(TextPackName::Dialogs, "" + (MsgStr::DialogTextId(EXPR, N)))DialogTextKey(EXPR, N)) were migrated to it. Verified through the qa_get_text debug command (resolves MsgStr::DialogTextKey for a given dialog id + number): klam_aldo 1/2/3 (“Не могу к доске подобраться…”, …), all_poker 0 (“Ваши карты:”), bh_phil 200 — all now return their real text.

Fixed: empty dialog text in @text format tags (poker / roulette / banker / NPC names)

The other consumer of the numbered dialog strings is the @text Dialogs <id>@ format tag — built server-side (poker card names, roulette, the replication-banker “spy” descriptors) and resolved client-side by Game.FormatTags so each player sees their own language. The tag carried a single id (MsgStr::DialogTextId(...)), so — like the Say/Info path — it omitted the dialog name and used the wrong number; all of it rendered empty. NPC names via @text Dialogs <MsgStr::NpcNameDlgTextId(dlg)>@ were broken the same way (the name is stored under the string key "Name").

Fix, in two parts:

Verified end-to-end with the qa_format_tags debug command (runs a raw string through Game.FormatTags): @text Dialogs klam_aldo 100000001@ → “Не могу к доске подобраться…”, @text Dialogs all_raider Name@ → “Рейдер”, @text Dialogs all_poker 100000000@ → “Ваши карты:”, and the same tag embedded mid-sentence resolves in place. (Requires a native TLA_Client rebuild for the FormatTags change.)

Fixed: empty proto-name @text tags (critter / item / location names)

@text Critters/Item/Locations <id>@ (NPC, item and location names in a handful of messages) were broken differently. The engine ProtoTextBaker stores a proto’s localized $Text <lang> name under [<pack>, <protoId>] — i.e. the proto id string itself is the key, and the pack names are Critters, Items (plural!) and Locations. The call sites instead passed a number (pid.uhash via NpcProtoNameTextId/ItemNameTextId/LocNameTextId) and, for items, the wrong pack name Item (singular) — so all of it missed and rendered empty.

Fix: helpers MsgStr::NpcProtoNameTag(pid)@text Critters <pid>@, MsgStr::ItemNameTag(item) / ProtoItemNameTag(pid)@text Items <pid>@, MsgStr::LocNameTag(pid)@text Locations <pid>@, with the 15 sites (across Behemoth/Caravan/FighterQuest/GameEventCaches/NcrPostman/Repairer/Resources/ Traveller) migrated to them. These are plain two-token tags (no engine change needed beyond the three-token support already added). Verified via qa_format_tags: @text Critters Algernon@ → “Алжернон”, @text Items _10mm_ap@ → “10 мм ББ”, @text Locations klamath@ → “Кламат”, and embedded in a sentence.

Roadmap (next)