Formats
pfx promises the JSON outputs and file formats below; each section is taken from the guide that owns it, which says more. A schema version or format number changes only with its page.
Scenes and projects
Section titled “Scenes and projects”Scenes are pfx-scene’s TOML format; a project is a folder with a project.toml.
The format is pfx-scene’s
Section titled “The format is pfx-scene’s”The files are format 1 of the public crate pfx-scene, which owns the format: every file kind, key, type, default and range, ids and references, includes and prefabs, paths, diagnostics and the format 0 to 1 migration. Its spec is docs/format.md in pfx-scene, at the tag crates/load/Cargo.toml pins (v0.1.0). This page keeps only what the engine does with a loaded scene.
From Scenes as data.
A project
Section titled “A project”A project is a folder with pfx-scene’s project.toml at its root (the format, project.toml). Its scenes, libraries, meshes and images are found under that root.
format = 1
[project]name = "my-game"scene = "content/first.scene.toml"
[authoring.pfx]play = "content/first.scene.toml"select = "incoming"| Key | What it says | Read by |
|---|---|---|
[project] name |
the project’s name | the editor’s title and log |
[project] scene |
the scene the editor opens first | pfx edit <folder>, <game> edit |
[authoring.pfx] play |
the scene the game plays; without it, scene |
the game’s main (Project::play) |
[authoring.pfx] select |
the object the editor selects when it opens, if that scene has it | the editor |
[authoring.pfx.screen] |
the game’s [screen] policy (Screens: modes, the device, aspects and scale): its aspects per device, the bars, the safe area and the UI scale |
the game’s config() (Config::screen) and the editor, which frames the scene camera with the policy’s first aspect for the device |
[authoring.pfx] flat |
true for a game with no 3D scene (Config::flat): the editor opens such a project without any *.scene.toml on an empty world, and play draws no 3D pass; a value that is not a boolean is refused |
the editor |
[authoring.pfx] modules |
the game’s gameplay modules: the built .wasm of each, relative to the root; a module’s id is its file’s stem |
the game (Project::modules, pfx_mod::Modules), and the editor’s live reload |
[project] ignore |
folders (or files) not to look in, relative to the root, besides target/ and tmp/ |
pfx-scene’s Project::open, and Project::scenes and folders here |
[tunables.<name>] |
the game’s tunables (Play mode, “Tunables”) | pfx-play, through pfx-scene’s ProjectFile |
[authoring.pfx] is pfx’s authoring table: pfx-scene checks only that it is TOML and keeps it byte for byte.
pfx_project::Project:
Project::open(folder)reads the folder’sproject.tomland refuses a missing file, a missing[project]orname, and ascene,playorselectthat is not a string, naming the file.Project::folder(folder)is the same, and for a folder withoutproject.toml(the engine’s fixture folders) it is a bare project named after the folder.Project::find(path)walks up from a file or folder to the nearestproject.toml.Project::locate(manifest_dir)is what a game’smaincalls: the folder beside the running exe when it holds aproject.toml(a shipped build packsproject.tomlandcontent/beside the exe), else the crate’s own folder (cargo run).scenes()lists every*.scene.toml, andfolders()every content folder (one that directly holds a scene, prefab, material library, proxies file, glTF, glb, buffer, PNG, HDR, WAV, Ogg, font, SVG, KTX2 or EXR), each with its files. Both are sorted by name and skip dot folders, folders with their ownproject.toml, the root’starget/andtmp/(cargo’s output and scratch) and the paths[project] ignorelists, as pfx-scene’s own walk does. The scaffold’sproject.tomllists nothing inignore:target/andtmp/are skipped by default.flat()is[authoring.pfx] flat, false without it.modules()lists the gameplay modules asModule { id, path }, in the ordermodulesnames them; amodulesthat is not a list of.wasmpaths is refused, naming the file.first()is the scene the editor opens:scene, elseplay, else the first scene listed.play()isplay, elsescene, else the first scene listed.
From Projects: one binary per game.
Material libraries
Section titled “Material libraries”One TOML file of named materials that both renderers and the bake share.
pfx_materials::Material is the one physically based description the live renderer, the tracer and the bake share. Material::to_toml and Material::from_toml write and read one material as TOML: every field has Material::default()’s value when left out, unknown keys are refused, and layers is a list of at most four { kind, frequency, amplitude, seed, params } tables (params left out is twelve zeros; see Noise layers below).
A product ships its materials as a library: one TOML file of [materials.<name>] tables, each in that same material TOML. Library::from_toml(text) parses it and Library::to_toml() writes it back, sorted by name, so a library round-trips to the same text and every material to the same bits (PackedMaterial included). A product loads its file with include_str! or from disk and looks materials up by name:
[materials.glass]family = "glass"base = [1.0, 1.0, 1.0]roughness = 0.02transmission = 1.0ior = 1.5thickness = 0.01
[materials."set/blue"]base = [0.1, 0.2, 0.9]
[[materials.wood.layers]]kind = "fbm"frequency = 8.0amplitude = 0.3seed = 1let library = Library::from_toml(include_str!("../render/materials.toml"))?;let glass = library.get("glass").copied().unwrap_or_default();get(name)is an exact lookup;contains,names,iter,lenandis_emptyread the rest, in name order.insert(name, material)adds or replaces one, as a product does when it generates its file from code.- A name is any non-empty text without control characters or spaces at its ends; a name with
/or.is quoted, as above. from_tomlrefuses an unknown top-level key, an unknown material field or family, a non-table entry, a duplicate name and a fifth noise layer, naming the material.pfx bakereads a library through the recipe’smaterials = "<library.toml>"(Probe baking), resolving each glTF material by its name, then by its name before the first., then by the recipe’sfallback.
From Material libraries.
Frame stats
Section titled “Frame stats”One JSON line per presented frame, and an end line.
The line
Section titled “The line”{"schema_version":1,"tick":2,"t_ms":350.239,"sim_ms":0.009,"submit_ms":2.523,"gpu_ms":0.089,"gpu_tick":2,"present_interval_ms":17.253,"kind":"full","size":[160,96],"device":"desktop","hud_ms":null}Every key is always present, in this order. Times are milliseconds as JSON numbers with three decimals, never negative.
| Key | Type | Meaning |
|---|---|---|
schema_version |
integer | 1 |
tick |
integer | the frame’s index, from 0, counting the frames of this file (not the renderer’s submission count, which also counts wake) |
t_ms |
number | monotonic time from the start of the first frame to the start of this one; the first line is 0.000 |
sim_ms |
number or null | the time the game’s simulation took since the previous frame’s submit: the sum of the sim_begin/sim_end spans completed in that interval, which pfx-play’s PlaySession::advance and step report when given the stats (set_frame_stats). null when no span was reported |
submit_ms |
number | CPU time of the frame from the renderer’s first work on it to the end of queue.submit: encoding and submitting, not waiting for the GPU |
gpu_ms |
number or null | GPU time, see below |
gpu_tick |
integer or null | the tick that gpu_ms measured; null exactly when gpu_ms is |
present_interval_ms |
number or null | time from the previous frame’s submit to this one’s. The renderer does not own the swapchain, so this is submit to submit, which a product that presents once per frame holds to its present to present. null on the first line |
kind |
string | full, partial, reproject or idle, as in the table above |
size |
[w, h] |
the renderer’s drawn size in pixels |
device |
string or null | the device the game runs on: steam-deck-lcd, steam-deck-oled or desktop (Screens: modes, the device, aspects and scale). Detected once when the stats open a file (the variable, or FrameStats::to_file), with PFX_DEVICE as an override; null for stats made with to_writer and no set_device |
hud_ms |
number or null | the dev HUD’s own draw cost over this frame, from the start of its draw to the end of its queue.submit (see Dev HUD); null when the HUD did not draw over the frame: hidden, or built without the dev-hud feature |
gpu_ms and its tick
Section titled “gpu_ms and its tick”GPU time comes from the renderer’s timestamp queries and resolves a frame or two after the frame was submitted, so a line cannot hold its own frame’s time at the moment it is written. A line is written when the next frame is submitted (or when the file is closed), and carries the newest GPU time that had resolved by then, against the tick it measured:
gpu_tickis that tick. It is the line’s own tick when the frame had already resolved (a product that waits on its frames, such asrender), and an earlier one otherwise, at most two frames earlier.gpu_msis the sum of the frame’s timed GPU passes. It is one number for the whole frame, with no gaps between passes, so it is below the frame’s wall time whenever the GPU idles.- Each GPU time is written once, on the first line after it resolved. A line with no new time has
gpu_msandgpu_tickbothnull; if two resolve between two lines, only the newer is written. A reader joins GPU time to frames bygpu_tick, never by the line it sits on. - A timing that resolves later than the renderer’s two-frame lag is not reported at all. Never assume one GPU time per line.
- The first frame of a run usually shows
null: its timing resolves after its line is written and is superseded by the next. - On a device with no timestamp queries every line has
null.
From Frame stats.
The end line
Section titled “The end line”On a clean exit (the renderer, and every handle to its stats, dropped, or FrameStats::finish called) the last line is written:
{"schema_version":1,"end":true,"frames":6,"dropped":0}frames is the number of frames the run presented, dropped ones included; dropped is how many of them have no line. So the file holds frames - dropped frame lines before the end line. A file without an end line came from a process that did not exit cleanly (a kill, a crash, process::exit): its lines are valid up to the last complete one.
From Frame stats.
The bench summary
Section titled “The bench summary”The JSON line pfx bench prints last.
The summary
Section titled “The summary”When the run ends, the last thing on stdout is one JSON line (a measured run of the neutral room at 320×180) (keys sorted, no promised order):
{"bench":true,"camera":{"at":[0.0,1.4,4.2],"look_at":[0.0,0.7,0.0]},"dropped":0,"frames":120,"gpu_ms":{"max":0.756,"p50":0.217,"p95":0.223,"p99":0.754},"inputs":0,"over_16_67":0,"scene":"bench.scene.toml","schema_version":1,"seed":5,"size":[320,180],"sim_ms":{"max":1.197,"p50":0.438,"p95":0.69,"p99":0.965},"stats":"tmp/pfx/bench/bench-1791307762.jsonl","submit_ms":{"max":6.919,"p50":2.989,"p95":3.895,"p99":6.391},"ticks":120}| Key | Meaning |
|---|---|
schema_version |
1, changing only with this file |
bench |
true |
scene, seed, size |
as given or defaulted; size is [w, h] |
frames |
the frames the run presented (the stats file’s end line), dropped ones included |
dropped |
how many of them have no line |
ticks |
sim ticks the world ran |
inputs |
recorded events fed over those ticks; 0 without --replay |
camera |
the camera at the end: at and look_at, four decimals |
sim_ms, submit_ms, gpu_ms |
p50, p95, p99 and max of that column, nearest rank, milliseconds with three decimals; null when no line has the column. Tick 0 is left out (it holds pipeline creation), null sim_ms and gpu_ms values are skipped, and gpu_ms is the set of resolved GPU times, joined to frames by gpu_tick in the file, not by line |
over_16_67 |
the lines (tick 0 left out) whose sim_ms + submit_ms or whose gpu_ms is above 16.67 ms |
stats |
the stats file’s path, as written |
Every refusal comes before the GPU opens and before the stats file is created, with nothing on stdout. Arguments it cannot parse (an unknown flag, a missing scene or --seconds, a bad --seconds, --size or --seed) get clap’s answer on stderr with exit 2, as every pfx command does (The game runtime, “Refusals”). The rest print one line, pfx: …, and exit 1: no scene at the path, a scene that does not load, --camera naming no object, a --replay that is missing or not a recording.
From Frame stats.
Input journals
Section titled “Input journals”The compact binary input log a game ships and verifies headless.
The bytes
Section titled “The bytes”All integers are little-endian. A file is a header, the field codes, the tick stream, the checkpoint table and a checksum:
| offset | size | field |
|---|---|---|
| 0 | 4 | magic, the ASCII bytes PFXJ |
| 4 | 2 | u16 format version, 1 |
| 6 | 2 | u16 field count F, 1 to 63 |
| 8 | 8 | u64 seed |
| 16 | 4 | u32 the game’s sim version |
| 20 | 4 | u32 sim::STREAM_VERSION of the build that wrote it |
| 24 | 8 | u64 schema hash |
| 32 | 8 | u64 tick count T |
| 40 | 4 | u32 checkpoint interval N, 0 for none |
| 44 | 4 | u32 checkpoint count C, T / N (0 when N is 0) |
| 48 | 8 | u64 tick stream length L |
| 56 | F |
one code byte per field |
| 56 + F | L |
the tick stream |
| 56 + F + L | 24 C |
the checkpoint table |
| end - 8 | 8 | u64 checksum |
The file is exactly 56 + F + L + 24 C + 8 bytes; a shorter one is truncated and a longer one corrupt.
Field codes. The top two bits are the kind, the low six the width minus one: 0x00 a flag, 0x40 | (bits - 1) unsigned, 0x80 | (bits - 1) signed. Any other byte (0xc0 and up, a flag with width bits set, an unsigned width of 64) is corrupt.
Hashes. Both are FNV-1a 64: start at 0xcbf29ce484222325, and for each byte h = (h ^ byte) * 0x100000001b3 modulo 2^64. The schema hash runs over the field count as a u16 followed by the F code bytes; a reader recomputes it and treats a mismatch as corrupt. The checksum runs over every byte before it.
Varints. Unsigned LEB128, as protobuf writes them: seven bits a byte, low group first, the top bit set on every byte but the last. At most ten bytes, the tenth no more than 0x01, and no trailing zero group (a last byte of 0x00 after another byte): every value has one encoding. Signed deltas are zigzag-mapped first, (d << 1) ^ (d >> 63), so 0, -1, 1, -2 become 0, 1, 2, 3.
The tick stream. Each tick is coded against the previous tick’s values, all zeros before the first tick and again after every checkpoint. The stream is a sequence of records, each starting with a varint h:
heven, a run: the next(h >> 1) + 1ticks repeat the previous values unchanged.hodd, a change:m = h >> 1is a mask of the fields that changed, bitifor fieldi.mis never zero and has no bit at or aboveF. Then, for each set bit from the lowest up, an integer field carries one varint, the zigzagged deltanew - oldin wrapping 64-bit arithmetic, never zero; a flag carries nothing and toggles. Each new value lies inside its field.
A run never spans a checkpoint boundary or the end. With checkpoints, tick k N (the first tick after checkpoint k) starts a new record exactly at that checkpoint’s offset, coded against zeros. After T ticks the stream ends exactly at L.
So an unchanged tick inside a run costs nothing extra (a run of up to 64 ticks is one byte), a button press or release is one byte, and a pointer moving under 64 units a tick on both axes is three: the mask byte and one byte per axis.
Checkpoints. Entry k (from 0) is 24 bytes: u64 tick (k + 1) N, the number of ticks applied; u64 the game’s state digest after that tick; u64 the byte offset into the tick stream where the next tick’s record starts (L for a checkpoint at T). Offsets never decrease.
An example. Fields Unsigned(12), Signed(8), Flag, seed 0x0102030405060708, sim version 9, a checkpoint every 4 ticks, and seven ticks [0,0,0] three times, [100,-3,1], [101,-3,1], [101,-3,0], [101,-3,0], written with digest 0xd1d10003 at tick 4:
5046584a 0100 0300 0807060504030201 09000000 01000000 magic, format 1, 3 fields, seed, sim 9, stream 1805330000586075a 0700000000000000 schema hash, 7 ticks04000000 01000000 0b00000000000000 every 4, 1 checkpoint, 11 stream bytes4b 87 00 Unsigned(12), Signed(8), Flag04 run of 3 unchanged ticks0f c801 05 all three change: +100, -3, flag toggles0f ca01 05 after the checkpoint, against zeros: +101, -3, toggle09 only the flag changes00 run of 10400000000000000 0300d1d100000000 0500000000000000 checkpoint: tick 4, digest, offset 574d8cfcc438c5867 checksumtests_journal.rs pins these bytes; the hashes were checked against an independent FNV-1a.
Versions. The format version changes only with the bytes above; a reader refuses any other. The sim version is the game’s own integer: it bumps it when a change to its sim would replay old inputs differently, and verify refuses another one. The stream version is sim::STREAM_VERSION (see pfx_core::sim: simulation math that replays bit for bit), refused when it is not the build’s.
From pfx_core::sim journal: a compact input log and headless verify.
Input recordings
Section titled “Input recordings”The engine’s own JSON log of a session’s input, which pfx bench –replay reads.
Recording and replay
Section titled “Recording and replay”Input::start_recording starts recording at a step boundary, between step and the next feed. A Recording holds these:
step_us;- a
Startsnapshot: the events that rebuild every connected pad and held control, the action states, the active device, the last pad, and the pointer anchor; - every step’s events, after normalization, so the pointer is already in layout units.
The recording serializes with serde, and serde_json round-trips each f32 exactly.
To replay, build a fresh Input with the same actions and bindings, call begin_replay(&recording), then call update(&mut recording.player()) once per step. Every action state, the pointer, the active device and the change events come back bit for bit: the test compares the f32 bits over 600 steps of random input. That lets pfx shots and replays drive a game without a device. Rumble commands during a replay go to Player::motors and are not played.
What a recording is for. It is the engine’s own log: shots, the editor’s play, a bug replayed tick for tick. It is not a format to ship. Its events are serde enums with f32 pointers, so 25 minutes at 60 steps a second with the pointer moving every step is about 5.7 MB of JSON (0.9 MB deflated), it replays only through the same actions and the same bindings (a player’s rebinds included), and RECORDING_VERSION changes whenever an event does. A game that ships replays (a leaderboard check, a 1v1) logs its own per-tick sim input instead: what its tick actually reads, quantized to the integers its sim runs on (the pointer in sim units, a bit per action), delta and varint coded, a few bytes a tick, with its seed and its own sim version, and re-runs its sim from that log headless with no Input at all.
From Input.
Steam depots
Section titled “Steam depots”The manifest pfx steam stage reads and the files it writes.
steam.toml
Section titled “steam.toml”Paths in path are relative to the directory that holds the manifest, or absolute. Every one of them must stay inside the caller’s git tree, inside the folder the tree’s own target link resolves to, or inside the CARGO_TARGET_DIR pfx runs with, so a build in a shared target folder outside the clone stages too, whether cargo reaches it through CARGO_TARGET_DIR or through a target symlink at the root of the tree. A target folder that holds the caller’s tree widens nothing. A missing file, a path that leaves all of them, a symlink, a depot with no files, a duplicate depot id, and setlive = "default" without setlive_default = true are refused.
An executable built with pfx-game’s tools feature is refused too: every staged file that starts as an ELF, PE or Mach-O binary is scanned for pfx-game’s tools marker (The game runtime, “The tools feature and a Steam build”), and one that carries it stops the stage before anything is written, with an error that names the file and says to build the Steam binary with cargo build --release --no-default-features. A release that builds without tools passes.
setlive is the branch SteamPipe sets live after a successful upload. Omitted, it is empty, and Steam does not move a branch. default is the public branch. It is written only when the manifest also sets setlive_default = true. preview defaults to false. true writes Steam’s preview build ("preview" "1"), which does not upload.
Depots are windows, linux, and macos. Each has an id and a list of file mappings. path is a file or a directory the caller’s build produced. depot is the path inside the install. For a file it is that file’s install path, and a trailing slash means the source file’s name inside that folder. For a directory it is the install folder, and the directory’s children are staged under it. A depot path uses forward slashes and cannot leave the depot.
app_id = 1000description = "Example Game 1.0.0"setlive = ""preview = false
[depots.windows]id = 1001
[[depots.windows.files]]path = "build/windows/example-game.exe"depot = "example-game.exe"
[[depots.windows.files]]path = "build/windows/assets"depot = "assets"
[depots.linux]id = 1002
[[depots.linux.files]]path = "build/linux/example-game"depot = "example-game"From Steam.
What gets written
Section titled “What gets written”<out>/app_build_<appid>.vdf<out>/depot_build_<depotid>.vdf<out>/content/<depotid>/...<out>/output/<out>/steam-manifest.jsonThe scripts are Valve’s ContentBuilder shape: an appbuild script whose depots block names one depot_build_<depotid>.vdf per depot, and a DepotBuildConfig per depot with ContentRoot content/<depotid> and one FileMapping per manifest entry. buildoutput is output, next to the scripts, for steamcmd’s logs. contentroot is content. local is empty.
The staged files are copies of the mapped sources. <out>/steam-manifest.json lists every staged file with its path inside the depot, its size in bytes, and its sha256, so the release step can check the tree it is about to upload. The same steamcmd line is recorded there as steamcmd.
The runtime crate, with everything above, is separate from this command. The upload itself stays with the release process.
From Steam.
Baked detail atlases
Section titled “Baked detail atlases”What pfx bake –detail writes and the engine loads.
Files and manifest
Section titled “Files and manifest”<output>/detail/ holds manifest.json (with manifest.sha256) and three KTX2 files per page, page-NNN-base.ktx2, page-NNN-normal.ktx2 and page-NNN-roughness.ktx2:
| map | format | bytes per texel |
|---|---|---|
| base colour | BC7, sRGB (VK_FORMAT_BC7_SRGB_BLOCK), opaque |
1 |
| normal | BC5 (VK_FORMAT_BC5_UNORM_BLOCK), tangent x and y; z = √(1 − x² − y²) |
1 |
| roughness | BC4 (VK_FORMAT_BC4_UNORM_BLOCK) |
½ |
The KTX2 files are single 2D images with their mip levels, a basic data format descriptor and no supercompression. atlas::ktx2 writes and reads them, and a test parses them with the ktx2 crate. The manifest says schema_version: 2 and format: "detail-atlas", or schema_version: 3 when any part bakes through TEXCOORD_1, so that an engine from before the second set refuses the atlas instead of sampling those parts through the wrong UVs. An atlas without such parts stays at 2, readable by both. It records input_hash (every part’s input hash with the page side, padding and the atlas code), padding, align, page (width, height, count, levels), the format of each map, total_bytes (the KTX2 files), vram_bytes (the levels as uploaded), decoded_vram_bytes (the same as RGBA8, the CPU-decode path), psnr per map kind against the uncompressed pages (dB over each part’s crop: base RGB, normal XY, roughness; null when lossless), the files with bytes and sha256, and per part part (the scene-order number), node, size, crop (x, y, width and height in tile texels), page, at (the crop’s corner on the page), scale and offset (the UV transform), its share of bytes, its input_hash and uv_set (0 for TEXCOORD_0, 1 for TEXCOORD_1). A reader takes a missing uv_set as 0 and refuses a set above 1, or a schema 2 manifest with a part on set 1.
The encoder is block_compression 0.10 (MIT). It is a pure-Rust CPU port of Intel’s ISPC texture compressor, built with its default features off (no wgpu, so only bytemuck), from crates.io with nothing else fetched. BC7 uses its opaque_slow settings. The same input gives the same bytes. The bake splits a level into bands of block rows across threads, and a test checks that the split gives the same blocks as one call. Its decoder is the CPU path.
From Probe baking.