Your own viewer: a patched Firestorm with a helper bridge
An assistant that builds a world needs to SEE it the way a person does, repeatably: put the camera exactly where it wants, take a clean picture, walk an avatar, read what is near it. Driving a person's desktop viewer through screenshots and mouse clicks works, but it is slow, fragile, and uses someone else's client. This chapter builds a separate viewer for the assistant, with its own settings and its own avatar, and a small HTTP bridge into the viewer's built-in helper-process API (LEAP).
Everything here is in the repo: viewer-patches/ (the source patches and the pinned base), viewer-build/ (the
build and packaging scripts), tools/leap_bridge.py (the bridge), tools/leap_idle.py, tools/run_viewer.py
(the launcher). The finished build is called Firestorm-Paracosm: a downstream modified Firestorm, not an official
release.
1. Build Firestorm from source (Windows)
Firestorm is open source (LGPL), built on Linden Lab's viewer code. The recipe is in the repo:
viewer-build/configure.bat then viewer-build/build.bat, then viewer-build/package.py for the portable zip.
packaging.md has the full sequence.
- Tools, with the versions the current build used: Visual Studio 2022 Build Tools (the C++ workload, plus ATL and
a Windows SDK; MSVC 14.44.35207), CMake 4.3.4, Git for Windows, Python 3.12.10 with
pip install -r requirements.txtandpip install git+https://github.com/secondlife/autobuild.git(autobuild 3.10.3.dev9), llbase 1.3.1. GNUpatchships with Git for Windows (Git/usr/bin), so Cygwin is not needed. FMOD, Havok and KDU are off: nothing licensed is needed. - Sources:
git clone https://github.com/FirestormViewer/phoenix-firestorm.git, thengit checkout -b paracosm 3125052b(the pinned base,viewer-patches/README.md), andgit clone https://github.com/FirestormViewer/fs-build-variables.gitat commitc17312d3. The scripts setAUTOBUILD_VARIABLES_FILEto the latter'svariables, andAUTOBUILD_VSVER=170. - Configure from a
vcvars64environment:autobuild configure -A 64 -c ReleaseFS_open -- --opensim --chan Paracosm -DLL_TESTS:BOOL=FALSE(configure.bat). This runs Firestorm's bash script: put Git'sbin(bash only) FIRST on PATH sobashis Git's and not WSL's, and put Git'susr/binLAST so its tools never shadow Windows ones. - Build with msbuild directly (
msbuild build-vc170-64\Firestorm.sln /p:Configuration=Release /p:Platform=x64 /m) from thevcvars64environment, WITHOUT Git'susr/binon PATH. Learned: building through the script ran under Git bash, whose/usr/bincomes first; msbuild then took GNUlink.exefor the linker and every link failed with a bare "exited with code 1". - A first build takes about half an hour; later builds after a patch take minutes. If a region runs on the same machine, build at idle priority (operations.md).
- A development build reads
app_settings/,skins/andcharacter/from its WORKING directory: run it from the source'sindra/newview, or it stops with "settings_files.xml not found". The portable package (viewer-build/package.py) carries them beside its exe and runs from its own folder.
2. The patches (viewer-patches/)
Ten patches in git format-patch form, one commit per change on top of the base 3125052b (each change's own
follow-up fixes folded into it before the first release). Apply them in file order with
git am viewer-patches/00*.patch: several change app_settings/settings.xml, llstartup.cpp or
notifications.xml, each made against the one before it. The "Paracosm NNNN" numbers inside are the order the
changes were planned in, and are what the source's <Paracosm> comments cite; the file numbers are the order they
apply in, so file 0008 carries Paracosm 0004 (the reconnect mode: planned early, built last), and there is no
Paracosm 0008.
| file | Paracosm no. | what it does |
|---|---|---|
0001-Paracosm-0001-its-own-settings-cache-and-saved-login.patch |
0001 | settings and saved logins in %APPDATA%/ParacosmViewer_x64, the cache in %LOCALAPPDATA%/ParacosmViewer_x64, so this viewer and an installed Firestorm never share state (llappviewer.cpp, lldir.cpp) The default cache really follows the app name (lldir_win32.cpp, lldir_linux.cpp built it before the name existed). |
0002-Paracosm-0002-LEAP-camera-placement-setCameraPosAndF.patch |
0002 | two LEAP operations on LLAgent: setCameraPosAndFocus {camera_pos, focus_pos, obj_uuid?} places the camera exactly, detached from the avatar the way alt-clicking an object detaches it (the programmatic form of Object View), and resetCamera returns it to the avatar |
0003-Paracosm-0003-LEAP-snapshot-format-png-or-jpeg-bmp-a.patch |
0003 | an optional format on the LEAP LLViewerWindow.saveSnapshot op: png (the default), jpeg or bmp |
0004-Paracosm-0005-no-phone-home-by-default.patch |
0005 | no requests to third parties by default: a new FSDataDownload setting (off) stops the phoenixviewer.com data files, GridListDownload defaults off, and the OpenSim search and Marketplace URLs default empty |
0005-Paracosm-0006-an-official-Linden-Lab-grid-takes-a-st.patch |
0006 | logging in to an official Linden Lab grid takes two stern confirmations (once per session), and every LEAP helper is terminated and refused for that session; with no grid chosen the viewer defaults to the first non-Linden grid, never Second Life A Linden grid is recognised at the moment of the login click (by name and login URI), and an unconfirmed Linden login is refused before anything is sent, by any route. |
0006-Paracosm-0007-voice-off-by-default-and-Vivox-never-p.patch |
0007 | voice off by default, and Vivox never posts crash dumps (a private OpenSim world has no Vivox) |
0007-Paracosm-0009-a-local-login-page-Firestorm-s-own-sit.patch |
0009 | the login screen's page: the Linden grids' page from Firestorm's own site and the legacy http://<grid>/app/login/ guess become skins/default/html/common/paracosm/login.html, shipped in the viewer and fetching nothing; a grid that names its own welcome page keeps it (fsgridhandler.cpp) The page is loaded directly (the login panel's LLURI rebuild lost a file: URL's path and showed black). |
0008-Paracosm-0004-reconnect-mode-after-a-disconnect-a-re.patch |
0004 | reconnect mode: after a disconnect the viewer restarts itself, waits for the grid to answer and logs back in with the same account to the same place (section 6; paracosmreconnect.cpp, llappviewer.cpp, llstartup.cpp, llprogressview.cpp) |
0009-Paracosm-0010-LEAP-relog-and-disconnect_state-on-the.patch |
0010 | LEAP relog {force?} and disconnect_state on the LLAppViewer event API (section 6; llappviewerlistener.cpp) |
0010-Paracosm-0011-local-crash-minidumps-a-crash-writes-P.patch |
0011 | a crash writes a local minidump and a note into the viewer's logs folder, nothing sent (section 7; llappviewerwin32.cpp) |
3. The bridge (tools/leap_bridge.py)
The viewer launches it as a helper (--leap) and talks to it over stdin/stdout. It serves HTTP on 127.0.0.1:9004
(bearer token): /snapshot, /camera, /orbit, /camera_reset, /position, /walk, /stop, /say,
/nearby, /relog and /disconnect_state (section 6), and /call for any LEAP operation by name (for example
{"pump": "LLFloaterReg", "op": "hideInstance", "name": "fs_nearby_chat"} closes a floater).
- Framing is
<length>:<LLSD>. This viewer SENDS binary LLSD with the header<? LLSD/Binary ?>; parse with an auto-detecting LLSD parser. REPLY in headerless notation, which the viewer recognises by its leading brace. Learned:llbase's binary header is spelled<?llsd/binary?>; the viewer could not parse it and killed the helper. - Nothing but LEAP frames may go to the bridge's stdout: that IS the channel. Log to a file (it goes to
%LOCALAPPDATA%/ParacosmViewer_x64/leap_bridge/leap_bridge.log). - The bearer token is new on every launch:
run_viewer.pymakes it (unlessPARACOSM_VIEWER_TOKENpins one), hands it to the bridge in the environment of the viewer's--leapchild, and it is written to.../leap_bridge/bridge.token, readable by this user only. Tools read it withpaths.read_viewer_token(), the MCP on every call./snapshotwrites only insidePARACOSM_SNAPSHOT_DIR(default.../leap_bridge/snapshots): send a bare file name and read the full path back from the reply.tools/PORTABILITY.mdhas the details.
4. Launching it (tools/run_viewer.py)
- Give the assistant its own avatar on the local grid (WorldBridge
create_account, or the estate owner, as.env.exampledoes), with its login in.env(PARACOSM_RESIDENT_*, git-ignored). - THE COMMAND LINE IS A TRAP. The viewer's tokenizer strips every quote character, and it parses a SINGLE value of
a list-typed setting as LLSD notation, so
--loginuri http://...and--leap "python bridge.py"both fail quietly. Two values of a list-typed option are taken as plain strings, so pass--leaptwice (the bridge plusleap_idle.py, a helper that just waits). Select the grid through the viewer's owngrids.user.xmlholding only the local grid, plus--set CurrentGrid <host:port>. - Learned the hard way: with
--loginurisilently unparsed, the first launch fell back to Second Life's real login server and sent the local account's name and password there. Before EVER passing--login, select the local grid, refuse any non-loopback login address in the launcher, and dry-run once without credentials to confirm the login screen shows the local grid. Then change any password that went astray. Patch 0006 (Paracosm 0006) now also guards this in the viewer itself: with no grid chosen it picks the first non-Linden grid, and a Linden grid needs two confirmations.--loginuriis still not a way to choose a grid in this build (operations.md #20). run_viewer.pydefaults to the development build (C:/fs/phoenix-firestorm/...);PARACOSM_VIEWER_EXEandPARACOSM_VIEWER_DATApoint it at another exe and its working folder (for the portable package, itsFirestormOS-Paracosm.exeand that folder). The accounts come from.env(PARACOSM_RESIDENT_*by default,PARACOSM_OWNER_*with--as owner); the bridge token isPARACOSM_VIEWER_TOKEN. Its "already running" check looks for a process named after the configured exe,firestorm-bin.exe(the development build) orFirestormOS-Paracosm.exe(the portable package), so it sees either build. It is any such viewer, not only the resident's.- Notifications are shown by default.
run_viewer.py --quietadds the viewer's--nonotifications, which hides every dialog (a failed login included: it once hid the reason a login did not happen) and records each hidden one as ignored for later runs, inignorable_dialogs.xmlunder%APPDATA%/ParacosmViewer_x64/user_settings. Delete that file to bring back dialogs an earlier quiet run hid. - Quit through LEAP (
LLAppViewer requestQuit); a region restart wants nobody logged in. After a region restart the viewer now comes back by itself (section 6), andregion_restart.pynudges it. tools/test_bridge_smoke.pychecks a running viewer end to end through the bridge: the token refusals,/state,/disconnect_state, camera placement (read back through the avatar's screen position), a snapshot landing only inside the snapshot folder,/reloganswering, and a clean quit (--no-quitkeeps the viewer,--relogadds a real relog). With no bridge listening it says so and skips.
5. What it is for
- Look at anything from any angle, repeatably:
/orbit {focus, distance, azimuth, elevation}then/snapshot. - Watch motion: fix the camera beside a walking creature and take a burst of snapshots (about four per second); a planted foot must stay put on screen while the body passes over it.
- Everything in verification.md about seeing the real render applies; this viewer just makes the looking cheap.
- Through MCP:
mcp/worldbridge_mcp.py(0.2) exposesviewer_look, which places this viewer's camera and returns the picture as an image content item the model can look at directly, plusviewer_walk,viewer_say,viewer_camera_resetandviewer_call. A model connected to the MCP gets eyes without any screenshot tooling. - Snapshots can be JPEG (patch 0003 adds a
formatto the LEAP snapshot op): an 800x500 view is about 20 KB.
6. Reconnect mode (patch 0004) and the LEAP relog (patch 0010)
A region restart drops a logged-in viewer to "You have been logged out". Before 0004 the only way back was
run_viewer.py --quit and a relaunch. Now the viewer does that itself, with the waiting built in.
Why a restart and not an in-process relog: once logged in, disconnectViewer() tears down the world, agent,
inventory and region singletons, and the viewer has no "log out to the login screen" path that sets them up again
in one process (Firestorm has none either; its grid switching happens before login). A fresh process is the clean
state a login needs. The code is indra/newview/paracosmreconnect.cpp.
- The disconnected viewer.
LLAppViewer::forceDisconnect(a lost region, a stalled event poll, a kick, the simulator going down) hands over to reconnect mode while the region still exists, so it can note the grid, the region and the avatar's position. If reconnecting is allowed (below), a dialog replaces the stock one: "Reconnect mode: this viewer restarts itself in 10 seconds and logs you back in to REGION as soon as the grid answers again", with Reconnect now, Stay disconnected (the stock "View IM & Chat / Quit" dialog, no reconnect) and Quit. When the countdown ends it starts the same exe with the same command line and working folder (so the--leapbridge,--set CurrentGridand any--logincome along), then quits normally. - The handoff (grid, region, position, the account's credential exactly as this session holds it: the name and
the password hash, or the plain password for a single-name OpenSim account; whether the password is remembered;
the disconnect reason; the recent reconnect times) travels in ONE place: the environment variable
PARACOSM_RELOGof the new process only. Never a command line, never a file, never the log. The new viewer reads it as the very first thing inLLAppViewer::init(), deletes it from its environment before it starts any helper (LEAP, SLPlugin, voice), and drops its copy once the login has taken the credential. Another process of the same Windows user could read it in that window, as it could read the running viewer's memory, which holds the same credential. A--loginthe first viewer was started with (run_viewer.pypasses one) is repeated as it was: the restart moves that password nowhere it was not already. - The viewer it started waits (up to 60 s) for the old one to exit, so the bridge's port, the marker files and the
settings writes are free. Then: no login screen; the start location is the lost region and position; and it holds
at the login progress screen, polling the grid's
<login uri>/get_grid_info(2, 4, 8, 16, then every 30 s; any HTTP answer below 500 counts as up) with a status line ("Reconnecting to GRID: waiting for the grid to answer (try 3, next in 8 s)") and the screen's button relabelled Cancel reconnect (to the login screen, name filled in). Once the grid answers it logs in. A login the grid refuses for now is retried after 5, 10, 20, then every 30 s: OpenSim's "region not responding" / "inventory service" answers (reasonkeywithout a word about the name or password), "you appear to be already logged in" (presence), a refused connection (CURLError, not a certificate error). A wrong password, an update demand, terms of service, MFA end the reconnect and show the usual error. AfterParacosmAutoReconnectTimeoutit gives up at the login screen with a dialog saying why. - What it refuses. Never on an official Linden Lab grid (0006's
paracosm_is_linden_grid(); the new viewer checks again). Never automatically after a kick or a login elsewhere (the disconnect reason mentions "kicked", "logged out by an administrator", "access to the region was revoked", "teleported home by the region owner", "another location", "logged in elsewhere", "banned", "ejected": coming straight back would fight the other session or the estate). Not more thanParacosmAutoReconnectMaxPerHourautomatic reconnects an hour (a loop guard; the count travels in the handoff). Not when the session has no stored credential, when the viewer is quitting, or before a login ever completed. Only on Windows in this build (the restart usesCreateProcessW; elsewhere the stock dialog stays). With RLVa on, the viewer still insists on the login screen (RLVa's own rule), so a reconnect stops there.--nonotifications(run_viewer.py --quiet): no dialog, the countdown alone runs. - Settings (
app_settings/settings.xml):ParacosmAutoReconnect(on),ParacosmAutoReconnectDelay(10 s, the dialog's countdown),ParacosmAutoReconnectTimeout(900 s of waiting and retrying),ParacosmAutoReconnectMaxPerHour(5). - Try it without restarting the region: Developer menu (Ctrl+Alt+Q) > Force an Error > Force Disconnect Viewer. The grid is up, so the new viewer logs straight back in.
- Limits. The position is kept inside a 256 m region (the SLURL wraps it; a var-region spot beyond that lands wrapped). The window, camera and open floaters are a fresh viewer's. The old viewer's chat and IM windows go with it (the chat logs on disk stay).
LEAP, on the LLAppViewer event API (the one requestQuit is on):
| op | request | reply |
|---|---|---|
disconnect_state |
{} |
connected, disconnected, startup_state, grid, linden, relogged (this viewer was started by a reconnect), session_started and now (epoch seconds), reconnect: {enabled, state, detail, reason, region, position, relaunch_in, probes, logins, next_in, waited_for_previous_ms, reconnects_last_hour} |
relog |
{force?: bool} |
{ok, state, error?, relaunch_in?}. Disconnected: restart in a second and log back in (an explicit request overrides the automatic refusals and the hourly cap, not the hard ones: Linden grid, no credential, quitting). A viewer a reconnect started, waiting for the grid or between login attempts: try now. Logged in: refused unless force is true, then it logs out, restarts and logs back in. |
state is one of idle, countdown, relaunching, refused, cancelled, failed (the disconnected viewer),
waiting_for_grid, logging_in, retry_wait, done, gave_up, stopped (the viewer a reconnect started).
The bridge serves them as POST /relog {force?} and GET|POST /disconnect_state. It goes away with the old viewer
and the new viewer starts a new one (it retries its port for a minute while the old one lets go); poll /state until
it answers again. tools/region_restart.py uses them: once the region is up it asks the viewer (if a bridge answers)
to relog now when its reconnect is waiting (countdown, refused, failed, waiting_for_grid, retry_wait),
relogs it with force when it is still on a session older than the restart (it never noticed), leaves a
cancelled one alone (a person chose "Stay disconnected"), and returns once a session newer than the restart is
up. --no-viewer-relog skips it.
7. Crash minidumps (patch 0011)
The build has no BugSplat (--bugsplat= empty, so no LL_BUGSPLAT and no LL_SEND_CRASH_REPORTS), and the viewer
excludes itself from Windows Error Reporting (WerAddExcludedApplication). A crash used to leave nothing but the log's
last lines and a marker. Now an unhandled exception on any thread, a CRT invalid-parameter or pure-virtual-call error,
and abort() write, into the viewer's own logs folder:
%APPDATA%\ParacosmViewer_x64\logs\Paracosm-crash-<YYYYMMDD-HHMMSS>-<pid>.dmp
%APPDATA%\ParacosmViewer_x64\logs\Paracosm-crash-<YYYYMMDD-HHMMSS>-<pid>.txt
The .txt names the exception, the faulting address as module + offset, the thread, and whether the dump was
written. The dump is a minidump (threads, stacks, referenced memory, module list; a few MB), written by a helper
thread with MiniDumpWriteDump from System32's dbghelp.dll; open it in Visual Studio or WinDbg with the build's
firestorm-bin.pdb (build-vc170-64/newview/Release). The newest five are kept. Nothing is sent anywhere. The
filter is installed first thing in LLAppViewerWin32::init, again after the window, GL driver and audio DLLs are
loaded, and every 30 s after (a DLL that installs its own filter is logged once and overruled). Not caught:
__fastfail (a /GS stack-cookie failure, heap corruption the OS detects) and a kill from outside, which by nature
leaves nothing. To see one: Developer menu > Force an Error > Force Bad Memory Access.
8. The unclean exits during development (2026-09-25 to 27)
Every viewer session makes a dump-<uuid> folder in %APPDATA%/ParacosmViewer_x64/logs and removes it at the very
end of a clean exit (removeDumpDir), and the exec marker Firestorm.exec_marker likewise. Eleven such folders were
left, so eleven sessions did not exit cleanly (times local, UTC-8):
| started | evidence | likely cause |
|---|---|---|
| 09-25 17:42 and 09-26 17:17 | the folder's static_debug_info.log: FatalMessage "llappviewer.cpp(3056) initConfiguration : Default configuration file settings_files.xml not found"; for 09-26 the Windows Application log has event 1000, firestorm-bin.exe, exception 0xc0000005 at offset 0x647f20 |
started from the wrong working folder (a development build reads app_settings/ from its working directory; section 1). LL_ERRS ends the viewer with a deliberate access violation: a crash by design |
| 09-25 17:51 | an empty folder: it died before writing its debug info | very early; the same class or killed at once (no event) |
| 09-25 17:55 | the old bridge log (tools/leap_bridge.log): 17:55:32 "unparsable frame ... <? LLSD/Binary ?>", then 17:59:12 "viewer closed stdin; exiting" |
the bridge could not parse the viewer's binary LLSD (the llbase header mismatch, section 3), so --quit over LEAP could not work; the viewer was ended from outside |
| 09-25 17:59, 18:28 | bridge handshakes at 17:59:18 and 18:28:43, the next launches 36 s and 6 min later | development iterations: a viewer ended from outside and relaunched |
| 09-26 18:28, 18:46 | no bridge in the bridge log; 18:28 logged in (STATE_STARTED, a login name, a region); 18:46 started while another viewer ran (FoundOtherInstanceAtStartup 1) |
viewers without the bridge, the operator's own login (run_viewer.py --as bob), closed from outside |
| 09-27 08:43 | Firestorm.old: the Linden-grid test of patch 0006, "Login failed" at 16:46:21Z, then the error dialog; the log stops at 16:48:43Z with no quit line |
killed at the error dialog to rebuild: the 0006 fix was committed at 08:53 |
| 09-27 09:29 | Firestorm.log (the portable package): the last lines are "userQuit : User requested quit" and "Alert: Are you sure you want to quit?" at 17:32:09Z |
the process was ended while the quit confirmation was open |
The Windows Application log holds no other Application Error or Application Hang event for the viewer since 09-25,
and the System log no reboot: apart from the two wrong-folder starts, none of these was a crash the system saw. They
were processes ended from outside (Task Manager, taskkill), which the viewer's next start reports as "Exec marker
... found; last exec crashed or froze". run_viewer.py --quit is not among them: it asks through LEAP
(requestQuit) and that exit is clean.
What changes: after a region restart the viewer now restarts itself through a normal quit (no marker, no leftover
folder) instead of being killed and relaunched, and a real crash now leaves Paracosm-crash-*.dmp/.txt beside the
log, with the faulting module and offset. A kill from outside still leaves only the markers. Quit through LEAP or the
window, and start the development build from indra/newview (run_viewer.py does both).