bacommontools package

Tools functionality shared by all Ballistica components.

Submodules

bacommontools.bacloud module

A tool for interacting with ballistica’s cloud services. This facilitates workflows such as creating asset-packages, etc.

class bacommontools.bacloud.App[source]

Bases: object

Context for a run of the tool.

run() int[source]

Run the tool.

run_interactive_command(cwd: str, args: list[str]) None[source]

Run a single user command to completion.

class bacommontools.bacloud.StateData(login_token: str | None = None)[source]

Bases: object

Persistent state data stored to disk.

login_token: str | None = None
bacommontools.bacloud.get_tz_offset_seconds() float[source]

Return the offset between utc and local time in seconds.

bacommontools.bacloud.run_bacloud_main() None[source]

Do the thing.

bacommontools.bacloudsession module

bacloud’s live session to a basn node.

One SmartSocket session replaces the request-per-HTTPS-handshake conversation bacloud used to have. There is no mint step and no HTTPS request at all: the client dials /bacloudsession on the node it already resolved, and the handshake itself creates the channel. Every command after that rides the session – including each end_command continuation and every streamed command’s output.

What the session buys, measured rather than assumed (2026-08-15): streamed output pushed as produced instead of on a 0.25-5s poll cadence, one connection instead of two for a streamed command, one recovery implementation instead of several (reconnects are invisible here; deaths are not), and a far end that finds out when we quit. Notably not per-command latency – that measured the same as the request-per-connection path it replaced, because urllib3 pooling had already amortized the TLS handshake across a run.

Sequential by contract. Send a request, read until its response, repeat. No correlation ids and no multiplexing: bulk transfers were never on this channel – uploads and downloads go direct to storage on their own connections – so nothing here needs to overlap.

Threading. bacloud is synchronous and stays that way. The session runs an asyncio loop on its own thread and BacloudSession.request() is an ordinary blocking call, so nothing above it has to know a socket is involved.

Credentials and resume. Our bearer rides the WS upgrade’s Authorization header on every attach, exactly as it rode every HTTPS request before. The node answers a freshly created channel with a SessionHandleResponse carrying a resume token, which we hold and present as X-WS-Token if we ever have to reconnect. It needs no refreshing – it is minted to outlive the session itself.

That response also tells us where to reconnect, and we must use it rather than re-dialing the host we opened with. In prod that host is a regional endpoint which routes each connection to some node, and a session lives in exactly one node’s process – so re-dialing it would usually reach a node with nothing to resume. (This is the one place bacloud is node-bound; streamcall is not, because its state is bamaster’s.)

