PYG Format Specification
This document specifies the PYG1 binary container format, the format
produced by pyg build and read directly (not via extraction) by pyg run,
pyg list, pyg info, pyg validate, and pyg extract. It also documents
the legacy ZIP-based .pyg format that PYG1 replaces, and the compatibility
contract between them.
Reference implementation: pyg/format/ (PYG1) and pyg/legacy/ (legacy ZIP).
1. Overview
A .pyg file is a single-file game package: game code, assets, and a JSON
manifest bundled together for distribution. Two things can produce a .pyg
file today:
| Format | Magic / signature | Status | Reader |
|---|---|---|---|
| PYG1 | 50 59 47 31 ("PYG1") at byte 0 |
Current, default | pyg.format.reader.Pyg1Package |
| Legacy ZIP | Standard ZIP local-file-header signature (PK\x03\x04) |
Read-only, deprecated | pyg.legacy.zip_reader.LegacyZipPackage |
pyg.loader.open_package() detects which one it's looking at and returns
the matching reader, both of which expose the same interface — see
§8 Compatibility.
PYG1 is a game distribution container, not a general-purpose filesystem. It deliberately does not include (and is not intended to grow): encryption, DRM, online activation, networking, or executable machine-code packaging.
2. Binary layout
A PYG1 file is four regions, laid out in this order:
Offset 0
┌────────────────────────────────────────────┐
│ PYG1 HEADER (128 bytes, fixed) │
├────────────────────────────────────────────┤
│ METADATA BLOCK (meta.json bytes) │ <- metadata_offset
├────────────────────────────────────────────┤
│ FILE TABLE (N variable entries)│ <- file_table_offset
├────────────────────────────────────────────┤
│ FILE DATA (raw/compressed │ <- data_offset
│ file bytes) │
│ main.py │
│ __init__.py │
│ icon.png │
│ assets/player.png │
│ assets/background.png │
└────────────────────────────────────────────┘
Every region's location and size is given explicitly in the header as an absolute byte offset + size pair, so a reader validates all four regions' bounds before trusting any of them (see §7 Validation). The builder always lays regions out contiguously in this order, but nothing in the reader requires that — a future writer could reorder them and old readers would still work, as long as the header's offsets are correct.
All multi-byte integers are little-endian.
3. Header (128 bytes, fixed size)
| Offset | Size | Field | Type | Description |
|---|---|---|---|---|
0x00 |
4 | magic |
bytes | Always 50 59 47 31 ("PYG1") |
0x04 |
2 | version |
u16 | Format version. 1 for PYG1. |
0x06 |
2 | header_size |
u16 | Size of this header in bytes (128 in v1). Lets a future reader know how much header to skip even if it doesn't understand every field. |
0x08 |
4 | flags |
u32 | Bitflags — see below |
0x0C |
8 | metadata_offset |
u64 | Absolute byte offset of the metadata block |
0x14 |
8 | metadata_size |
u64 | Size in bytes of the metadata block, as stored (see METADATA_COMPRESSED flag) |
0x1C |
8 | file_table_offset |
u64 | Absolute byte offset of the file table |
0x24 |
8 | file_table_size |
u64 | Total size in bytes of the file table region |
0x2C |
4 | file_count |
u32 | Number of entries in the file table |
0x30 |
8 | data_offset |
u64 | Absolute byte offset where file data begins |
0x38 |
8 | data_size |
u64 | Total size in bytes of the data region |
0x40 |
4 | header_crc32 |
u32 | CRC32 of bytes [0x00, 0x40) — everything above, computed over the unpacked field values, not including this field or the reserved tail |
0x44 |
60 | reserved |
bytes | Zero-filled. Reserved for PYG2+. |
Total: 128 bytes (0x00–0x80).
flags bit definitions (v1)
| Bit | Name | Meaning |
|---|---|---|
| 0 | METADATA_COMPRESSED |
Metadata block is zlib-compressed |
| 1–31 | — | Reserved, must be 0 in v1 |
Why a header CRC?
header_crc32 lets a reader detect a corrupted or hand-edited header
before trusting any offset in it — this is checked first, ahead of
per-region bounds checking, so a single flipped byte anywhere in the header
is caught deterministically rather than potentially producing a
seemingly-valid-but-wrong read.
Why header_size as its own field?
A PYG2 header can be longer than 128 bytes (using some of today's reserved space, or growing past it) without breaking a PYG1-only reader's ability to locate the metadata block, file table, and data — those are all absolute offsets from byte 0, not "immediately after the header."
4. Metadata block
The metadata block holds the game manifest (conventionally authored as
meta.json in the source project) as UTF-8 JSON bytes, located via
metadata_offset/metadata_size. If METADATA_COMPRESSED is set, the
stored bytes are zlib-compressed and must be inflated before parsing.
Manifest schema
{
"format": "pyg",
"format_version": 1,
"name": "My Game",
"id": "com.example.mygame",
"version": "1.0.0",
"author": "Joven",
"entry": "main.py",
"icon": "icon.png"
}
| Key | Required | Notes |
|---|---|---|
name |
yes | Non-empty string |
id |
yes | Non-empty string (reverse-DNS style by convention) |
entry |
yes | Non-empty string; a path that must exist in the file table after build |
format |
no | Defaults to "pyg" if omitted |
format_version |
no | Defaults to 1 if omitted |
version, author, icon, ... |
no | Free-form; unknown keys are preserved, not rejected |
The reader can fetch and parse metadata with a single seek + read — no file
table parsing or extraction required, which is what makes pyg info fast
even on a large package.
meta.json itself is not duplicated as a regular file table entry; it
exists only as the metadata block.
5. File table
Located at file_table_offset, spanning exactly file_table_size bytes,
containing exactly file_count entries back-to-back. Each entry is
variable-length (because paths vary):
| Field | Type | Description |
|---|---|---|
entry_size |
u32 | Total byte length of this entry, including this field |
path_length |
u16 | Length in bytes of the path string that follows |
path |
bytes | UTF-8, POSIX-style (/), relative, no leading /, no .. components |
data_offset |
u64 | Absolute file offset where this entry's (possibly compressed) data begins |
compressed_size |
u64 | Byte length of the stored data, as written (post-compression if compressed) |
original_size |
u64 | Byte length after decompression |
compression_method |
u8 | See §6 |
checksum_method |
u8 | 0 = none, 1 = CRC32 |
checksum |
u32 | CRC32 of the original (decompressed) data, or 0 if checksum_method == 0 |
entry_flags |
u16 | Reserved, 0 in v1 |
reserved |
8 bytes | Zero-filled, reserved for future per-entry metadata |
Fixed portion (everything except path) is 46 bytes; total entry size
is 46 + path_length.
entry_size being self-describing means a reader can walk the table by
repeatedly jumping entry_size bytes forward, without needing to fully
understand every field in an entry written by a newer format version —
the same forward-compatibility idea as header_size.
Loading the whole table is one seek + read (it's typically tiny — just entries, not data); after that, locating any individual file is a dictionary lookup, and reading it is one more seek + read into the data region. No full-package scan or extraction is needed to read one file.
6. Compression
Compression is applied per file, not to the package as a whole — this is what makes random access to a single file possible without touching any other file's data.
Method enum
| Value | Name | Status |
|---|---|---|
0 |
STORED |
Implemented — raw bytes, no compression |
1 |
ZLIB |
Implemented — zlib/deflate |
2 |
ZSTD |
Reserved for a future version |
3 |
LZMA |
Reserved for a future version |
Builder policy
For each file, the builder tries zlib and keeps the compressed result only
if it's actually smaller than the original — otherwise it falls back to
STORED. Empty files are always STORED (zlib has non-zero overhead even
for zero input bytes). This means, for example, a short main.py might
stay STORED while a large, repetitive dialogue .txt file compresses to
a fraction of its size — both are legal, and the file table simply records
whichever method was actually used for that file.
7. Checksums
Every file entry (as written by the current builder) carries a CRC32
of its original, decompressed content, computed with checksum_method = 1.
On read, after decompression, the reader recomputes CRC32 over the
decompressed bytes and compares it to the stored value — a mismatch raises
ChecksumMismatchError rather than silently returning corrupted data.
Checksum verification can be explicitly skipped per-read
(pkg.read(path, verify_checksum=False)) for tooling that wants raw access
regardless.
There is no package-level checksum in PYG1 — header_crc32 covers header
corruption, and per-file CRC32 covers data corruption; a whole-package hash
was deliberately left out to keep the format simple, per the "don't
overcomplicate PYG1" design goal.
8. Compatibility and format detection
Format detection order
if file starts with PYG1 magic bytes:
use PYG1 reader (pyg.format.reader.Pyg1Package)
elif file is a valid ZIP archive:
use legacy reader (pyg.legacy.zip_reader.LegacyZipPackage)
else:
reject as an invalid .pyg package
Implemented in pyg.loader.open_package(), used by every CLI command
except build (which always produces PYG1) — so a game distributed as an
old ZIP-based .pyg continues to run, list, validate, and extract exactly
as it did before, with no user-visible difference.
Shared reader interface
Both Pyg1Package and LegacyZipPackage expose:
read_metadata() -> dict
list_files() -> list[str]
get_entry(path) -> object with at least .original_size
read(path, *, verify_checksum=True) -> bytes
extract_all(dest_dir) -> Path
close()
...plus context-manager support (with open_package(path) as pkg:). Code
written against this interface doesn't need to know or branch on which
concrete format it received.
What's different for legacy packages
- No binary header/file-table to validate —
pyg validateinstead checks ZIP integrity (zipfile.testzip()) and manifest validity. get_entry()returns compression info as a string ("STORED"/"DEFLATED") rather than the PYG1 integer enum, since ZIP's own compression model doesn't map onto PYG1'scompression_methodvalues.- Checksum verification is handled internally by Python's
zipfilemodule duringread()(it raises on CRC mismatch itself); there's no separate verification step to opt in or out of.
What's the same
Everything a caller actually needs day to day: metadata, file listing, individual file reads, and safe extraction all work identically regardless of format.
9. Validation rules
Run by pyg validate (pyg.validator.validate_pyg(), dispatching to
validate_pyg1() or validate_legacy_zip() by detected format).
PYG1
- File is at least 128 bytes (header fits)
magic == b"PYG1"versionis supported by this buildheader_size >= 128header_crc32matches a recomputed CRC32 of the header- All four regions'
offset + size <= file_size(no out-of-bounds reads; checked with arbitrary-precision integers, so no overflow risk) - No two regions overlap
- Metadata block parses as valid JSON and contains
name,id,entry - File table has exactly
file_countentries, and parsing it consumes exactlyfile_table_sizebytes - Every path is safe: no
.., no leading/or\, no drive letters, no empty path segments - No two file entries share the same path
- Every entry's data span falls inside the declared data region
- No two entries' data spans overlap each other
- Every file decompresses successfully and its checksum (when present) matches its content
Legacy ZIP
- File is a well-formed ZIP archive (
zipfile.testzip()finds no corrupt members) meta.jsonis present, parses as JSON, and containsname,id,entry
10. Security model
.pyg files — of either format — are treated as untrusted input:
- Path safety is enforced on every entry, both when writing (defense
against a buggy builder) and when reading (defense against a hostile or
corrupted package): absolute paths (
/etc/passwd,C:\Windows\...), parent-directory traversal (../../file), and empty/./..path segments are all rejected before any file is opened for writing. extract_all()additionally resolves each target path and confirms it stays inside the destination directory before writing, as a second, independent check beyond path-string validation.- All header and file-table offsets/sizes are bounds-checked against the
actual file size before any seek/read happens, and sanity-bounded
(
MAX_REASONABLE_SIZE,MAX_REASONABLE_FILE_COUNTinpyg/format/constants.py) to reject obviously-corrupt or maliciously crafted header values before they're used for anything. - Nothing in the header or file table is trusted for control flow beyond locating and bounds-checking bytes to read.
Deliberately out of scope: encryption, DRM, code signing, or any attempt to make package contents tamper-proof rather than tamper-evident. CRC32 (both levels) detects accidental corruption; it is not a security mechanism against a deliberate attacker who can rewrite the whole file, including its checksums.
11. Building and running packages
Build
pyg build MyGame/ # writes MyGame.pyg next to the project dir
pyg build MyGame/ -o dist/Game.pyg # explicit output path
pyg build MyGame/ --format pyg1 # explicit format (currently the only option)
pyg build MyGame/ --compile # ship .pyc bytecode instead of .py source
MyGame/ must contain meta.json with at least name, id, and entry.
Every other file under the directory is packaged; meta.json itself becomes
the metadata block rather than a regular file entry. See §12
before relying on --compile for anything beyond casual source hiding.
Inspect
pyg info MyGame.pyg # print the manifest as JSON
pyg list MyGame.pyg # print every file path (and size) in the package
pyg validate MyGame.pyg # run every check in §9, print a pass/fail report
All three work directly against the binary layout — no extraction step — and all three auto-detect PYG1 vs. legacy ZIP.
Extract
pyg extract MyGame.pyg # extracts to ./MyGame/
pyg extract MyGame.pyg -o some/dir/ # extracts to a specific directory
Run
pyg run MyGame.pyg
pyg run MyGame.pyg -- --level 3 # extra args are passed to the entry point
pyg run MyGame.pyg --keep-temp # don't delete the runtime dir afterward (debugging)
pyg run (pyg.runtime.run_pyg1) does the following, matching the flow
described in the original design:
- Open the package (format auto-detected)
- Validate the manifest has an
entry - Create a fresh temporary runtime directory
extract_all()the package into it- Confirm the entry point file actually exists post-extraction
- Launch it with the current Python interpreter, with
cwdset to the runtime directory (so relative asset paths likeassets/player.pngresolve exactly as they would unpackaged) - Wait for it to exit, propagating its exit code
- Delete the runtime directory (in a
finally, so this happens even if the game crashes or the process is interrupted) — unless--keep-tempwas passed
12. Source protection — what PYG1 does and does not do
PYG1 provides integrity, not confidentiality. pyg extract (and the
same code path that powers pyg run) will always recover the exact bytes
that were packaged, by design — that's what makes the format actually
runnable. Renaming a .pyg file to .zip no longer works (unlike the old
format — see §8), which stops
casual browsing with a generic archive tool, but it does not stop
pyg extract itself, since that's the legitimate "run this game" path and
can't be removed without breaking pyg run.
pyg build --compile ships compiled .pyc bytecode instead of .py
source: every .py file is compiled with the standard library's
py_compile and only the bytecode is packaged (the source text is not
included in the output file at all); meta.json's entry field is
rewritten automatically (main.py → main.pyc) if it pointed at a
compiled file. This raises the bar from "open in a text editor" to "run a
decompiler" — meaningfully higher friction, genuinely useful against
casual viewing — but it is explicitly not encryption and does not stop
a determined person: bytecode decompilers (e.g. decompyle3,
uncompyle6) recover source close to the original from .pyc files.
Real encryption was deliberately excluded from PYG1's goals (see
§1) and isn't a natural fit for a Python-executed format
regardless: whatever key or scheme decrypts a file at runtime has to be
reachable by the interpreter that's about to execute it, so it slows down
extraction rather than preventing it. For a distributable Python game,
there is no tier that stops a sufficiently determined person — the
interpreter that runs the game always has to see real, executable source
or bytecode. --compile is the practical ceiling for this format, not a
missing feature waiting to be escalated.
13. Extending the format (PYG2+)
Designed extension points, so a future version doesn't require rewriting readers from scratch:
- Header:
header_sizelets old readers skip past new trailing fields they don't understand, and 60 bytes ofreservedspace exist for new fixed-position fields before the header needs to grow past 128 bytes. - File table:
entry_sizelets a reader skip to the next entry even if a newer format added fields to the end of the entry it doesn't recognize. - Compression:
compression_methodis an open enum;ZSTD(2) andLZMA(3) are reserved values, not yet implemented — adding one is a change topyg/format/compression.pyplus the writer's method-selection logic, not a layout or reader change. - Metadata: the
METADATA_COMPRESSEDflag is already defined even though the current builder doesn't set it, for exactly this kind of forward wiring.
None of this is implemented for a hypothetical PYG2 today — this section just documents why the current layout looks the way it does.
Release files for pygpack 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pygpack-1.0.0.tar.gz | 41.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pygpack-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 75.0 kB
Release files / pygpack-1.0.0.tar.gz
| Download URL | pygpack-1.0.0.tar.gz |
|---|---|
| Size | 41.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9396d5bfbb748c7c52cb5999b1c09a158afe144bf557ee293877f591f7b4526d
|
|
BLAKE2b-256 checksum How to use checksums |
8aad6e389d4babec383e6968838b9b1ec90c79665cb5ed620e38f8382acca0ec
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.6
|
Release files / pygpack-1.0.0-py3-none-any.whl
| Download URL | pygpack-1.0.0-py3-none-any.whl |
|---|---|
| Size | 33.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c0854782d84453ccebc64088afda127e9d7a445cbf9a9889dccf748b2d6cdab1
|
|
BLAKE2b-256 checksum How to use checksums |
7156526be4697e713feaa711889e45e76bb4e7312a464dbea22986103209f934
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.6
|