There is a clock in the furthest room of our test level. It ticks once a second — or it is supposed to. For the last few sessions it has been quietly explaining, through everything it got wrong, how our acoustic simulation actually works. Walk into the long hall where it hangs and the tick was barely there. Stand anywhere else and it did not update as you moved. Loop it and it stuttered like a clock with a bad escapement. Each of those failures traced to a different layer — a missing room volume, a raycast that believed walls were the shortest path between two points, a reverb estimator reporting its floor value as a measurement, and an audio file that carried over a second of silence it never asked for.
Fixing the clock meant finishing the system around it. This is a dev-log on the CSG acoustic layer as it now stands: the same CSG subdivision that feeds the renderer's cell-portal graph now feeds a room graph for sound propagation, room brushes became acoustic volumes with presets, doors became valves on that graph, and the Steam Audio ray solve moved onto the job system behind an input fingerprint. Then, because the question "why is FlatBuffers in our build" kept surfacing, a section on what that dependency is actually for — and which of our own binary formats should probably follow it.
Room brushes are older than they look
Everything here descends from DromEd, the editor shipped with Thief: The Dark Project in 1998 — the same lineage as our CSG brushes, portalize BSP build, and object hierarchy. DromEd designers painted room brushes: invisible volumes laid over the carved air cells of a level, primarily so the engine could propagate sound and feed EAX reverb. The room database was not decoration metadata. It was the level's acoustic skeleton, authored by hand because the geometry alone could not answer "how does sound get from the guard's boots to the player's ear." Our implementation follows the same contract: a brush whose operation = 'room' contributes no render geometry at all — it defines an acoustic volume that must overlap another room volume for sound to cross between them.
That last clause is the physics. Sound in this model does not raycast through a doorway; it travels the room graph, where an edge exists only when two room volumes volumetrically overlap — the overlap slab is the passage. Each room-portal edge carries a transmission coefficient (0–1, the fraction of energy that crosses). A listener-to-source path becomes a route through that graph, and the gain along it is the product of the portal transmissions on the best route found by a max-product Dijkstra over the portal adjacency.
csg level. Overlapping room volumes form portals; doors clamp a portal's transmission to 0.15 when closed.A path that crosses two closed doors multiplies to 0.15² ≈ 0.02 — the clock becomes a rumor you have to want to hear. Open one and it is still sealed at the other. That is the point of having two doors in the test level: gating has to compose across a chain, not just toggle a single wall.
What shipped this cycle
The topology fix. The long hall existed as geometry but had no room volume — four "uncovered air cell" warnings at load were the tell. A listener standing in it resolved to no room, so the room-graph model disengaged and audio fell back to a lone raycast through several walls. Two brushes fixed the shape of the problem: a small_dead tunnel room around the doorway air, and a 51-meter large_live hall room overlapping the cave. The graph went from 3 rooms / 2 portals to 5 rooms / 4 portals — A ↔ tunnel ↔ B ↔ cave ↔ hall.
Portal chains own cross-room propagation. Both straight-path occlusion mechanisms — the physics sphere cast and Steam Audio's direct-path ray solve — were multiplying the portal gain toward zero. Neither is wrong about what it measures; both measure a straight segment that is not the propagation path when sound goes through a doorway. Cross-room voices now skip both terms entirely: the portal chain's product is the answer, with a floor at the configured minimum occlusion so deep attenuation never fully silences a source.
The simulation moved off the game thread. Direct + indirect Steam Audio solves — 512 rays per source, 4 bounces, a 2-second impulse budget — run on a job-system worker behind an input fingerprint. The main thread writes inputs, commits, dispatches, and harvests the previous round's outputs on a later tick. Rounds only re-fire when something the sim reads actually changed: scene geometry, listener position or room, the reflections flag, or a simulated source's position. Measured rounds run 0.1–12 ms on this level — well inside the ~33 ms cadence budget. The steady-state fingerprint does mean a genuinely drifting listener still dispatches every tick; the skip is for static scenes, not for rescuing noisy input.
The reverb floor bug. Steam Audio's indirect estimator reports RT60 decay times with a floor near 0.1 s. Cross-room, the estimator's impulse response at the listener was legitimately empty — our acoustic scene meshes each room as a closed shell, so rays physically cannot leave the source's room — yet the sim still reported the data as valid, and the 0.1 s floor value was overriding a perfectly good Sabine estimate (the analytic reverberation-time formula, computed per room from volume and surface absorption). Simulated reverb times now only win when the listener shares the voice's room — the only configuration where those rays can actually arrive. Same-room harvests in the hall report 0.5–1.9 s, noise-varying with the Monte Carlo estimator, versus the room's analytic estimate — the space now audibly has a tail.
The clock itself. The "broken clock" turned out to be an asset defect, not a playback bug. The source file was AAC in an MP4 container, and our decoder writes the codec's priming and padding straight into the imported PCM — about 0.18 s of silent preroll and 0.94 s of dead tail in the runtime asset. Every loop played a hole. The replacement is a PCM WAV cut to a 4.010-second loop at quiet points, phase-aligned so the wrap gap is exactly the clip's 1.000-second tick cadence with short cosine fades at the seam. It ticks the way a clock ticks. implemented
Saving is plumbing now. Design-mode saves write the level TOML to the authored source tree when it exists (dev checkout) and always to the exe-relative runtime tree — which is how a packed level gets its first loose override: the vpak entry bytes seed the file, then the save patches it. Portalized cell caches go downstream only; they are regenerable and explicitly excluded from the level pack.
Why it matters for level authoring
The short version: the CSG level is now a place designers can author sound in. Room brushes are paintable acoustic volumes with per-room presets — default, small_dead, large_live, cave, outdoor — that drive both the air-absorption curve over distance and the reverb decay estimate. A designer who wants a dead-sounding corridor paints the corridor. Doors and emitters are ordinary entities in the level TOML with component subtables; the F10 design mode edits them, the portalize step rebuilds cells and the room graph, and saving persists transforms back to the authored TOML.
That is the immersive-sim lineage paying rent. Thief's sound propagation through room brushes was the mechanic — AI heard your footsteps because the same authored room volumes told the engine how far sound carried. We are not there on the AI side yet, but the graph, the doors, the presets, and the save path are all real and testable in csg today.
FlatBuffers: the dependency hiding in the acoustic pipeline
The Steam Audio vendoring question comes up often enough to answer properly. Our build does not pull FlatBuffers because anyone felt like adding a serialization library — it is there because Steam Audio's own object-serialization layer is FlatBuffers all the way down. The engine CMake runs flatc over thirteen upstream .fbs schemas at build time — vector, sphere, triangle, mesh, material, static_mesh, scene, energy_field, reverb, baked_reflection_data, path_visibility, path_data, probe_batch — into generated headers that are pure build artifacts, never committed, and version-locked to the runtime by the commit upstream's dependencies.json names.
Read that schema list as a feature map: it is everything needed to persist a baked acoustic simulation. Scene, mesh, and material describe the geometry the rays bounce off; energy_field, reverb, and probe_batch describe the results of an offline solve; baked_reflection_data and path_data describe how to play those results back without re-solving. Today our simulation is live — rays fly every tick the fingerprint says something moved. The FlatBuffers path is what turns that into a bake-once-at-author-time, load-instantly-at-runtime pipeline: run the indirect solve over probe points when the level is portalized, serialize the energy fields, and the game just reads them. That is on the roadmap precisely because the runtime is already vendored and wired. roadmap
What FlatBuffers actually buys, for anyone who has only met it as a build log line: schema files compile to type-safe accessors; readers bounds-check and tolerate missing fields, so old data loads under new schemas without a version-branch per field; and the wire format is zero-copy — a buffer maps to structs in place rather than deserialize-allocating a parallel object tree. Those properties matter most where a format is a persisted object graph rather than a flat blob stream.
Which is the honest way to survey our own formats. The hand-rolled candidates:
| Format | Today | FlatBuffers fit |
|---|---|---|
.adb asset database | Hand-rolled little-endian appends + magic/version checks | High — a table of records with optional fields is the canonical FB shape; schema evolution kills the version-gate boilerplate |
| VPak directory index | Hand-rolled header + entry table (blob payloads stay raw) | Medium — the index is tabular, but the win is modest; payloads are already opaque bytes |
Portalized cache (.csg.toml) | Text TOML sidecar, checksum-gated, regenerable | Medium — binary load is faster than a TOML parse and it is a cache by contract, so forward-compat is a bonus |
| Save system | Under design (textbook ch. 11 names FlatBuffers as a candidate) | High — savefiles are the classic evolving-schema problem; old saves must load forever |
| Swarm entity deltas | 48-byte packed POD structs on the wire | Low for deltas — a 48-byte hot-path datagram wants no builder or vtable overhead; higher for session/snapshot transfer where versioning matters |
The discipline is the same either way: TOML stays for human-authored truth (levels, config), FlatBuffers earns its place where machines produce and machines read — the asset database, savefiles, baked acoustic fields — anywhere "add a field next month without breaking old files" is a real requirement rather than a hypothetical.
The ledger
| Quantity | Value | Source |
|---|---|---|
| Room / room-portal count | 5 / 4 | CSGRoom build log |
| Portalized cells / cell portals | 27 / 56 | portalize + CSGPortal logs |
| Sim round wall-clock | 0.1–12 ms per ~33 ms tick | sim_debug timing |
| Rays / bounces / IR budget | 512 / 4 / 2.0 s | audio.toml + sim constants |
| Door transmission closed / open | 0.15 / 1.0 | level TOML |
| Clock loop length / cadence | 4.010 s / 1.000 s | decoded PCM |
| Hall RT60 (simulated, same-room) | 0.5–1.9 s, varies per round | harvest logs |
| Unit suite | 658 cases green | test run |
threads:. Zero stall events; the sim budget was never in danger.Caveats and limitations
- Closed-shell acoustics. Room meshes in the sim scene have no portal apertures — the same-room gate on both occlusion and reverb is a correctness shim, not the model. Carving portal polygons into the acoustic scene is the real fix and the largest remaining audio-side item.
- Noise, not ground truth. The 0.5–1.9 s hall RT60 figures are per-round Monte Carlo estimates at 512 rays — they demonstrate that real estimates flow, not that 1.9 s is the hall's measured decay. Temporal smoothing or more rays would stabilize them.
- AAC priming is systemic. The clock was fixed at the asset; the decoder still bakes priming/padding into every other
.m4ait imports. Honoring MP4 edit-list / gapless metadata is the proper fix. - FlatBuffers adoption for our own formats is a proposal, not shipped code — the only FlatBuffers consumer today is Steam Audio's serialization layer.
- Shim, not free physics. Cross-room sources get their room's Sabine reverb scaled by portal gain — a good approximation of "you hear the hall's character through the doorway," not a simulated coupled-rooms solve.
Where this points next
The remaining audio items telescope: portal apertures unlock real cross-room indirect simulation; gapless decoding fixes every looping .m4a; baked acoustics via the already-vendored FlatBuffers schemas turns the live sim into an authoring-time bake. On the design side, the csg vertical slice resumes its main line — F10 brush transform gizmos → portalize → save round-trip — now standing on correct room topology.
The clock ticks. Every second. Through whichever doors you left open.
Related transmissions
- Spherical Portals, Dark Energy, and the Dromed Brush Hierarchy — the theory side: portalize/BSP as a design pipeline.
- Physics Without a Server: Dynamic Authority on the PI Swarm — the 48-byte entity deltas referenced in the FlatBuffers section.