Canonical design: efrohome:docs/global_design/ streamcall-smartsocket.md (“Consumer #2”).

class bacommontools.bacloudsession.BacloudSession(ws_url: str, bearer: str | None)[source]

Bases: object

A live conversation with the bacloud server.

Constructed via open(), which returns None rather than raising when a session can’t be had – not because that is survivable (it is not; the caller turns it into a hard error) but so the message the user sees is written where the surrounding context is, rather than here.

property alive: bool

Is the session still usable?

end() None[source]

Tell the far end we’re done, briefly and best-effort.

end rather than detach: a process that is exiting is not coming back, and a relay that is told so releases the channel (and its node-side task) now instead of holding the slot through the whole linger window.

We only ask; _end_when_requested does the saying, on the session thread. That split is load-bearing in two ways.

It is the only place the goodbye can work at all: once endpoint.run() has returned, the transport is already gone and endpoint.end() has nothing left to send – so a goodbye issued from out here was silently a no-op in exactly the case it was written for.

And scheduling a coroutine in from out here is not merely useless but unsafe. run_coroutine_threadsafe creates a task, and creating one while asyncio.run is tearing the loop down races the C _asyncio accelerator’s task bookkeeping and segfaults the interpreter. That window is not exotic: the node closes the channel when a command finishes, so a run whose last command just completed arrives here with the loop already unwinding. (Reported from a build 2026-08-19; the _ended flag could not guard it, since it is set only after asyncio.run has fully returned.) call_soon_threadsafe is the one loop method documented as thread-safe, creates no task, and raises rather than crashing if the loop is already closed.

classmethod open(server: str, bearer: str | None) BacloudSession | None[source]

Open a session to server, or return None.

server is the host bacloud already resolved – the same one its requests went to before – so this adds no lookup and no hop.

request(request: StandardRequestData) StandardResponseData[source]

Send one request and block for its response.

Blocks without a deadline of its own on purpose: the session’s own liveness machinery is the authority on whether the far end is still there, and a second timeout here could only disagree with it. A dead session raises rather than hanging.

The one gap in that, stated so it isn’t rediscovered: liveness watches the leg, not the outstanding request. A response dropped on a connection that then stays healthy is only retransmitted by a resume hello, which never fires because the leg looks fine – so this would block forever. Real transports cannot produce that (TCP delivers or the connection breaks); only the relay’s chaos hook can, by silencing frames on a live connection. If that ever stops being true, the fix is a request-scoped deadline here, not a shorter liveness window.

bacommontools.meshcompile module

Compilers for Ballistica’s binary mesh formats.

Covers display meshes (.bob) and collision meshes (.cob).

This module is intentionally stdlib-only and side-effect free so it can run anywhere it gets efrosynced to (game repo asset builds now, master-server cloud-build recipes later).

class bacommontools.meshcompile.BobCompileResult(corner_count: int, vertex_count: int, tri_count: int, index_size: int)[source]

Bases: object

Stats from a display-mesh compile.

corner_count: int
index_size: int
tri_count: int
vertex_count: int
property vertex_reuse: float

Verts per triangle; lower is better (0.5 = perfect grid reuse).

Values near 3.0 mean almost no corner sharing (hard edges / UV seams splitting most vertices) - an art/export property no index reordering can fix.

class bacommontools.meshcompile.BobData(mesh_format: int, vertices: list[tuple[float, float, float, int, int, int, int, int]], indices: list[int])[source]

Bases: object

Parsed contents of a .bob file.

indices: list[int]
mesh_format: int
vertices: list[tuple[float, float, float, int, int, int, int, int]]
class bacommontools.meshcompile.CobCompileResult(vertex_count_in: int, vertex_count_out: int, tri_count_in: int, tri_count_out: int)[source]

Bases: object

Stats from a collision-mesh compile.

tri_count_in: int
tri_count_out: int
property tris_dropped: int

How many degenerate triangles were dropped.

vertex_count_in: int
vertex_count_out: int
property vertices_welded: int

How many exact-duplicate vertices were merged away.

class bacommontools.meshcompile.CobData(file_id: int, positions: list[float], indices: list[int], normals: list[float] | None)[source]

Bases: object

Parsed contents of a .cob file.

file_id: int
indices: list[int]
normals: list[float] | None
positions: list[float]
bacommontools.meshcompile.compile_collision_mesh(src: str | Path, dst: str | Path) CobCompileResult[source]

Compile a wavefront .obj file to a binary .cob file.

Reads a constrained subset of the obj format: v records and f records (v, v/t, v//n, and v/t/n corner forms are all accepted; texture-coordinate and normal references are ignored). Faces with more than 3 corners are fan-triangulated.

Output is deterministic for a given input, which matters for content-addressed asset storage.

Beyond straight conversion this applies a few optimizations:

  • Exact-duplicate vertex positions are welded (compared at float32 precision, matching what gets written).

  • Degenerate triangles (two or more corners sharing a vertex) are dropped.

  • Triangles are sorted along a Morton curve of their centroids and vertices are then ordered by first use, so triangles that are near each other in space are also near each other in memory. ODE/OPCODE reads these arrays in place during collision queries; spatially-local queries thus touch fewer cache lines. (Tree shape is unaffected; OPCODE splits on geometry, not input order.)

  • Unreferenced vertices are pruned (they would otherwise inflate both memory use and ODE’s model-space AABB).

bacommontools.meshcompile.compile_mesh(src: str | Path, dst: str | Path) BobCompileResult[source]

Compile a wavefront .obj file to a binary .bob file.

Reads the obj subset our exporters produce: v/vt/vn records plus f records with full v/t/n corners. Faces with more than 3 corners are fan-triangulated. The obj V texture coordinate is flipped (1 - v) per GL convention. UVs and normal components meaningfully outside their encodable ranges ([0, 1] / [-1, 1]) are errors; values within a 0.05 tolerance are treated as authoring noise and clamped silently by the quantization.

Output is deterministic for a given input, which matters for content-addressed asset storage.

Optimizations applied:

  • Corners are quantized to the final vertex encoding and welded (exact-match on all attributes), so identical corners share one vertex.

  • Degenerate triangles (two or more corners welding to the same vertex) are dropped.

  • Triangle order is optimized for the GPU post-transform vertex cache (Forsyth’s linear-speed algorithm), then vertices are renumbered by first use for fetch locality. This also prunes unreferenced vertices.

  • Index width is chosen per mesh: u16 when vertices fit, u32 otherwise (the engine supports both; this removes the old make_bob 21845-face limit).

bacommontools.meshcompile.read_collision_mesh(path: str | Path) CobData[source]

Read a binary .cob file (current or legacy format).

bacommontools.meshcompile.read_mesh(path: str | Path) BobData[source]

Read a binary .bob file.

bacommontools.pcommands module

Pcommands for bacommontools.

bacommontools.pcommands.bacurl() None[source]

Run curl with the Ballistica API key injected.

Usage: bacurl [curl-args…] <url>

Reads ballistica_api_key from pconfig/localconfig.json and passes it as a Bearer token in the Authorization header. All arguments are forwarded to curl. The -s (silent) flag is added automatically. HTTP errors exit non-zero (–fail-with-body) so piped JSON parsing fails loudly instead of KeyError-ing on error payloads; the error body still prints.

Examples:

bacurl https://dev.ballistica.net/api/v1/admin/stats/catalog
bacurl -X POST -H 'Content-Type: application/json' \
    -d '{"dry_run":true}' \
    https://dev.ballistica.net/api/v1/admin/stats/flush
bacommontools.pcommands.compile_collision_mesh() None[source]

Compile a collision mesh from .obj to our binary .cob format.

Usage: compile_collision_mesh <src.obj> <dst.cob>

bacommontools.pcommands.compile_mesh() None[source]

Compile a display mesh from .obj to our binary .bob format.

Usage: compile_mesh <src.obj> <dst.bob>

bacommontools.pcommands.require_ballistica_api_key() None[source]

Verify a Ballistica API key is available; error if not.