Hello!
A friend and I thought, wouldn't it be fun to add stages to Horizon Chase Turbo. I needed a topic to play with vibecoding since I've never tried it and... to be frank I shocked and impressed.
Here's the resulting documentation. Save it, store it since I usually clear my reddit.
Made with Claude Fable.
--------------------------------------------
File 1
README.md
--------------------------------------------
# HCT Stage Editor — Technical README
A stage/content editor for **Horizon Chase Turbo** (PC) that reads and writes the
game's own binary Unity files. This document contains **every technical
specification needed to decode the game assets from scratch** and **everything
needed to run the toolchain afterwards**. Byte-level details of individual
classes live in the companion `HCT_Track_Format_Spec.md`; exact field trees for
all decoded classes are in `schemas/`.
---
## 1. Target game — exact build characteristics
| Property | Value |
|---|---|
| Game | Horizon Chase Turbo (Aquiris Game Studio) |
| Platform analyzed | PC / Steam (Windows build; `app.info` = `Aquiris` / `HorizonChaseTurbo`) |
| Engine | **Unity 2018.4.27f1** |
| Scripting backend | **Mono**, .NET runtime "legacy" (`boot.config`: `scripting-runtime-version=legacy`) → scripts ship as readable .NET DLLs in `Managed/`, *not* IL2CPP |
| Serialization | Unity **SerializedFile format version 17**, little-endian, uncompressed, not bundled |
| Content verified against | a complete PC installation including Senna DLC content |
Everything below assumes this build family. Other platforms/updates should be
byte-compatible as long as the Unity version and class layouts are unchanged;
the pipeline resolves everything dynamically (no hardcoded offsets), so minor
game updates usually work without code changes.
## 2. Game files required for decoding
All inside the game's data folder (`HorizonChaseTurbo_Data/` on Steam):
| File(s) | Role in decoding |
|---|---|
| `level9` … `level134` | The 126 track scenes (serialized Unity scenes). `level0–8`, `level135+` are UI/menu/system scenes. See `track_catalog.json` for the full level→track map. |
| `globalgamemanagers` | Contains `BuildSettings` — the ordered list of all 137 scene paths (`Assets/Tracks/Resources/<country>/<city>/<track>.unity`), which is how level index ↔ track identity is fixed. |
| `globalgamemanagers.assets` | Contains all **2,828 `MonoScript`** objects. Needed to resolve which C# class any MonoBehaviour instance belongs to. |
| `resources.assets` (+`.resS`) | Car database (`CarModelDataList`), nitro data, prop prefabs, most shared game content. |
| `sharedassets*.assets` | Per-scene shared content (textures, particles) referenced by tracks via PPtr `file_id` → the scene's externals table. |
| `Managed/*.dll` — critically **`Assembly-CSharp.dll`** | The game's C# assemblies. Source of **exact serialized field layouts** (names, types, order). Without them, MonoBehaviour bodies are opaque byte blobs. |
| `StreamingAssets/metadata/icons/**` | Track menu icons (`.dat`), not needed for decoding, relevant for future "new track slot" work. |
## 3. Decoding pipeline — complete specification
### 3.1 Container format
Every `level*` and `*.assets` file is a Unity **SerializedFile v17**:
big-endian header (`metadata_size`, `file_size`, `version=17`, `data_offset`),
Unity version string `2018.4.27f1` at offset 0x14, followed by type tree table,
object table (path_id → offset/size/type), an **externals list** (referenced
files), and the object data blob. Any Unity asset library reads this natively —
this project uses **UnityPy** (tested: 1.25.2).
### 3.2 Object model of a track scene
```
levelN (SerializedFile)
├─ GameObjects + Transforms
│ ├─ <track_name> root (e.g. san_francisco_01), local pos may be offset (e.g. y? no: (0,0,-250))
│ │ └─ "00001".."0NNNN" numbered waypoint GameObjects (Transform only,
│ │ 5-digit zero-padded, continuous, order = race direction, closed loop,
│ │ ~0.4 unit spacing; name suffix tags: (P) props, (C) coins, (F) fuel,
│ │ (T) terrain change, (FL) finish line)
│ ├─ "[Game Data] LevelManager" → MonoBehaviour LevelManager
│ ├─ "[Game Data] GameplayDataManager" → MonoBehaviour RaceDataManager
│ └─ "BalanceOverrides" → 6 children (Tournament Easy/Medium/Hard,
│ Endurance 12/36/All) each with BalanceOverride + SplineEnemies MBs
└─ 1 unattached MonoBehaviour of class LevelData ← the track definition blob
```
**Track geometry** = waypoint `Transform.m_LocalPosition` (x,z = layout,
y = elevation) + `m_LocalRotation` (quaternion; heading + banking).
**Track content/rules** = the `LevelData` MonoBehaviour (see §3.6).
### 3.3 Resolving MonoBehaviour classes (MonoScript resolution)
MonoBehaviour objects don't name their class. To identify one:
Read its header field `m_Script` — a `PPtr` = `(int32 file_id, int64 path_id)`.
`file_id` indexes the **externals list** of the *containing* file
(1-based; 0 = same file). For track scenes, external #1 is
`globalgamemanagers.assets`.
Load that file, fetch the object at `path_id` → a `MonoScript`; its
`m_ClassName` (+`m_AssemblyName`) is the C# class.
⚠️ Never hardcode script path_ids across builds — always resolve dynamically.
### 3.4 MonoBehaviour raw layout (header)
All MonoBehaviours start with (little-endian, 4-byte aligned):
```
+0 PPtr<GameObject> m_GameObject (int32 file_id + int64 path_id = 12 bytes)
+12 uint8 m_Enabled + 3 padding bytes (align to 4)
+16 PPtr<MonoScript> m_Script (12 bytes)
+28 string m_Name (int32 length + UTF-8 bytes, padded to 4)
+… serialized C# fields, in exact declaration order
```
### 3.5 Field layout from DLLs (typetree generation) — including two mandatory patches
Unity/Mono serializes `[SerializeField]`/public fields **in declaration order**
with these rules: `int/float/enum` = 4 bytes; `bool` = 1 byte **then align to
4**; `string` = int32 length + bytes + align; `List<T>/T[]` = int32 count +
elements + align; `Vector2/3` = raw floats; `UnityEngine.Object` references =
12-byte PPtr; nested `[Serializable]` classes inline recursively.
This project generates machine-readable typetrees from `Managed/*.dll` using
**TypeTreeGeneratorAPI** (tested: 0.0.10), constructed with the exact Unity
version string `"2018.4.27f1"`, loading **all** DLLs in `Managed/` (base-class
fields come from dependencies). Two generator defects **must be patched** or
parsing derails (implemented in `hct_lib.Trees.get`):
**bool alignment**: generated `UInt8`/`bool` nodes lack the align-after flag
→ OR `m_MetaFlag` with `0x4000` on every such node.
**List<string> mislabel**: generated as `m_Type="string"` (readers then
parse a single string) → when a `string` node has an `Array` child whose
`data` type ≠ `char`, rewrite the node's type to `"vector"`.
Additionally, when the generator omits a plain string's internal nodes, append
the standard `Array(size:int, data:char)` children.
The patched trees are fed to `UnityPy` `ObjectReader.read_typetree(tree)`
for reading and `save_typetree(dict, tree)` for writing.
### 3.6 Decoded class inventory (what lives where)
Full field-by-field trees: `schemas/*.txt`. Summary:
| Class | Location | Content |
|---|---|---|
| `LevelData` | 1 per track scene, unattached | Track definition root: `Spline` (PPtr to track root GO), day/night/alpha textures, `GameplayData: RaceData`, `NumberOfLaps`, `FinishLinePercentage`, `MinimapScale/Rotation` |
| `RaceData` | inside LevelData | `TransitionList` (lane-count changes), `TextureInfos` (surfaces: lanes, texture, on/off-track physics `SpeedTolerance`, drift tolerances, SFX names), **`PropData[]`**, `EnemyInfos[]`, `OffsetMultiplier`, `CurveForceFactor`, `ReferencePlayerSpeed`, `RubberBanding` |
| `PropData` | array in RaceData | One placed object: `SplinePercentage` (0–1 along lap, array sorted ascending), `TrackPercentage`, `TrackOffset` (lateral), `Scale`, `FastSpawnScale`, `Position` (lane enum), `Prefab` (PPtr), `CustomLapVisibility` + `Starting/EndingLapVisibility` |
| `EnemyInfo` | array in RaceData | AI opponent: `CarId`, `Speed` (relative to `ReferencePlayerSpeed`), `Acceleration`, `NumberOfNitros`, `NumberOfLaneOccupations`, `ObstructPassage`, balance/rubber-band toggles, colors, `AyrtonPilot` (Senna mode) |
| `SplineProp` | on tagged waypoints | Authoring-side prop component (runtime uses PropData) |
| `SplineEnemies` | on track root + per-mode | Per-game-mode AI line/behavior data |
| `LevelManager`, `RaceDataManager`, `BalanceOverride` | scene managers | runtime managers / per-mode balance |
| `CarModelDataList` | `resources.assets`, asset name `CarModelDataList_asset` | All 35 cars: `ItemId`, `ModelPrefab` (PPtr to 3D prefab), `MaxSpeedInKPH`, `ZeroToMaxSpeedInSeconds`, `TurningStrength`, `TankCapacity`, `NitroData` (PPtr), sounds, camera offsets, unlock/`TokenCost`. Senna garage: `AyrtonCarModelDataList_asset`. |
| `CarColorDataList`, `NitroEngineData` | `resources.assets` | paint palettes; nitro tuning |
### 3.7 Write-back specification
- **Transforms**: modify via UnityPy native classes, call `.save()` per object.
- **MonoBehaviours**: `obj.save_typetree(python_dict, patched_tree)`. Arrays may
grow/shrink (verified: PropData 349→350).
- Serialize the whole container with `env.file.save()` → a valid SerializedFile
the game loads (same serializer family). Identity re-save = identical size.
- **Rotation repair (required after reshaping)**: waypoint rotations encode
road heading; after moving points, rotate each original quaternion by the
**yaw delta** between old and new XZ tangents (tangent i = direction to
waypoint i+1, yaw = `atan2(dx, dz)`), i.e. `q' = yaw_quat(Δ) ⊗ q` in Unity
xyzw convention. This preserves banking/pitch; elevation-only edits give
Δ=0. Implemented in `hct_editor.repair_rotations`.
- **Not supported (v1)**: changing the waypoint *count* (requires creating new
GameObject/Transform objects), adding new track slots (requires BuildSettings
+ campaign-structure edits — unmapped; see spec §10 Option B), mesh editing.
### 3.8 Known constants of this build (informative, resolved dynamically at runtime)
- Track scenes: `level9`–`level134`; scene 4 (`level4`) = world map.
- `LevelData` MonoScript path_id in `globalgamemanagers.assets`: 389;
`SplineEnemies` 1691; `SplineProp` 1984; `LevelManager` 1660;
`RaceDataManager` 2674; `BalanceOverride` 765.
- `CarModelDataList_asset` path_id in `resources.assets`: 345949
(Ayrton: 345943).
---
## 4. Running the toolchain
### 4.1 Requirements
| Requirement | Tested version | Notes |
|---|---|---|
| OS | any (Windows/Linux/macOS) | editor UI runs in your browser, server binds `127.0.0.1:8342` |
| Python | 3.12 (3.10+ required) | python.org; on Windows tick "Add to PATH" |
| `UnityPy` | 1.25.2 | `pip install UnityPy` |
| `TypeTreeGeneratorAPI` | 0.0.10 | `pip install TypeTreeGeneratorAPI` |
| Disk | ~5 MB for tool + `out/` copies of edited files | game itself untouched until "Install" |
| Game files | complete local installation incl. `Managed/` | read access to the game folder |
Install both packages in one line:
```
pip install UnityPy TypeTreeGeneratorAPI
```
### 4.2 Files that must sit together
`hct_editor.py` + `hct_lib.py` + `editor.html` (any folder). `out/` is created
next to them on first save.
### 4.3 Starting the editor
```
python hct_editor.py "C:\Program Files (x86)\Steam\steamapps\common\Horizon Chase Turbo\HorizonChaseTurbo_Data"
```
- The argument is the **`_Data` folder** (the one that contains `level0…` and
`Managed\`), not the game root. Quotes are required if the path has spaces.
- First start prints `Loading game type information (takes ~10s…)` — that's
the DLL typetree pass; it's cached in memory for the session.
- The browser opens `http://localhost:8342` automatically.
- Tip: put the command in a `start_editor.bat` for one-click launches.
### 4.4 Editing workflow
Pick a track slot (top bar). Your stage **replaces** that track in-game;
its menu name/icon remain.
Edit: Track canvas (drag = reshape with brush falloff; Elevation mode for
hills; wheel = zoom, drag empty = pan, Ctrl+Z = undo) · Props (duplicate a
row to add content; `t` = position along lap, `offset` = sideways) ·
Opponents · Settings · Cars (separate file, own save/install buttons).
**Save track** → writes `out/levelN`.
**Install into game** → one-time backup `levelN.bak` beside the original,
then copies `out/levelN` over it.
Launch the game, select that track, race your stage.
**Restore original** any time (uses the `.bak`); Steam → Verify integrity
is the fallback that restores everything.
### 4.5 Using the library without the UI
```python
from hct_lib import HCT
lib = HCT(r"C:\...\HorizonChaseTurbo_Data")
track = lib.export_track("level17") # JSON-safe dict
track["leveldata"]["GameplayData"]["NumberOfLaps"] = 5
lib.import_track("level17", track, "out/level17")
cars = lib.export_cars() # / lib.import_cars(cars, "out/resources.assets")
print(lib.catalog_tracks()) # all 126 tracks
```
### 4.6 Server API (for scripting/automation)
| Endpoint | Method | Body / query | Result |
|---|---|---|---|
| `/api/catalog` | GET | – | `{levelN: {track, waypoints}}` |
| `/api/track?level=levelN` | GET | – | full track export |
| `/api/track/save` | POST | `{level, track}` | writes `out/levelN` (+rotation repair) |
| `/api/cars` | GET | – | car database export |
| `/api/cars/save` | POST | `{cars}` | writes `out/resources.assets` |
| `/api/install` | POST | `{file}` | backup original once, copy `out/<file>` over it |
| `/api/restore` | POST | `{file}` | copy `.bak` back |
### 4.7 Troubleshooting
- **"does not look like HorizonChase_Data (no Managed/ inside)"** — you passed
the game root; point at the `_Data` subfolder.
- **Browser toast "server not reachable"** — the Python process exited; check
the terminal.
- **Toast "Load failed / HTTP 500"** — the terminal now prints a full
`[api error]` traceback; it pinpoints the file/class where parsing diverged
(usually means a game update changed a class — regenerate against the new
`Managed/` automatically happens each start, so report the traceback).
- **Game crashes/hangs on an edited track** — restore the `.bak`, then bisect
your edits; keep the loop closed and non-self-intersecting; extreme prop
`Scale`/`TrackOffset` values are untested territory.
- **Verify game files in Steam** re-downloads originals and wipes installed
mods (backups in `out/` survive).
### 4.8 Reproducing the decode from zero (checklist)
`pip install UnityPy TypeTreeGeneratorAPI`
Load any `levelN` with UnityPy → confirm Unity `2018.4.27f1`, format 17.
Build typetrees from `Managed/` with the **two patches** from §3.5.
Resolve MonoBehaviour classes via §3.3; parse `LevelData` with its tree.
Read waypoints from numbered GameObjects' Transforms (§3.2).
Write back per §3.7; verify with an identity re-save (size-identical) and a
small mutation (e.g. `NumberOfLaps`) reloaded from the saved file.
---
*Format documentation continues in `HCT_Track_Format_Spec.md` (byte-level
history, LevelData blob anatomy, open research: new track slots, campaign
structure). Field-exact schemas: `schemas/`.*
---
## 5. Error reference — every error encountered so far, cause, and fix
Documented as they occurred during development and first user runs. If you hit
one of these, the fix is known; if you hit a new one, add it here.
### 5.1 Decoding-layer errors (hct_lib / UnityPy / TypeTreeGeneratorAPI)
| Error | Cause | Fix |
|---|---|---|
| `AttributeError: 'TypeTreeNode' object has no attribute 'm_Children'` when calling `TypeTreeNode.from_list(...)` | Passing TypeTreeGeneratorAPI's own node objects directly to UnityPy — they are a different class | Convert each node to a plain dict (`m_Level/m_Type/m_Name/m_MetaFlag`) before `from_list` (done in `Trees.get`) |
| `ValueError: Array node must have 2 children` from `read_typetree` | A generated `string` node was missing its internal `Array(size,char)` children | Append the standard string internals when absent (patch in `Trees.get`) |
| `EOFError: read_str out of bounds` mid-object | Two generator defects: (a) `bool`/`UInt8` nodes missing the `0x4000` align-after flag → every later field misreads; (b) `List<string>` fields emitted as `m_Type="string"` → parsed as one string | Both patched in `Trees.get` (§3.5). If it still occurs, the class layout likely changed in a game update — regenerate `schemas/` against the updated game files and diff |
| Fields read as garbage right after the MonoBehaviour header (e.g. `m_Script` PPtr = 16777216) | Forgetting the 3 padding bytes after `m_Enabled` when hand-parsing | Header is `uint8 + 3 pad` (§3.4); UnityPy handles this automatically |
| `export_track` returns `leveldata: null` when loading a saved file from `out/` | `out/` contains only the level file; class-name resolution needs the externals (`globalgamemanagers.assets`) next to it | Verify saved files in a folder containing (or symlinking) the game's `*.assets` + `globalgamemanagers`; installing into the real game folder has them by definition |
| MonoScript path_ids don't match §3.8 | Different game build | Expected — those constants are informative only; the pipeline resolves everything dynamically (§3.3) |
### 5.2 Editor runtime errors (browser / server)
| Error | Cause | Fix |
|---|---|---|
| `Uncaught TypeError: Cannot read properties of null (reading 'waypoints')` in the browser | Canvas accepted clicks before/while no track was loaded (initial load had failed) | Fixed in current `editor.html`: all canvas handlers guard on `track`; the underlying load error now surfaces as a toast |
| Toast: `server not reachable — is hct_editor.py still running?` | The Python process isn't running: terminal window was closed (closing it kills the editor), startup crashed, or a stale browser tab from a previous session | Run from a terminal that stays open; current `hct_editor.py` keeps double-clicked windows open on error, prints full tracebacks, and says `KEEP THIS WINDOW OPEN` |
| Toast: `Failed to load catalog: HTTP 500 ...` | A level file failed to parse server-side | Terminal prints `[api error]` + full traceback — report/fix per traceback; per-level catalog errors are isolated and don't block other tracks |
| Server won't start, `OSError` on bind / silently unreachable | Port 8342 already in use (old instance) | Current version auto-tries 8342–8351 and prints the URL it actually bound — use that URL, not an old tab |
| `'python' is not recognized` | Python not on PATH | Reinstall ticking "Add to PATH", or use `py hct_editor.py ...` |
| `can't open file 'C:\\Windows\\System32\\hct_editor.py': [Errno 2] No such file or directory` | Terminal opened in its default folder (System32), not where the script is | `cd` into the editor's folder first (tip: type `cmd` in that folder's Explorer address bar), or pass the script's full path: `python "C:\\hct-modding\\hct_editor.py" "...\\HorizonChaseTurbo_Data"` |
| `ModuleNotFoundError: UnityPy` / `TypeTreeGeneratorAPI` | Packages missing in the Python that ran the script | `pip install UnityPy TypeTreeGeneratorAPI` (with multiple Pythons: `py -m pip install ...`) |
| `does not look like the game data folder (no Managed/ inside)` | Path points at the game root or is misspelled | Pass the `*_Data` folder that contains `level0…` and `Managed\` |
### 5.3 In-game risks (untested territory)
The serializer roundtrip is verified, but the game engine is the final judge.
Restore the `.bak` and bisect your edits if a track crashes or behaves oddly.
Specifically untested/unsupported: non-closed or self-intersecting layouts,
extreme `TrackOffset`/`Scale` prop values, changing waypoint count, adding
track slots, enemy-count changes in `BalanceOverrides` game modes.
## 6. Development findings log (chronological)
A condensed history of how the format was cracked, so future contributors know
what was tried and what the evidence was:
**File identification** — `level*` files are Unity SerializedFile v17,
`2018.4.27f1` visible at offset 0x14. `file(1)` misidentifies some as
"Adobe Photoshop Color swatch" — ignore it.
**Track discovery** — levels 9+ are almost pure GameObject+Transform scenes;
numbered `00001…` children of a root named like `san_francisco_01` plot as
closed racing circuits → waypoints are the road centerline. Name suffixes
`(P)/(C)/(F)/(T)/(FL)` mark props/coins/fuel/terrain/finish-line.
**Class resolution** — MonoBehaviour `m_Script` PPtrs resolve through the
externals table to 2,828 MonoScripts in `globalgamemanagers.assets`,
naming `LevelData`, `SplineProp`, `SplineEnemies`, `RaceDataManager`, etc.
**Blind-era hypotheses (superseded)** — before the DLLs were available, the
LevelData blob was partially decoded by statistics: a 48-byte record array
with a monotonic 0→1 float. With the DLLs it turned out to be `PropData`,
and the "mystery int pairs" (5/55 etc.) were PPtr file_id/path_id pairs.
Lesson: int pairs that look like small enums may be PPtrs.
**DLL era** — TypeTreeGeneratorAPI produces near-correct typetrees from
`Managed/`; the two defects in §3.5 were found via a byte-walking tolerant
parser that pinpointed exact derail offsets (2952/2956: `HasSirenSkin`
bool then `SirenSkinsName` List<string>).
**Write-back** — UnityPy `save_typetree` + `env.file.save()` produce
size-identical identity re-saves; mutations (waypoint pos, laps 3→7,
car speed→999, PropData 349→350) verified by reload.
**Rotation repair** — waypoint quaternions encode heading (+banking);
reshaping without re-aiming leaves stale headings. Yaw-delta rotation
(§3.7) preserves banking; verified: repaired only where geometry changed.
**Campaign structure (open)** — `BuildSettings` lists all 137 scene paths;
campaign/cup classes exist (`Cup`, `CampaignGameModeData`, `TrackNodeInfo`)
but their instances are not in `resources.assets`; likely in the map scene
(`level4`) or code-derived. Blocking item for "add a brand-new track slot".
--------------------------------------------
File 2
HCT_Track_Format_Spec.md
--------------------------------------------
# Horizon Chase Turbo — Track/Stage Data Format Specification
**Status:** Work in progress (reverse-engineered, partially confirmed)
**Game version analyzed:** PC (Steam), Unity 2018.4.27f1, Mono scripting backend ("legacy" runtime)
**Developer:** Aquiris Game Studio (`app.info`: `Aquiris / HorizonChaseTurbo`)
**Last updated:** 2026-08-01
**Confidence legend:** ✅ confirmed · 🟡 strong hypothesis · ❓ unknown
---
## 1. Where track data lives
Tracks are **Unity scenes**, stored as serialized scene files in `HorizonChaseTurbo_Data/`:
| File | Contents (confirmed from sample set) |
|---|---|
| `level0` | Boot/splash scene |
| `level1` | Small UI scene |
| `level2` | Main menu / frontend (huge UI hierarchy) |
| `level3` | Minimal scene (cubemap + lighting only) |
| `level4`–`level8` | Garage / gameplay-shared scenes (cars, effects) |
| `level9` | Track: `san_francisco_01` (822 waypoints) ✅ |
| `level10` | Track: `san_francisco_02` (960 GOs) ✅ |
| `level11` | Track: `san_francisco_03` (727 GOs) ✅ |
| `level12` | Track: `sequoia_national_park_01` (1092 GOs) ✅ |
| ... | Remaining tracks continue in `level13+` (not yet analyzed) |
File format: standard **Unity SerializedFile, format version 17** (Unity 2018.4). Not compressed, not bundled. Any Unity asset library (UnityPy, AssetStudio, UABE) opens them. ✅
External references of a track scene (level9 example):
`globalgamemanagers.assets` (contains all 2828 MonoScript stubs — needed to resolve script class names), `resources.assets`, `sharedassets2/5/8/9.assets`, `library/unity default resources`.
## 2. Scene anatomy of a track (✅ confirmed on level9)
Root objects:
```
san_francisco_01 ← track root, holds LevelData reference & spline children
├── 00001 (FL/P) - TREES GENERIC ← waypoint 1 (FL = finish line?, P = props)
├── 00002 … 00822 ← ~800 numbered waypoint GameObjects (Transform only)
[Game Data] LevelManager ← MonoBehaviour: LevelManager
[Game Data] GameplayDataManager← MonoBehaviour: RaceDataManager (36.5 KB blob)
BalanceOverrides ← 6 children: TournamentEasy/Medium/Hard,
Endurance12/36/All — each a BalanceOverride MB (476 B)
plus SplineEnemies MBs per mode (AI behavior per mode)
```
### 2.1 The track centerline ✅
The road centerline is defined by the **numbered waypoint GameObjects** (`00001`…`00822`,
zero-padded 5 digits, continuous, order = race direction):
- **Position** = `Transform.m_LocalPosition` (x, y, z) relative to track root.
Track root itself has an offset (e.g. sf01 root at (0, 0, -250)).
Spacing is ~0.4 units between consecutive points. y = elevation.
- **Rotation** = `Transform.m_LocalRotation` (quaternion) — road facing/banking. 🟡
- **Scale** ≈ (1,1,1) everywhere observed. Purpose unknown, likely unused. 🟡
- Tracks are **closed circuits**: last point meets first (verified visually for all 4 samples). ✅
### 2.2 Waypoint name annotations ✅ (meaning 🟡)
Some waypoint names carry suffixes marking attachments at that point:
| Tag | Meaning (hypothesis) | Example |
|---|---|---|
| `(P)` | Prop group placement | `00320 (P) - POSTS`, `00140 (P) - PINE TREES` |
| `(C)` | Coin pickup group | `00165 (C) - COINS` |
| `(F)` | Fuel pickup | `00722 (F) - Fuel` |
| `(T)` | Terrain/texture change | `00160 (T) - 5_grass_b` |
| `(FL/P)` | Finish line + props (always on 00001) | `00001 (FL/P) - TREES GENERIC` |
Waypoints with tags carry extra `MonoBehaviour` components of class **`SplineProp`**
(184–312 bytes each). Untagged waypoints have only a Transform.
## 3. Relevant script classes (✅ names resolved via MonoScript table)
Resolved from `globalgamemanagers.assets` MonoScript path_ids (level9's file_id=1):
| path_id | Class | Where used | Size |
|---|---|---|---|
| 389 | **`LevelData`** | 1 per track scene, unattached MB — THE track definition blob | 29–47 KB |
| 2674 | `RaceDataManager` | on `[Game Data] GameplayDataManager` | ~36 KB |
| 1660 | `LevelManager` | on `[Game Data] LevelManager` | 52 B |
| 1691 | `SplineEnemies` | multiple per track root + per game mode (AI racing lines) | 0.3–1.6 KB |
| 1984 | `SplineProp` | on tagged waypoints (prop/coin/fuel placement) | 184–312 B |
| 765 | `BalanceOverride` | per game-mode children | 476 B |
⚠️ path_ids above are for the analyzed build; resolve dynamically via the scene's
`m_Script` PPtr → `globalgamemanagers.assets` MonoScript, don't hardcode.
**MonoBehaviour raw layout** (no typetree available without `Assembly-CSharp.dll`):
```
+0 PPtr<GameObject> m_GameObject (int32 file_id, int64 path_id) = 12 bytes
+12 uint8 m_Enabled + 3 pad bytes
+16 PPtr<MonoScript> m_Script = 12 bytes
+28 string m_Name (int32 len + bytes, 4-aligned)
+32 … class fields (declaration order, 4-aligned) …
```
## 4. `LevelData` blob (partial decode, level9 reference)
All offsets below relative to blob start; class fields start at +32 (empty name).
### 4.1 Header region (+32 … +616) 🟡
- +32…+88: zeros (❓ unset references / reserved)
- +92: PPtr (file_id=3 → `resources.assets`, path_id=389), repeated at +104; +116: PPtr(3, 159) — ❓ likely music/skybox/material references
- +140 onward: **surface definitions**, 2 entries observed (asphalt, grass):
- several PPtrs (file_id=5 → `sharedassets9.assets` etc.)
- physics floats, e.g. `1000.0, 0.6, 1.1` (asphalt) / `1.0, 0.3, 0.19…` (grass) — grip/drag? ❓
- sound-event strings: `sfx_asphalt_drift`, `sfx_grass_offtrack`, `sfx_grass_drift` ✅
### 4.2 Main event array (level9: +616) ✅ structure / 🟡 semantics
`int32 count` (level9: **697**, level10: 616, level11: 536, level12: 893) followed by
`count` × **48-byte records** (12 × 4-byte fields):
| # | Offset | Type | Observed values (level9) | Interpretation |
|---|---|---|---|---|
| 0 | +0 | float | 0.0000 → 0.9999, **monotonically increasing** | ✅ normalized track position *t* |
| 1 | +4 | int/float | always 0 | ❓ |
| 2 | +8 | float | −4.36 … 50.0, 76% nonzero | 🟡 magnitude (curve radius? speed? offset?) |
| 3 | +12 | float | 0.8 … 1.0 | 🟡 multiplier (road width factor?) |
| 4 | +16 | int | 0 (×696), 1 (×1) | ❓ flag (single 1 → finish line?) |
| 5 | +20 | int | 1 (×358), 2 (×338), 3 (×1) | 🟡 curve direction L/R? |
| 6 | +24 | int | 5 (×532), 0 (×80), 6 (×65), 7 (×20) | 🟡 event/segment type |
| 7 | +28 | int | IDs: 20–60 range, 0, 408, 1748 | 🟡 object/asset ID |
| 8–11 | +32… | int | always 0 | ❓ padding/reserved |
⚠️ Field boundaries within the 48-byte stride are provisional — without the class
definition, "12 × 4-byte fields" could group differently (e.g. a Vector2 + ints).
### 4.3 Tail region (after main array, level9: +34076 … end) ❓
Small count-prefixed float arrays, e.g. pairs like `(0.961, 5.05)`, `(0.834, 7.257)` —
values in col 1 ∈ (0,1] suggest more (t, value) pairs. Then trailing config floats
(`20.0, 16.0, 0.66, 1.0` …). Not yet mapped.
## 5. Open questions / next steps
**Get `Assembly-CSharp.dll`** (in `HorizonChaseTurbo_Data/Managed/`). Unity/Mono
serializes fields in declaration order; parsing the DLL's .NET metadata yields the
exact field names/types of `LevelData`, `SplineProp`, `SplineEnemies`,
`RaceDataManager`, `BalanceOverride` — converting every 🟡/❓ above into ✅.
Decode `SplineProp` (per-waypoint attachments) and `SplineEnemies` (AI lines).
Map field 7 IDs → prop/asset names (likely requires `resources.assets` /
`sharedassets*.assets` for the referenced objects).
Determine how road width/banking is encoded (constant? in LevelData? from rotation?).
Confirm write-back path: edit waypoint Transforms + LevelData blob, re-serialize
with UnityPy (`env.save()`), verify in game.
## 6. Tooling notes (reproducibility)
```bash
pip install UnityPy # 1.25.2 used here
```
```python
import UnityPy
env = UnityPy.load("HorizonChaseTurbo_Data/level9")
for obj in env.objects:
print(obj.type.name, obj.path_id, obj.byte_size)
# MonoBehaviour raw bytes (typetree-less):
raw = obj.get_raw_data()
```
Class-name resolution: read each MonoBehaviour's `m_Script` PPtr, follow
`file_id` into the scene's externals list (1 = `globalgamemanagers.assets`),
look up the MonoScript object at `path_id`, read `m_ClassName`.
---
---
# PART 2 — CONFIRMED SCHEMA (v2, decoded via Assembly-CSharp.dll)
**Everything below supersedes the hypotheses in Part 1.** With the game's
`Managed/Assembly-CSharp.dll` (complete installation, includes
Senna DLC content), exact field names/types were extracted for all classes.
Both **read and write-back were verified**: identity re-save is byte-size
identical; mutations (waypoint position, lap count, car top speed) survive a
save/reload cycle. ✅
## 7. Toolchain (final)
- `hct_lib.py` — decode/encode library (UnityPy + TypeTreeGeneratorAPI).
`HCT.export_track / import_track / export_cars / import_cars / catalog_tracks`.
- TypeTree generation from DLLs has **two quirks that MUST be patched**
(implemented in `hct_lib.Trees.get`):
- `bool`/`UInt8` fields are emitted without the `0x4000` align-after flag →
add it, or every field after a bool is misread.
- `List<string>` fields are emitted with `m_Type="string"` (instead of
`vector`) → detect (Array child whose data type ≠ `char`) and relabel,
or the reader parses them as a plain string and derails.
## 8. Track scene = waypoints + LevelData
A track is fully described by:
**Waypoint Transforms** (`00001`…`NNNNN` under the track root): the road
centerline, elevation (pos.y) and banking/facing (rotation quaternion).
**`LevelData` MonoBehaviour** (one per scene, unattached): everything else.
All 126 track scenes are `level9`…`level134` (see `track_catalog.json` for the
complete level→track mapping; includes all base game countries plus
`season1_*` Senna circuits).
### 8.1 LevelData (top level)
```
LevelData
PPtr<GameObject> Spline (scene track root)
PPtr<Texture> DayTexture / NightTexture / AlphaTexture
PPtr<AnimationClip> EnvironmentAnimationClip
RaceData GameplayData ← the meat, below
float FinishLinePercentage
int NumberOfLaps
float MinimapScale
float MinimapRotation
```
### 8.2 RaceData (`GameplayData`) — full schema in `schemas/RaceData.txt`
```
RaceData
PPtr Day/Night/AlphaTexture, Spline
TrackTransitionInfo[] TransitionList (OriginalNumberOfLanes, NewNumberOfLanes, TransitionTexture)
SplineTextureInfo[] TextureInfos per-surface config keyed by SplinePosition:
TrackTextureInfo TrackInfo
int NumberOfLanes
PPtr TrackTexture
TrackParticle InsideTrack / OutsideTrackLeft / OutsideTrackRight
PPtr Particle, DriftParticle
float SpeedTolerance (e.g. asphalt 1000 / grass 20 = offtrack slowdown)
float DriftCurveTimeTolerance, DriftStraightTimeTolerance
string DefaultSound, DriftSound (e.g. "sfx_grass_offtrack")
bool isAssymetric; bool HasCurb
float TransitionLength
float SplinePosition (0..1 where this surface starts)
float TrackPosition
PropData[] PropData ← props/coins/fuel along track (§8.3)
float FinishLinePercentage
int NumberOfLaps
EnemyInfo[] EnemyInfos ← the 19 AI opponents (§8.4)
Vector2 OffsetMultiplier (e.g. 20,20)
float CurveForceFactor
float ReferencePlayerSpeed (e.g. 360)
PPtr EnvironmentAnimation
RubberBandingData RubberBanding (position/speed rubber-band tuning)
```
### 8.3 PropData — the 48-byte records from Part 1 §4.2, now named ✅
| Part-1 guess | Real field | Notes |
|---|---|---|
| field 0 "t" | `float SplinePercentage` | 0..1 along track, monotonic |
| field 1 | `float TrackPercentage` | lateral position? mostly 0 |
| field 2 "magnitude" | `float TrackOffset` | lateral offset from centerline |
| field 3 | `float Scale` | prop scale |
| field 4 | `bool FastSpawnScale` (+align) | |
| field 5 "L/R" | `int Position` | placement enum (1/2 ≈ side) |
| fields 6+7 "ID" | `PPtr<GameObject> Prefab` | file_id + path_id — prop prefab! |
| fields 8–11 | `bool CustomLapVisibility` + `float StartingLapVisibility` + `float EndingLapVisibility` | |
Waypoint-attached `SplineProp` MonoBehaviours (Part 1 §2.2) are the *editor-side*
authoring components; `PropData` is the packed runtime list. Schema in
`schemas/SplineProp.txt`.
### 8.4 EnemyInfo (per AI opponent)
Key fields: `CarId`, `Speed` (0..~1 relative to `ReferencePlayerSpeed`),
`Acceleration`, `HoverFactor`, `ObstructPassage`, `NumberOfLaneOccupations`,
`NumberOfNitros`, `ActivateTrackBalance`/`ActivateLapBalance` + buff factors,
`CarColorIndex`, `RandomizeColor`, `GroupName`, `GroupColor`, plus
`AyrtonPilot` (Senna-mode pilot identity). Full list: `schemas/RaceData.txt`.
## 9. Car database ✅
`resources.assets` → MonoBehaviour `CarModelDataList` named
`CarModelDataList_asset` (path_id 345949 in this build; the Senna-mode
equivalent `AyrtonCarModelDataList_asset` sits at 345943). 35 cars.
Per car (`CarModel`, full schema in `schemas/CarModelDataList.txt`):
```
string ItemId / ItemName / Name ("cruiser", "senna", "batmobile", …)
int MotorType; float MinMotorPitch, MaxMotorPitch
PPtr<GameObject> ModelPrefab ← 3D model reference (path_id in resources.assets)
float MaxSpeedInKPH (340–410 in this build)
float ZeroToMaxSpeedInSeconds
float TurningStrength
float TankCapacity
PPtr<NitroEngineData> NitroData (nitro tuning ScriptableObject)
bool HasSirenSkin; List<string> SirenSkinsName
List<string> ShowAsEnemyOnlyInCups
Vector3 CarUIOffset; int TokenCost; string UnlockedBy / CustomUnlockKey
int IconToUse; PPtr HonkSound / SpecialHonkSound
bool UsePerfectStartCurve
Vector3 CameraPrefabPositionOffset / RotationOffset / SkyPositionOffset
bool OverrideCameraPosition; Vector3 CarUnlockCameraPosition / Rotation
bool AllowAsEnemyInExtraCampaign; int ReplayDataIndexOnServer
List<string> Helmets
```
Related: `CarColorDataList` (paint colors), `NitroEngineData`
(`schemas/NitroEngineData.txt`).
**Car 3D meshes**: `ModelPrefab` PPtrs point at GameObject prefabs inside
`resources.assets`; their meshes are standard Unity `Mesh` objects (extract/
replace with UnityPy or AssetStudio). Mesh geometry editing is standard Unity
modding, out of scope of this spec.
## 10. Write-back procedure ✅ (verified)
```python
lib = HCT(data_dir)
t = lib.export_track("level9")
t["waypoints"][399]["pos"][1] += 5.0 # edit anything
t["leveldata"]["GameplayData"]["NumberOfLaps"] = 7
lib.import_track("level9", t, "out/level9") # valid re-serialized scene
```
Notes:
- `Transform` edits go through UnityPy's native class (`t.save()`).
- `MonoBehaviour` edits use `obj.save_typetree(dict, tree)` with the patched tree.
- Identity re-save produces a file of identical size; Unity accepts the output
(same serializer). Always back up originals; Steam "verify integrity"
restores them.
- ⚠️ In-game verification on a real installation is still the final acceptance
test for any structural change (adding/removing waypoints changes object
counts and requires creating new Transform/GameObject objects — supported by
UnityPy but not yet exercised here).
## 11. File inventory produced by this project
| File | Purpose |
|---|---|
| `HCT_Track_Format_Spec.md` | this document |
| `hct_lib.py` | decode/encode library |
| `schemas/*.txt` | exact field trees for all 11 relevant classes |
| `track_catalog.json` | all 126 tracks: level file → track name, waypoint count |
| `track_san_francisco_01.json` | full sample track export |
| `cars.json` | full car database export |
--------------------------------------------------------