pcm-zces-parser
pcm-zces-parser reads and writes Pro Cycling Manager 26 .zces stage
files and turns them into things you can look at: an elevation profile, a
route map, a GeoJSON or GPX route on the real-world map, and a readable JSON
summary of the stage.
A .zces file holds a stage's 3D world: terrain, roads, fences, houses,
cities, vegetation, water, sprints and more. The matching .cds file holds
the game's race profile, which
pcm-cds-profile reads.
The format was reverse engineered from the PCM 26 Stage Editor and is described in docs/FORMAT.md; every class is listed in docs/classes.md. Tested on 3,367 workshop stages: all of them convert to JSON and back to identical data.
Install
python -m pip install pcm-zces-parser
Python 3.12 or later. No dependencies. To take elevation and race points from
the stage's .cds file (recommended), also install
pcm-cds-profile; it is picked
up automatically:
python -m pip install "git+https://github.com/Strandfelt/pcm-cds-profile.git@v0.1.0"
Command line
pcm-zces export topclas_rondevv_2026.zces -o out
writes, next to each other:
| File | Contents |
|---|---|
STAGE.profile.svg |
Elevation profile coloured by gradient, with climbs, KOMs, sprints, start and finish labelled and cobbled/gravel sectors marked |
STAGE.map.svg |
Route map, north up, with the stage's other roads, climbs and sectors highlighted, race points and a scale bar |
STAGE.geojson |
The route, every climb and sector as lines and the race points, in real-world coordinates with elevation |
STAGE.gpx |
The route as a GPX track with waypoints, for gpx.studio, Garmin, Strava and the like |
STAGE.stage.json |
Everything above as readable JSON (see below) |
STAGE.html |
Interactive page: profile, map on OpenStreetMap and event list, linked on hover |
--only profile-svg,geojson picks formats, --theme dark switches the SVG
colours, --no-cds ignores the .cds file. pcm-zces profile and
pcm-zces gpx [--format geojson] are shortcuts for single formats.
Lossless conversion of the whole file:
pcm-zces to-json c0_almeria_2020.zces # writes c0_almeria_2020.json
pcm-zces from-json c0_almeria_2020.json -o out # writes out\c0_almeria_2020.zces
pcm-zces info c0_almeria_2020.zces # stage name, version, object counts
from-json reads its own output back before saving, so a broken edit fails
instead of producing a file the game cannot load. python -m pcm_zces_parser
works too.
Library
from pcm_zces_parser import export_stage, load_stage, map_svg, profile_svg, stage_report
view = load_stage("topclas_rondevv_2026.zces") # uses the .cds next to it when available
print(view["name"], view["distance_km"], "km")
for climb in stage_report(view)["climbs"]:
print(f'{climb["summit_km"]:6.1f} km {climb["name"]} {climb["avg_gradient_pct"]} %')
svg = profile_svg(view, width=1600, theme="dark")
export_stage(view, "out", formats=["map-svg", "geojson"])
| Function | Returns |
|---|---|
load_stage(path, cds="auto") |
Stage view: route samples, gradients, race points, climbs, sectors, roads |
stage_view(doc, cds=None) |
The same from an already decoded file and an optional pcm_cds_profile.read_cds result |
profile_svg(view, width=1200, plot_height=300, theme="light", title=True) |
SVG text |
map_svg(view, size=800, theme="light", roads=True, title=True) |
SVG text |
geojson(view) / geojson_text(view) |
GeoJSON dict / text |
gpx_text(view) |
GPX text |
stage_report(view) / stage_report_json(view) |
Readable summary dict / JSON text |
profile_html(view, source_name="", standalone=True) |
HTML page |
export_stage(view, out_dir, stem=None, formats=None, theme="light") |
{format: path} of the files written |
read_zces(path), write_zces(doc, path), to_json(doc), from_json(text) |
Lossless access to every object in the file |
gps_transform(doc) |
Function (x, y) -> (lat, lon) for game coordinates, or None |
A stage view's samples are rows of [km, altitude_m, x, y, lat, lon]
every 200 m (lat/lon only when the stage has a location), and events
lists the race points, climbs and sectors with their km.
The stage JSON
{
"format": "pcm-zces-stage-report",
"stage": {"name": "Antwerpen - Oudenaarde", "region": "Belgium - Flanders", "distance_km": 271.89,
"climbing_m": 2038, "highest_point_m": 149.4, "cobbles_km": 17.11,
"location": {"start": {"lat": 51.2123, "lon": 4.378317}, "finish": {...}, "bounds": {...}}, ...},
"race_points": [{"type": "start", "name": "Antwerpen", "km": 0.0, "altitude_m": 7.2, "lat": 51.2123, "lon": 4.378317}, ...],
"climbs": [{"name": "Oude Kwaremont", "start_km": 137.07, "summit_km": 138.79, "length_km": 1.72,
"avg_gradient_pct": 5.6, "max_gradient_pct": 10.0, "kom_category_code": null,
"summit": {"altitude_m": 119.8, "lat": 50.77124, "lon": 3.531038}}, ...],
"sectors": [{"type": "cobbles", "name": "Paterberg", "start_km": 219.56, "end_km": 220.13,
"length_km": 0.57, "difficulty_stars": 3, "start": {...}, "end": {...}}, ...],
"profile": [{"km": 0.04, "altitude_m": 7.2, "gradient_pct": 0.1, "lat": 51.2123, "lon": 4.378317}, ...]
}
Distances are km from the start line and heights metres, converted from game
units (distance ×10, height ×7.5). The file's units block explains each
field.
Where the numbers come from
- Route. The route's waypoints are road points; between two waypoints the race follows the road they share, built from its cubic Bézier curves. Circuits are repeated for their number of laps.
- Elevation and race points. With the
.cdsfile, the elevation, start, finish, sprints and KOMs are the game's own, and the route is matched to that profile by elevation. 97% of the stages tested match exactly; the rest keep the game's elevation with approximate map positions. Without the.cdsfile the elevation comes from the road geometry. - Climbs and sectors come from the
.zcesfile: climbs with average and maximum gradient, cobbled sectors rated 1–5 stars and gravel sectors 1–3. - Real-world position. The editor builds the terrain from real elevation data over the stage's GPS box, so a stage is the real area at 1:10 scale and converts to latitude and longitude linearly. Positions are as accurate as the stage's own roads; well-built stages land within a few hundred metres. About 6% of the stages tested were built without real terrain and have no location, so GeoJSON and GPX are skipped for them.
The lossless JSON
to-json writes every object of the file:
{
"format": "pcm-zces",
"format_version": 1,
"archive_version": 115,
"flag": 1,
"roots": [1],
"objects": [
{"id": 1, "class": "CTStage", "class_version": 0, "landscape": 2, "route": 721, ...},
{"id": 2, "class": "CTLandscape", "class_version": 0, "gps_min": [-32484210, 361073696], ...}
]
}
- Each object has an
id, its C++classname and the fields of that class in file order. Fields that point to other objects hold that object'sid. - Floats are written with as few digits as still give back the same 32-bit
value; NaN and infinity as
{"f32bits": "..."}. Strings that are not UTF-8 are written as{"cp1252": "..."}so the original bytes survive. - Fields without a known meaning keep the member offset from the game's code,
e.g.
f_0x124.
When editing, counts come from list lengths and ids must stay 1..n in
order. Recompressed files can differ byte for byte from the originals because
the game uses a different zlib build; the decompressed data is identical.
Development
python -m pip install -e . "git+https://github.com/Strandfelt/pcm-cds-profile.git@v0.1.0"
python -m unittest discover -s tests -v
Tests on real stages look for the Steam workshop stage folder and are skipped
without it; set PCM_ZCES_STAGES to point elsewhere and PCM_ZCES_STEP to
test more or fewer files. python tools/make_class_docs.py STAGE_FOLDER
regenerates docs/classes.md.
No game files are included in this repository. This is an independent project and is not affiliated with Cyanide or Nacon.
License
MIT
Metadata
Release files for pcm-zces-parser 0.1.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 | Provenance | |
|---|---|---|---|---|
| pcm_zces_parser-0.1.0.tar.gz | 51.4 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pcm_zces_parser-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 100.5 kB
Release files / pcm_zces_parser-0.1.0.tar.gz
| Download URL | pcm_zces_parser-0.1.0.tar.gz |
|---|---|
| Size | 51.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
729fedf9f6ce5e7eba31615f791f134b812fa64c184c109cdcc121285d046775
|
|
BLAKE2b-256 checksum How to use checksums |
fa46722c919b89804959ea2597ddb00e45687a9956ce5c635b36fb4b1586a722
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 23, 2026.
Transparency logRelease files / pcm_zces_parser-0.1.0-py3-none-any.whl
| Download URL | pcm_zces_parser-0.1.0-py3-none-any.whl |
|---|---|
| Size | 49.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b4b2ed3027d0cf4050203e96065b43a5bc22c4f4ef5d7faab4aa90bea33cea9e
|
|
BLAKE2b-256 checksum How to use checksums |
611fa691d0b6f48621994e189fa22487be0cb15fc9825fdf4f8080521020c836
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 23, 2026.
Transparency logSecurity for pcm-zces-parser 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for pcm_zces_parser-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 23, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.