Pinyon Shift loads mods: native libraries that react to the title through hook points and a C API, and asset mods that replace game files. This page is the contract for mod authors. The design and its history are in the native port backlog (NP-7).
Vulkan mods put independently authored SPIR-V in mods/<name>/shaders/.
Use an ordinary mod.toml without library; a visual-only mod can declare
shares_save = true. Only successfully loaded mods participate. The earlier
mod in resolved load order wins for each exact shader identity, as for
game/ files. Dependencies and load_after are resolved first. Restart
after changing mods or bytecode.
Each file is named
shader_<guest hash>_<modification>_<translated hash>.spv, with three
uppercase 16-digit hexadecimal values. The last value is XXH3-64 of the
original translation before mod or built-in edits. Specialization,
translator output and graphics settings can change that identity. A
nonmatching filename is ignored; rebuild and reverify against a fresh
capture instead of renaming old bytecode to bypass the check.
Mods precede developer vulkan_shader_override_dirs (semicolon-separated),
then legacy vulkan_shader_override_dir, then built-in edits. The first
valid matching file wins; replacements of one shader do not merge. Files
are limited to 16 MiB and checked by the bundled SPIRV-Tools Vulkan 1.2
validator, including the translation's main execution model. Unreadable,
malformed or wrong-stage files are logged and skipped, allowing the next
directory or built-in edit. A Vulkan shader-module rejection retries the
original translation. vulkan_shader_overrides = false disables mod,
developer and built-in replacements.
To find shaders without disassembly, capture one frame using the native Vulkan renderer and a pinned seed:
python tools/run-fh1-render-test.py config/render-tests/fh1-smoke.fh1test `
--state-root .local/render-seeds/appdata-2026-09-27 `
--output .local/shader-discovery-run `
--game-argument=--async_shader_compilation=false `
--game-argument=--vulkan_shader_discovery_frame=300 `
--game-argument=--vulkan_shader_discovery_dir=C:/path/to/pinyon/.local/shader-discovery
python tools/discover-fh1-shaders.py .local/shader-discoveryThe frame is the GPU swap count; choose one with native draws and a new
output directory for each capture. Discovery writes original translated
.spv files with full identities and draws.jsonl. The tool groups them
by stage, draw number, HUD boundary and bound color/depth surface (slot,
packed key, size, format and samples). These are render targets, not
material or object labels. Use --shader <hash>, --surface <key> or
--json to inspect them. --check-mod <state>/mods/<name>/shaders
distinguishes stale translations from shaders absent from that frame;
a matching name still needs runtime validation.
draws.jsonl also records used texture slots with dimensions, format,
physical base/mip addresses and padded guest lengths. Ordered
resolves.jsonl records completed copies and their actual source rectangles;
after_draw=N means after N captured draws, before zero-based draw N.
Intersecting allocations identifies possible producers, not proof of a
sample or a complete overwrite. Partial rectangles, aliasing and CPU readers
need separate analysis. See the post-chain trace.
Overrides change how existing inputs are used. Preserve descriptor bindings, stage inputs/outputs, push constants and the entry point: this feature adds no textures, uniforms or game-side data. Validation does not prove interface compatibility or visual correctness. Test on the target GPU; replacement pipelines still cost driver compilation time, especially on Android.
Keep captures and translated guest shaders under .local/; never commit
or distribute them. Distribute only independently authored code or a local
transformation built from the player's files, following LEGAL.md.
A mod lives in the state directory, never in the game files:
<state>/mods/<name>/
mod.toml
code/<name>.dll optional: a native mod
game/... optional: files that replace the game's
<state> is the directory the launcher runs with (--state-root); for an
installed preview it is %LOCALAPPDATA%\PinyonShift\source\<version>\.local\preview.
Mods load when their names are listed, in order, in the enabled_mods
setting of <state>/config/pinyon_shift.toml:
enabled_mods = "hello_telemetry,english_strings"The in-game SETTINGS screen lists the mods found under mods/. A change
takes effect at the next start. Every load and every rejection is logged as
a mod.loaded or mod.rejected event with the reason.
name = "hello_telemetry" # must equal the directory name
version = "1.0.0"
abi = 1 # PINYON_MOD_ABI_VERSION the mod was built for
game_version = "DB40DF605ADE49A6" # optional: prefix of the default.xex SHA-256
library = "code/hello_telemetry.dll" # omit for an asset-only mod
requires = [] # mods that must be enabled and load first
load_after = [] # mods to load after when they are enabled
conflicts = [] # mods that must not be enabled with this one
hide_dlc = [] # marketplace package IDs the title must not see
profile = "" # play a new save, <state>/user-<profile>
shares_save = false # true: never changes saved data, keeps the player's saveA mod whose requirement is missing or failed, or that conflicts with an
enabled mod, is not loaded. Load order follows requires and load_after,
otherwise the enabled_mods order.
game_version may also be a list. The FH1 v4 title-update build identifies
itself as 74B033805AB1BCAA, the SHA-256 prefix of its verified patched
image. It has its own symbol table, so a mod that uses only symbol and hook
names can list both builds:
game_version = ["DB40DF605ADE49A6", "74B033805AB1BCAA"]. A mod that relies
on raw guest addresses or field offsets must target one build: classes grew
in v4, so several offsets differ (see the
title update v4 backlog).
hide_dlc lists marketplace package IDs (the hex names of the DLC
directories) that the title does not see while the mod is loaded: they are
not enumerated, opened or counted as installed, and nothing in them is
changed. A mod that replaces the database an expansion extends hides it; the
XE mod hides Horizon Rally (6F6992766050D818245ADD408031E280FB5F4E634D),
and with it hidden the built-in Rally adapter stays off. Each hidden package
is logged once in a mod.dlc.hidden event.
profile gives a mod its own new save instead of the shared modded copy
(see below). Use it when the mod's saves cannot be mixed with the game's,
such as a total conversion that renumbers cars.
With any mod enabled, the title plays a separate profile,
<state>/user-modded, which starts as a copy of the player's own the first
time. The unmodded profile in <state>/user is never opened while mods are
on. Every save of the modded profile writes user-modded/pinyon_shift_mods.json
with the enabled mods, a hash of the mod set and a hash of the plaintext save
body, so a save can always be traced to the mods that made it. Turning all
mods off returns to the unmodded profile unchanged.
The first enabled mod with a profile replaces user-modded with
<state>/user-<profile>, created empty the first time (a
mod.profile.created event), so the game starts a new save there. Neither
the player's own profile nor user-modded is opened while it is enabled.
A mod that changes only how the game looks or sounds, never what it saves,
sets shares_save = true. When every enabled mod does (the generated
zz-archive-patches counts as its sources do; zz-db-patches never does),
the title keeps the player's own profile in <state>/user, and no
user-modded copy is made.
Marketplace DLC installations and their entitlement headers are shared from
<state>/user: launcher enable/disable changes apply to both profiles. Creating
the modded profile copies save data without duplicating DLC assets. Existing
DLC copies under user-modded are left in place but are not enumerated.
The project's own optional mods live in mods_src/builtin/<name>. Before
each start, tools/build-mod-patches.py copies each one into
<state>/mods/<name> (replacing an older copy, and never a player's own mod
of the same name), with a .pinyon-builtin marker. They are not enabled,
except one whose mod.toml sets always_enabled = true: it is added to
enabled_mods, and a player who had it switched off gets its
off_settings (only for settings not already in their file), so nothing
looks different. Such a mod is not listed on F6 > MODS.
immersive_camera (#419) appends Pinyon Shift's own camera layers to
media/camera.zip's CameraPhysics.xml (G-forces, the throttle and brake
jolt, corner lean, speed shake, braking vibration, breathing and head sway),
written by tools/build-immersive-camera.py, and sets shares_save. It
always loads (#443): each layer carries its effect group in the fraction of
its SpringK or NoiseSeed, and the game's per-frame layer update scales
the group by pinyon_shift_camera_gforce, _lean, _shake and _head
(0 to 2, applied at once). F6 > DISPLAY > IMMERSIVE CAMERA sets all four
(OFF 0, SUBTLE 0.5, DEFAULT 1, STRONG 1.5), and the four CAMERA rows below it
set each one (CUSTOM). The v4 update has a separate merge under
merge-update/media.zip/camera/cameraphysics.xml: its chase cameras have
80/75 stock layers rather than the disc's 74/69. The generator writes both
variants. Update merges build into the generated mod's update/ overlay;
the v4 host mounts it ahead of the verified update on update:, leaving
the installed update untouched. Without an installed update these merges
are inactive. Misspelt update archive or member names fail the build.
Merges apply over the winning enabled mod's whole-archive replacement under
update/, when present, using the same load order as disc overrides.
F6 > DISPLAY > CAMERA EDITOR (#487) edits both chase views live. Height (-0.5 to +1.5 m) and distance (-1 to +3 m) are offsets from the data loaded for the current car. FOV (80–130%) and stiffness (50–200%) multiply the loaded tuning; stiffness scales yaw and height spring K, and D by the square root of that multiplier to preserve the damping ratio. Defaults are zero offsets and 100%, so the editor makes no changes until used. RESET CAMERA EDITOR restores these values and the normal driving view. The existing FIELD OF VIEW setting still multiplies every view as well.
CameraSettings.ini in media/camera.zip supplies the two chase tuning
blocks, including when another mod merges that member. Per-car
CameraOverrides can replace the instance's placement. The title parses
this data once and exposes no qualified hot reload; the host applies the
editor to its loaded blocks and instance offsets on the guest main thread.
It retains the original values, detects replacement per-car offsets and
never accumulates edits. No camera data from the game is distributed.
The controller mode table also contains WheelCam (10) and CarRelativeCams
(15). DRIVING VIEW selects these native wheel and car-relative replay
views while driving, then NORMAL returns to the previous player view.
Photo mode, free camera and cutscenes retain their own modes.
Windows qualification uses fh1-camera-editor with pinned i443-camera
and i443-camera-v4 seeds on multivac. Captures show isolated height,
distance and FOV changes and their restoration; driving exercises 50% and
200% spring stiffness, both replay views and the return to normal. v4
mounts the separate update overlay and reports all four tagged immersive
groups in chase/cockpit views. Original archive layers and source hashes
are unchanged by the generated merges. The UI/reset route is
fh1-camera-editor-ui.
hidden_cars (#484) is listed on F6 > MODS, off by default. Its
db/10-hidden-cars.sql lists only the intro Corrado and showcase Mini,
500 Abarth and 205 T16. Traffic entries, including the tour bus, are no
longer exposed: IsDrivable does not establish garage or paint support
(#669). Purchases play the modded profile. Existing purchases stay in that
profile; updating the mod does not delete them. If an unsupported selected
car prevents garage use, close the game and run
python tools/fh1-profile.py recover-car --state-root "<state>" to back up
that profile and request the title's starter-car fallback on its next load.
See Hidden-car qualification and recovery for the complete
asset audit, limitations and backup restoration steps.
photo_mode_limits (#488) is listed on F6 > MODS, off by default, and
keeps the player's own save (shares_save). It merges four
PhotoModeCam keys into camera.zip/CameraSettings.ini: MaxHeight 2 to
30 m, BoundaryCeilingHeight 6 to 50, BoundaryCylinderRadius 100 to 300
and MinFOVPhotoMode 4.5 to 1 (more zoom). On the base build the photo
camera climbed to the 30 m readout where stock stops at 2 m
(config/render-tests/fh1-photo-mode-limits.fh1test). v4's media.zip
has no CameraSettings.ini, so the merge should apply there too; not yet
tried on v4.
Files under game/ replace the game's files with the same path
(case-insensitive) while the mod is enabled; an earlier mod in the load order
wins over a later one. New directories supplied by a mod are accessible;
existing disc directories retain their base files alongside replacements.
A mod's new files are also listed with the disc's directories, as the title
enumerates some of them (wildcards in FilesToCache.xml); the overlay
records the number of entries it added in a mod.overlay.listed event.
Replacement is whole files: the title reads its
archives (media/*.zip) through C streams, so an asset mod ships a complete
archive. Nothing from the game disc may be distributed; a mod's install
instructions should build its files from the player's own copy, as
tools/install-sample-mod.py english_strings does. Each replaced file the
title opens is logged as mod.file.override.
For an FH1 archive containing XMem members, use
patch-fh1-archive.py to replace selected
members in a new archive built from your own game files:
python tools/patch-fh1-archive.py <original.zip> <new.zip> --replace member.xml=<edited.xml>The tool retains other members' compressed bytes and updates both ZIP header
offsets and FH1's absolute payload offsets (extra field 0x1123). Ordinary ZIP
repacking can leave those offsets pointing into unrelated data. The output
must be a new file; the original is never overwritten. A loose XML under
game/media/GameModes does not replace an existing member of gamemodes.zip.
The title checks some game files block by block against SHA-256 digests of
the originals and stops with a dirty-disc error on a mismatch. For a file a
loaded mod replaces, the host accepts the mismatch (logged once as
mod.file.hash_accepted); every other file is still checked.
Most game data lives in zip archives listed in media/zipmanifest.xml. A
mod that changes one file inside an archive ships just that member:
<state>/mods/<name>/members/<archive path>/<member path>
e.g. mods/my_mod/members/media/StringTables/EN.zip/PauseMenu.str
Before each start, tools/build-mod-archives.py (run by
launch-preview.ps1) rebuilds each affected archive from the player's own
copy: other members keep their compressed bytes, replaced ones are stored
uncompressed, and a matching zipmanifest.xml goes with it, all as the
generated mod zz-archive-patches. An earlier mod wins a member two mods
replace. Archives whose end record disagrees with the manifest (the largest
track archive) must still be replaced whole.
zz-archive-patches/archives.json lists what was rebuilt.
A new file goes under add/ with the same layout, and is appended to the
archive stored:
<state>/mods/<name>/add/<archive path>/<member path>
e.g. mods/my_mod/add/media/dynamicpost.zip/ColourGradingMaps/Sunset.dds
The build refuses a members/ or merge/ file the archive does not have
(it belongs under add/) and an add/ file it already has (it belongs under
members/), so a typo cannot silently do nothing.
New or changed text goes in strings/<language>/<Table>.toml, where
<language> is the string archive's name (EN, FR, ...) or ALL for
every language, and each line names a string by its IDS_ name:
# mods/my_mod/strings/ALL/List_CarMake.toml
IDS_Make_PinyonMotors = "Pinyon Motors"A name the table has takes the new text; a new name is added to the table
with its key. The database refers to a string as _&N, where N is
(table_hash << 16) | key; tools/fh1-strings.py computes both
(string_reference("List_CarMake", "IDS_Make_PinyonMotors")). A new name
whose 16-bit key another name in the table already holds is refused. Seven
tables keep no name order (no chunk 3) and take only changes to existing
names. For a key two mods set, the earlier mod wins.
The game grades the scene (not the HUD) with 16x16x16 colour maps in
media/dynamicpost.zip (ColourGradingMaps/*.dds, NormalGrading.dds is
the identity), chosen per area of the map by the loose
media/tracks/colorado/Ribbon_00/PostProcessingZones_Safe.xml. A look mod
either replaces maps under members/, or adds its own under add/ and ships
an edited copy of the zones file under game/ that names them:
mods/my_look/add/media/dynamicpost.zip/ColourGradingMaps/Warm.dds
mods/my_look/game/media/tracks/colorado/Ribbon_00/PostProcessingZones_Safe.xml
A map is a DDS volume texture, A8R8G8B8, with red varying fastest, then
green, then blue; start from a copy of NormalGrading.dds built on the
player's machine.
For a look over the whole scene without a mod, a player can drop a .cube
3D LUT (2 to 256 points, DOMAIN_MIN/DOMAIN_MAX 0 to 1) into the state
folder's luts folder and pick it in F6 > GRAPHICS > COLOUR LUT
(scene_lut_path, scene_lut_strength). It applies after the game's
grading, on the scene only.
Several mods can change the same settings file when each ships only the
settings it changes, under merge/ instead of members/:
<state>/mods/<name>/merge/<archive path>/<member path>
e.g. mods/my_mod/merge/media/physics.zip/PhysicsSettings.ini
A merged .ini (such as PhysicsSettings.ini or GameTunableSettings.ini)
lists Section\Key value lines: those keys take the new values (keeping the
file's own = or space), other keys keep the player's, and unknown keys are
added. A merged .xml (such as AIOpenWorld.xml) mirrors the file's
structure with only the elements to change: an element matches the file's
element of the same tag with the same id, name, model, key or type
attribute, or, without one, the one at the same position among its tag; its
attributes and text replace the file's, unmatched elements are added, and
pinyon-remove="true" removes the matched one. Elements without a key attribute (the
layers of CameraPhysics.xml) match by position, so a merge that adds one
marks it pinyon-add="true": it is appended, and the attribute dropped. For example, no free-roam
traffic:
<AIOpenWorld><Settings name="freeroam">
<CarList numInitialTrafficCars="0" numInitialFestivalCars="0"/>
</Settings></AIOpenWorld>Merges apply on top of a member another mod replaces (or the player's own,
decompressed with the build's pinyon_shift_fh1_archive_extract), and for a
key two mods set, the earlier mod in the load order wins. archives.json
lists the mods merged into each member.
Texture replacement works on the Vulkan renderer every platform uses
(#518). The dump,
the lookup and the DDS files are shared with the legacy D3D12 cache
(pipeline/texture/replacement.cpp in ShiftGlue). On a GPU without BC
texture formats (most Android GPUs on their stock driver, #516) the game
decodes DXT textures on load, so only 8_8_8_8 replacements apply there.
A mod may replace textures without touching the archives that hold them:
<state>/mods/<name>/textures/<hash>.dds
<hash> is 16 hex digits naming the texture's content. To find it, run the
game with --texture_dump_dir=<folder>: every DXT1, DXT3, DXT5, DXT5A (BC4),
DXN (BC5) and 8_8_8_8 texture it loads is written there as <hash>.dds,
untiled and with its mip levels. 8_8_8_8 dumps use a DX10 header and keep the
channels as the game stores them (the game's swizzle picks them when it
draws), so edit them in place rather than reordering channels. Textures the
game renders itself (resolve output) are never dumped or replaced. Edit
a dump and ship it under the same name; the replacement must keep the format
and at least as many mip levels, otherwise it is logged and ignored. Earlier
mods win.
A replacement may also be 2, 4 or 8 times the dump's width and height (the same factor both ways). It then needs at least the dump's level count; any further levels, down to the dump's smallest size and below, are used too, so ship a full chain: for a 2x replacement of a 512x512 dump with 10 levels, 1024x1024 with 11 levels. The game samples it like the original, only sharper.
While the game runs, SETTINGS > MODS > RELOAD TEXTURES reads the texture
folders again and reloads every texture (texture_replacement_reload), so
edited, added or removed replacements show without a restart; the game
hitches once while its caches refill.
tools/build-fh1-radio.py turns a folder of music into an asset mod that
adds the tracks to Radio1, Radio2 or Radio3, or replaces a station's
playlist with them (#420, FB-1.4). It needs FFmpeg on the PATH (or
--ffmpeg), and builds from the player's own game files:
python tools/build-fh1-radio.py D:\Music\Drive --state-root <state> --station Radio1 --replace --enableAny format FFmpeg reads is encoded to the bank's own format (MPEG-1 Layer
III, 48 kHz stereo, 160 kbps CBR) and appended to a copy of
media/audio/radio/Radio_Music.fsb, so the stock tracks keep their indexes
and the other stations are unchanged. The tool writes the bank,
RadioSoundbankInfo_Music.xml (computed, not generated by the game) and
RadioSystem.xml to <state>/mods/custom_radio/game/media/audio/radio, sets
shares_save, and with --enable adds the mod to enabled_mods.
--station can be repeated; --name builds a differently named mod; running
it again rebuilds the mod it made. Titles and artists come from the files'
tags, or the file name. Each track's SongStart and EventStart sync
points are at its beginning and IdentStart four seconds before its end.
The DJ lines are unchanged (FB-1.5).
The bank is a copy of the player's 680 MB original plus the new tracks, so the mod takes that much disk space; nothing in it may be distributed.
pinyon.py radio manages whole stations, which the launchers' Radio panels
use (#445). A station takes over one of the three slots the executable
names (Radio1 HORIZON BASS ARENA, Radio2 HORIZON PULSE, Radio3 HORIZON
ROCKS) with its own name, folders or files and optional PNG logo, and
either replaces the slot's playlist or adds to it. The stations are kept in
<state>/config/radio-stations.json:
python tools/pinyon.py radio create "Night Drive" --slot Radio2 --state-root <state>
python tools/pinyon.py radio add Radio2 D:\Music\Night --state-root <state>
python tools/pinyon.py radio logo Radio2 night.png --state-root <state>
python tools/pinyon.py radio build --state-root <state>build writes every station into the one custom_radio mod and enables
it. The name replaces the station's text in each language's MyProfile.str
(the RADIO STATION option) and Tips.str (the loading tip's title and
text), and the logo is fitted into Hud/Radio/RS_BassArena.xds,
RS_Pulse.xds or RS_2Eleven.xds (210x100 DXT5, the station popup), all
as archive members built from the player's files. With no stations left,
build removes the mod and disables it. rename, delete, remove,
mode replace|add, logo --clear and list change or show the stations;
ffmpeg PATH stores the FFmpeg to use (otherwise PINYON_SHIFT_FFMPEG or
the PATH), and --json prints one JSON object for the launchers.
--check-bank [file] checks that the tool reads and rebuilds a bank byte for
byte.
The game's data (cars, prices, events) is the SQLite database
media/db/gamedb.slt. A mod may ship SQL scripts in db/*.sql:
-- mods/cheap_jaguar/db/10-price.sql
update Data_Car set BaseCost = 1000 where Id = 1496;Before each start, tools/build-mod-patches.py (run by
launch-preview.ps1) copies the player's own database, applies every
enabled mod's scripts in load order and file-name order, and serves the
result as the generated mod zz-db-patches, listed first so it wins. With no
scripts left the generated mod is removed and the stock database returns.
zz-db-patches/patches.json records the base and patched hashes and every
script applied.
When an enabled mod replaces media/db/gamedb.slt itself, as the XE mod
does, the scripts apply to that mod's database instead of the disc's; the
earliest enabled mod in the load order that ships one is the base, recorded as
base in patches.json. Archive members and merges likewise start from a
mod's replacement archive or zipmanifest.xml when one is enabled.
The v4 title update doesn't read that file: it opens its own
db/gamedb.slt from the update's media.zip on update:, which shadows
any game: database. When the update is installed,
tools/build-mod-archives.py therefore also applies the same scripts, in
the same order, to v4's database. It reads the member from the installed
update (or from the winning mod's whole update/media.zip replacement),
runs the scripts on that copy, and writes the result into
zz-archive-patches/update/media.zip together with any merge-update/
merges. The v4 host mounts that overlay ahead of the verified update, which
stays untouched. patches.json lists the scripts as database_scripts
under the media.zip entry. A script must therefore work on both
databases; v4's is a superset of the disc's (the same tables plus the Rally
cars). The v4 copy is built from the update's own database, never from an
enabled mod's game: replacement such as the XE mod's.
tools/clone-fh1-car.py turns a short spec into a database script and the
cars' names, so a mod adds cars that reuse a model the game has, with their
own name, price, power and listed class:
# cars.toml
[[car]]
id = 1700 # new id; 1700 and up are free of the game's and its DLC's
source = 1496 # the Jaguar XKR-S's model, physics and upgrades
name = "Pinyon XK"
price = 85000
torque_scale = 1.15
pi = 598 # the Autoshow's index and class (A here)python tools/clone-fh1-car.py cars.toml <state>/mods/my_cars --game-db <game>/media/db/gamedb.sltIt writes db/50-clone-cars.sql, which copies the source car's rows inside
the player's own database when the mod is built (so the mod carries no game
data), and strings/ALL/Data_Car.toml with the names. The new car is sold
in the Autoshow under the source's make and appears in an event's
recommendations when its class fits the event. Its thumbnail is the
source's.
The index and class in the spec are only what the Autoshow lists. Once
bought, the game rates the car from its physics, as it does after an
upgrade, so a listed index the physics do not produce changes when the
car is delivered (an Exige clone listed at S 620 arrived at A 570).
Without pi (or a raw performance_index) the tool estimates the index
from torque_scale, usually within a few points; to list a car exactly,
buy it once, read its index in the garage and set that as pi. The
database's curb weight is not the weight the game uses, so the spec has no
weight scale.
Cars bought from a database mod live in the separate modded profile, not the player's own save. Removing the mod later leaves that profile loadable: the game switches to another owned car and leaves the clone out of car select. Whether the car returns when the mod is turned back on has not been tested. Clones are upgraded, painted (the Autoshow's colour choice) and changed like any car: five were bought, driven, Auto Upgraded at Dak's Garage and kept after a reload.
Forza Horizon XE by
Teancum adds about 170 cars, among them FH2 and Fast & Furious 7 cars, plus
engine and drivetrain swaps. It is installed from the player's own downloads;
nothing from it is part of Pinyon Shift. Download both archives from ModDB:
Forza_Horizon_1_XE_Mod_v1.0.7z (2.0 GB) and the
FH1XE_v1.01_hotfix.7z (1.6 GB). In the Windows launcher, open DLC and choose
Download from ModDB: it opens both ModDB download pages in the player's
browser and installs the archives as soon as both finish in the Downloads
folder (found by name and size, then checked by MD5; it waits up to four
hours). Install XE takes archives the player already has. Linux and macOS
launchers have the same controls. From a terminal:
python tools/pinyon.py xe install Forza_Horizon_1_XE_Mod_v1.0.7z FH1XE_v1.01_hotfix.7z
python tools/pinyon.py xe install --find [--open-pages] [--wait SECONDS] [--dir DIR]
python tools/pinyon.py xe find
python tools/pinyon.py xe status | enable | disable | remove
Pinyon Shift does not fetch XE by itself. ModDB's robots.txt disallows
automated clients on /downloads/start/ and /downloads/mirror/, the pages a
scripted download would need (checked 2026-10-10, #426), so the player's
browser downloads it, from ModDB, with credit to Teancum shown in the launcher.
install needs both archives. It checks each one's size and MD5 against
ModDB's, extracts them (the hotfix over 1.0) with 7-Zip or bsdtar (tar.exe on Windows 10 and
later), and keeps only the files that differ from the player's game: 10
replaced files (seven cars, the database, the UI textures and
zipmanifest.xml) and about 1,140 new ones, 2.4 GB in <state>/mods/xe. It
needs 7 GB free while it works. XE's executables are not used: they differ
from the disc's only in the file hash table, and the host accepts the hashes
of files a mod replaces, so the base build runs XE unchanged.
As XE's readme asks, the mod runs on the base game without the title update,
with Horizon Rally removed and on a new save. Its mod.toml targets the base
executable, hides Rally and sets profile = "xe", so XE plays
<state>/user-xe and the player's own save is untouched. While XE is on, the
launcher starts the base build even if title update v4 is chosen. disable
returns to the player's own save, and remove deletes the mod's files but
keeps user-xe. Smaller mods made for XE can be enabled before it; their
database scripts apply to XE's database.
XE 1.0 without the hotfix stops the game on a new save when the community
UDLC pack is installed (#901). Right after the new-player opening's intro,
when the drive loads, the game opens
media\Audio\Cars\Engines\Soundbanks\Burbles\ with no file name, gets
0xC00000BA (a directory opened as a file) and stops itself on its
dirty-disc error (exit 0x80000003). It reproduces from pinned seeds with
fh1-opening-sync: XE 1.0 plus UDLC stops, while XE 1.0 alone, XE 1.0 with
the hotfix, and XE 1.0 with the hotfix plus UDLC all reach the opening drive.
Releases before 0.7.2 installed 1.0 alone when the hotfix was missing, so for
an install recorded as 1.0 the mod.toml also hides UDLC and its Traffic Car
Unlocker. Each start rewrites an older install's mod.toml
(pinyon_xe.refresh_manifest, run by build-mod-patches.py), and the
launcher asks for a reinstall with both downloads. install now refuses 1.0
alone.
Known XE issues, present on the console too: some new cars have black
thumbnails, and the Autoshow asks for an ambience bank XE does not ship
while an XE car is shown (AMB_FA_Autoshow.fsb, logged and harmless).
The route config/render-tests/fh1-xe-buy-car.fh1test buys an XE car and
races it from a seed with XE installed.
A native mod is a DLL built against the C header
include/pinyon_mod.h. It exports two functions:
PINYON_MOD_EXPORT uint32_t rex_mod_abi_version(void) { return PINYON_MOD_ABI_VERSION; }
PINYON_MOD_EXPORT int rex_mod_create(const PinyonModApi* api, PinyonMod* mod);rex_mod_create keeps the PinyonModApi pointer, registers what it needs
and fills PinyonMod with its lifecycle callbacks: on_create_dialogs
(presentation is ready: add binds), on_module_launched (the title is about
to start) and on_shutdown. Mods are loaded before the title starts and are
never unloaded. The ABI only grows: members are appended to PinyonModApi,
whose size tells a mod which exist.
| Id | When | Event fields |
|---|---|---|
PINYON_HOOK_FRAME_TICK |
once per title frame, main thread | none |
PINYON_HOOK_VEHICLE_POSE |
the player's vehicle pose is final | floats[0..2] position, args[0] its guest address |
PINYON_HOOK_SAVE_BEFORE_ENCRYPT |
the save body is about to be encrypted | args[0] body address, args[1] size |
PINYON_HOOK_FILE_OPEN |
the title opened a file | text lower-case guest path |
PINYON_HOOK_PAUSE_BUTTON_CONSTRUCTED |
the pause menu built a button | args[0] button address |
PINYON_HOOK_SAVE_AFTER_DECRYPT |
a saved file was decrypted, before the title reads it | args[0] body address, args[1] size |
Callbacks run on the guest thread that reached the hook, synchronously: keep
them short. subscribe returns a handle for unsubscribe.
The two save hooks see the plaintext of the title's secure files. The
profile (ForzaProfile) begins with a self-describing section: a
big-endian field count, then for each field [u32 name length][name][u32 0x20][u32 0][u8 type][value]. Types are 0x00 bool and 0x01 byte (1 byte),
0x03 UInt32, 0x07 Int32 and 0x09 float (4 bytes), 0x04 UInt64 (8 bytes), and
0x0F a struct, whose value is its own field count and fields. The player's
values are under Main (Main/Credits, Main/XP, Main/Level,
Main/WristbandLevel). Class-serialised challenge states and padding follow
the section. tools/fh1-profile.py decodes a body and edits a field. Edit a
save after it is decrypted, not before it is encrypted: the running title
keeps its own copy of the values, so a value written into an outgoing save
only lasts until the next save. To change the credits while the game runs,
call the title's accessors from a guest task instead (profile.from_user,
profile.credits and profile.set_credits).
Guest memory is big-endian, as the title sees it; read_guest and
write_guest copy bytes. Writing memory the GPU reads (textures, vertex
buffers) is not supported: the renderer would not see the change.
Mods look addresses up by name instead of hard-coding them.
find_symbol("frame.tick") and find_offset("vehicle.slot.position") look
names up in the symbol table for the supported executable:
config/mod/fh1-symbols.toml, plus every
hook site as hook.<name>. Unknown names return 0 or -1. The table is
generated into the host by tools/generate-fh1-symbols.py, and a test fails
when it and the hook declarations disagree. The table names functions, hook
sites and offsets but not data globals yet; the two that object walks need,
the game holder 0x832DF024 (v4 0x833CAAE4) and the activity manager's
component index 0x832FEE8C (v4 0x833EDA1C), are given on the site's Game
objects page.
Guest functions are called only from a guest task: enqueue_guest_task
runs a callback on the title's main thread at the next frame tick, where
call_guest(address, args, count) calls a function with up to six integer
arguments and returns r3. Recompiled code calls other functions directly,
so a mod cannot replace a guest function; it can only observe hook points and
call functions.
register_cvar(name, default, description)adds a text setting under "Mods", saved inpinyon_shift.toml; prefix the name with the mod's.get_cvarandset_cvarread and change any setting by name.register_bind(name, "F9", description, callback, user)adds a key bind the player can rebind; the callback runs on the UI thread.show_dialog(title, text, buttons, count, callback, user)shows a host message box over the title; the chosen index (orUINT32_MAXfor cancel) arrives on the UI thread.log(level, text)writes the runtime log;log_event(event, keys, values, count)writes amod.<event>diagnostics event.
The host draws a mod's UI over the title in the game's own fonts, without touching guest addresses (NP-11):
-
set_hud_text(id, text, x, y, size)shows, moves or (with empty text) removes a label. Positions are in the title's 1280x720 layout; keep to the 90 % safe area, x 64 to 1216 and y 36 to 684, so the label stays on screen at every output size. Ids are shared by all mods: derive yours from your mod's name. It may be called from any thread, including hooks. -
add_menu_action(label, callback, user)adds a row to SETTINGS > MOD ACTIONS, reached from the pause menu's SETTINGS, and runscallbackon the UI thread when the player picks it. -
set_ui_string(table, key, text)replaces the text of one entry of the title's string tables, such as a menu row's label, with UTF-8 text of any length; the title's own layout then fits it. It applies when the title next loads the table, so call it fromrex_mod_create.tableis the table's file name andkeyits 16-bit entry key, whichtools/fh1-strings.pylists:python tools/fh1-strings.py --archive <game>/media/StringTables/EN.zip --table PauseMenu.str --grep photo 0xDED7 PHOTO MODEEach replacement is logged as
ui.string.override(in place when it fits, otherwise appended to the table). The host renames the pause menu's offline MULTIPLAYER row (0xDD6B) to SETTINGS unless a mod sets it.
Adding rows to the title's own menus (native UI4 insertion) is still research, tracked as NP-11.3 in the backlog.
| Sample | Shows |
|---|---|
hello_telemetry |
a setting, an F9 bind, frame.tick and vehicle-pose hooks, a guest call through the task queue (kernel.get_av_pack), a host dialog, a HUD label, a menu action, a longer pause-menu label and shutdown |
english_strings |
an asset-only mod replacing media/StringTables/EN.zip |
Build and install them into a private state copy (never the AppData save):
cmake --build out/build/win-amd64-release --target pinyon_shift_mod_hello_telemetry
python tools/install-sample-mod.py <state> hello_telemetry
python tools/install-sample-mod.py <state> english_strings
config/render-tests/fh1-mods.fh1test runs both in a scripted route.