Skip to content

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 are pfx-scene’s TOML format; a project is a folder with a project.toml.

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 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’s project.toml and refuses a missing file, a missing [project] or name, and a scene, play or select that is not a string, naming the file. Project::folder(folder) is the same, and for a folder without project.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 nearest project.toml. Project::locate(manifest_dir) is what a game’s main calls: the folder beside the running exe when it holds a project.toml (a shipped build packs project.toml and content/ beside the exe), else the crate’s own folder (cargo run).
  • scenes() lists every *.scene.toml, and folders() 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 own project.toml, the root’s target/ and tmp/ (cargo’s output and scratch) and the paths [project] ignore lists, as pfx-scene’s own walk does. The scaffold’s project.toml lists nothing in ignore: target/ and tmp/ are skipped by default.
  • flat() is [authoring.pfx] flat, false without it.
  • modules() lists the gameplay modules as Module { id, path }, in the order modules names them; a modules that is not a list of .wasm paths is refused, naming the file.
  • first() is the scene the editor opens: scene, else play, else the first scene listed. play() is play, else scene, else the first scene listed.

From Projects: one binary per game.

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.02
transmission = 1.0
ior = 1.5
thickness = 0.01
[materials."set/blue"]
base = [0.1, 0.2, 0.9]
[[materials.wood.layers]]
kind = "fbm"
frequency = 8.0
amplitude = 0.3
seed = 1
let 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, len and is_empty read 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_toml refuses 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 bake reads a library through the recipe’s materials = "<library.toml>" (Probe baking), resolving each glTF material by its name, then by its name before the first ., then by the recipe’s fallback.

From Material libraries.

One JSON line per presented frame, and an end 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 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_tick is that tick. It is the line’s own tick when the frame had already resolved (a product that waits on its frames, such as render), and an earlier one otherwise, at most two frames earlier.
  • gpu_ms is 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_ms and gpu_tick both null; if two resolve between two lines, only the newer is written. A reader joins GPU time to frames by gpu_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.

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 JSON line pfx bench prints last.

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.

The compact binary input log a game ships and verifies headless.

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:

  • h even, a run: the next (h >> 1) + 1 ticks repeat the previous values unchanged.
  • h odd, a change: m = h >> 1 is a mask of the fields that changed, bit i for field i. m is never zero and has no bit at or above F. Then, for each set bit from the lowest up, an integer field carries one varint, the zigzagged delta new - old in 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 1
805330000586075a 0700000000000000 schema hash, 7 ticks
04000000 01000000 0b00000000000000 every 4, 1 checkpoint, 11 stream bytes
4b 87 00 Unsigned(12), Signed(8), Flag
04 run of 3 unchanged ticks
0f c801 05 all three change: +100, -3, flag toggles
0f ca01 05 after the checkpoint, against zeros: +101, -3, toggle
09 only the flag changes
00 run of 1
0400000000000000 0300d1d100000000 0500000000000000 checkpoint: tick 4, digest, offset 5
74d8cfcc438c5867 checksum

tests_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.

The engine’s own JSON log of a session’s input, which pfx bench –replay reads.

Input::start_recording starts recording at a step boundary, between step and the next feed. A Recording holds these:

  • step_us;
  • a Start snapshot: 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.

The manifest pfx steam stage reads and the files it writes.

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 = 1000
description = "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.

<out>/app_build_<appid>.vdf
<out>/depot_build_<depotid>.vdf
<out>/content/<depotid>/...
<out>/output/
<out>/steam-manifest.json

The 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.

What pfx bake –detail writes and the engine loads.

<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.