Mods
How Sunrise finds, orders, checks and loads mods, the folder and manifest a mod needs, mod config and cheat mods.
A mod adds packages, patches installed packages, and adds or replaces mission scripts. Sunrise loads mods once, at startup, before the game asks for its content list. Restart the game after you add or remove a mod, or change its mod.json, packages or patches. Changed mission scripts and Lua modules of a loaded mod can be reloaded in the running game. See Mission scripts and SDK modules. Scripts read changed config values immediately. Config bound to package data applies at the next startup. The loader code is in src/state/mods/.
None of this has run in game yet. Folder loading, package merging, table merging and typed config baking are tested offline.
The mods folder
Mods live in bin/x64/Sunrise/mods, one folder per mod. Sunrise creates the folder at startup. Sunrise Studio’s Build and install writes a mod there, into a folder named after the mod id.
- A folder whose name starts with
.is skipped. Studio stages an install there. - A folder name may use only letters, digits,
.,-and_, up to 64 characters. The game opens package files by an ASCII path, so the name must be plain ASCII. - Sunrise reads at most 64 mod folders, in name order. The rest are skipped and counted in the summary notice.
mods\dev.example.mod\
mod.json the manifest
packages\ new packages: every generation of each new package id, plus .merge files
patches\ patches of installed packages: the next generation, one per id, plus .merge files
sdk\ Lua modules added to the mission require path
scripts\ mission scripts: <stem>\<stem>.lua controllers and helper modules
Only mod.json is required. Leave out the folders a mod does not use.
A <stem>.merge file sits next to <stem>.pkg. It is the package’s merge data, and Studio writes it. A package without one still loads, but its entries cannot merge with other mods. See Two mods patching one package.
mod.json
{
"manifestVersion": 2,
"id": "dev.projectsunrise.example",
"version": "1.0.0",
"name": "Example",
"description": "What the mod does.",
"authors": ["Name"],
"src": "https://example.com/mod",
"dependencies": [
{ "id": "dev.projectsunrise.base", "minVersion": "1.2.0", "maxVersion": "1.9.0" }
],
"incompatible": ["dev.other.mod"],
"cheat": false,
"config": [
{ "key": "respawn_ms", "type": "int", "default": 5000, "name": "Respawn delay" },
{ "key": "scale", "type": "float", "default": 1.5 },
{ "key": "hard_mode", "type": "bool", "default": false }
]
}
key
required
rule
manifestVersion
yes
1, 2 or 3. configBindings needs version 3. A higher number needs a newer Sunrise
id
yes
lowercase reverse-domain id, 3 to 64 characters: letters, digits, -, _, at least two parts split by .
version
yes
MAJOR.MINOR.PATCH, each 0 to 65535, no leading zeros
name
yes
1 to 64 bytes of UTF-8
description
no
up to 512 bytes
authors
no
up to 8 names of up to 64 bytes
src
no
up to 256 bytes. Shown in the Mods page, never opened
dependencies
no
up to 32. id and minVersion are required; maxVersion is optional. Both ends are inclusive
incompatible
no
up to 32 mod ids
cheat
no
true or false. Version 2 or 3. See Cheat mods
config
no
up to 32 entries. Version 2 or 3. See Mod config
configBindings
no
up to 256 typed sites. Version 3 only. See Package config bindings
The file is UTF-8 JSON of at most 256 KiB. Unknown keys and repeated keys are refused, so a misspelt dependencies cannot quietly drop a dependency. A mod cannot depend on itself, list itself as incompatible, list the same id twice, or list one id as both a dependency and incompatible.
Versions 1 and 2 are read too. Version 1 cannot use cheat or config. Versions 1 and 2 cannot use configBindings. The refusal names the key.
Mod config
A mod can declare values that the player changes on the Mods page. Its mission scripts read them while the game runs.
What a mod declares
Each entry in config is one value.
field
required
rule
key
yes
1 to 32 characters of a-z, 0-9 and _, starting with a letter. Unique in the mod
type
yes
int, float or bool
default
yes
a value of that type. int is a whole number that fits 32 bits. float is any finite number that fits a 32-bit float; a whole number such as 2 is fine. bool is true or false
name
no
the label on the Mods page, 1 to 64 bytes of UTF-8. The key is shown when it is absent
Unknown fields are refused. A refusal names the entry by its place in the list, for example config entry 2: 'type' must be "int", "float" or "bool".
Where the player changes it
Open the Sunrise menu, then Mods, then the mod. Each entry has a control:
- a checkbox for
bool, - a number field for
intandfloat, - a Reset button that returns the value to the mod’s default.
A number is saved when you press Enter or leave the field. Scripts see the change on their next config read. For a bound package value, the page also shows the value applied at startup and Restart required when it differs from the saved value. Reset follows the same rule. Hover the label to see the key and its default.
Where the values are saved
Values are saved in bin/x64/Sunrise/mod_config.json on every change. The whole file is written to a temporary file beside it, then moved into place. If saving fails, the previous file and the live config values stay as they were.
{
"version": 1,
"mods": {
"dev.projectsunrise.example": {
"respawn_ms": 2500,
"hard_mode": true
}
}
}
- The file holds only the values you changed. Reset removes the value, so the mod’s default applies again, even when a later version of the mod changes it.
- Values of a mod that is not loaded are kept.
- A value whose type does not match the mod’s declaration is ignored, and the default applies.
- A missing file means every value is at its default. The log line is
ev=mods stage=config result=none. A broken file, or one with anotherversion, means the same. Sunrise renames it tomod_config.json.bad, so a later save cannot overwrite it, and logsev=mods stage=config result=fail reason=bad_file kept=bad. The game starts as normal in both cases.
The file is separate from the mod folder, so installing a new version of a mod keeps its values.
How a script reads it
A mission script that the mod ships reads its values through context.mod_config. For example, context.mod_config.respawn_ms returns an integer, scale a number and hard_mode a boolean. Each read returns the current value, so a change on the Mods page reaches the next read. A key the mod does not declare reads as nil, and so does every key in a script that no mod ships.
Package config bindings
Manifest version 3 adds configBindings. Sunrise Studio writes this metadata when you bind a supported CUI site to a setting of the same type. One setting can drive several sites. The loader checks the exact source entry, scalar ownership and type before accepting a mod.
profile
supported values
site fields
cui-property-v1
float layout X, Y, width or height on supported built-in widgets; boolean text Auto width
propertyOffset, poolOffset
cui-comparison-v1
signed integer or float comparison operands
bindingOffset
Each site also needs key, type, tag, class, size, sourceSha256 and offset. The key must be declared in config. tag and class are unsigned 32-bit JSON numbers. The supported resource class is 0x80804825. sourceSha256 is the 64-digit hexadecimal digest of the supplied plaintext entry. Offsets are byte offsets within that entry. Studio derives them from supported rows. They are not arbitrary write locations. A site on a hash, an enum, a structural field, a pointer or shared scalar storage is refused. So is an earlier direct property that a later direct property supersedes. All sites for one entry must agree on its identity and may not overlap. A bound entry may be at most 64 MiB.
The declaring mod must supply the bound entry in its own new package or changed patch entry. A binding cannot edit installed bytes or an earlier dependency’s entry without supplying its own replacement. Target data must be plaintext in mod-supplied blocks. Encrypted or compressed target blocks and missing older mod generations are refused. Original package files stay unchanged.
At startup, the loader reads saved values or defaults, merges patches and tables, then writes merged entries and private copies of bound entries into cached packages. It verifies source SHA-256, class, size and typed sites before it applies values. Floats keep their binary32 bits, including the sign of zero. These values stay fixed in package data until the next startup. Native bindings or layout rules may still replace the stored defaults when the resource runs.
Config snapshots and applied-value records are published only with a successful overlay. A later patch that replaces an earlier mod’s required bound entry is refused. If a required merge or bake fails, no mod loads this session. The notice reads “Mod packages could not be checked. No mods are loaded.” Cached files from earlier runs and mod_config.json stay as they were.
Cheat mods
A mod with "cheat": true is a cheat mod. When one is loaded, nothing earned or changed in that session is saved. The session plays on a copy of the save held in memory, and the copy is gone when the game exits. Cheat sessions has the detail.
- A cheat mod that is refused does not count.
- A notice at startup reads “Cheats are on. Progress this session is not saved.” The Mods page says so too, and shows
(cheat)after the mod’s name. - If the copy fails, Sunrise does not start, so nothing can reach the save.
- To save progress again, remove the mod and restart the game.
The log line is ev=mods stage=cheat result=session_copy mods=1 tools=0. mods counts the loaded cheat mods. tools is 1 when client.cheats is on.
Load order
A mod loads after every mod it depends on. When two mods have no order between them, the one with the lower id loads first. Load order decides two things when mods patch the same package. Rows that mods add to one table follow each other in load order. When two mods change the same bytes, or an entry cannot merge, the later mod’s values are used. See Two mods patching one package. A later patch replacing an entry with an earlier required config binding is refused.
A dependency orders ordinary overrides. It does not permit replacing a required bound entry.
Why a mod does not load
A mod that fails any check is not loaded at all. A mod that depends on it is not loaded either.
reason
log reason=
what happened
bad folder name
folder_path
the folder name breaks the rules in The mods folder
no mod.json
manifest_missing
the folder has no manifest
unreadable manifest
manifest_unreadable
mod.json could not be read or is over 256 KiB
bad manifest
manifest_invalid
the JSON or a field breaks a rule above; the message names the field
newer manifest
manifest_version
manifestVersion is higher than this Sunrise reads
duplicate id
duplicate_id
two folders use the same id; both are refused
missing dependency
missing_dependency
a dependency is not installed
wrong version
dependency_version
a dependency is installed but outside the range
dependency did not load
dependency_not_loaded
a dependency was refused itself
circular dependency
dependency_cycle
mods depend on each other in a loop; every mod in the loop is refused
incompatible
incompatible
the mod lists an installed mod as incompatible. The mod that declares it is refused, not the other one
package conflict
package_conflict
a new package id is already installed or added by a mod that loads earlier
bad package
package_invalid
a file name, header or table does not fit the rules below
bad patch
patch_target, patch_invalid
the patched package is not installed, or the patch does not fit the rules below
too many package files
capacity
the mods together add more than 1024 package files
bad config binding
package_invalid
the declaration, source identity, typed site or required block data fails validation
required config conflict
asset_override
a later patch replaces an entry with an earlier required binding
The keys asset_override and merge_failed also appear with result=warn on a mod that loads. They mean it overwrites another mod’s bytes, or some of its changes were left out of a merge. Merge warnings lists each one. A failed merge or bake that carries required config bindings stops every mod. See Package config bindings.
Each refusal shows as a notice at the top of the screen while the game loads, as “Mod did not load: ”. Up to three are shown, then one summary line: “N more mods did not load. See Sunrise > Mods.” In a cheat session two refusals are shown, so the cheat notice still fits. When no refusal is hidden, the summary line instead counts the loaded mods that have warnings. The Sunrise menu has a Mods page with every mod, its details, and every reason. The log has one line per mod and per reason, for example ev=mods stage=load folder=dev.example mod=dev.example result=refused reason=missing_dependency.
Packages and patches
A package file name is w64_<family>_<4-digit hex id>_<generation>.pkg, with an optional locale before the generation. Startup _unp1_ packages cannot be added or patched by a mod.
New packages go in packages. Their id must be 0x0100 to 0x0CFF. The game’s content list takes ids up to 0x19FF, but its header check refuses any id above 0x0CFF. The id may not be installed or used by another mod. Ship every generation from 0 to the newest.
Patches go in patches. A patch is the next generation of an installed package, built against the installed newest generation, never against another mod’s patch. It keeps every block record of that generation and changes some entries. One patch per package id per mod. A mod may also patch a new package added by a mod that loads before it.
Two mods patching one package
Sunrise merges them, so each mod keeps its changes wherever the merge can be exact.
Loading the patches in order is not enough. Each generation of a package carries the complete entry and block tables, and the game uses only the tables of the newest generation. Two mods that patch the same package both ship the same next generation. Whichever file the game took, the other mod’s changes would be gone, even when the two mods change different entries.
So Sunrise writes one new generation that holds:
- the installed generation’s tables,
- each mod’s changed entries, in load order, with required bound-entry conflicts refused,
- the union of every mod’s dependency records,
- each mod’s new blocks, copied in. Installed blocks are still read from the installed files.
Then it merges each entry that several mods change, using the mods’ .merge files. Merged entries go into new plaintext blocks of the served file, the same way bound config values do.
What merges
change
how it merges
dependency records
per record, the union of the entries every mod added
manifests
per package group, the union of the entries every mod added
sorted maps
the union of the rows by key, kept sorted
field edits to an installed entry that keeps its size, or inside a table listed in How a build works
in aligned 8-byte words. Edits in different words all apply
rows added to an installed table
each mod’s rows follow the rows of the mods before it. Its references to them move with them
The merge never reads the installed package. Each mod’s copy of an entry already holds the installed rows, and its merge data lists the installed bytes it changed. For a table, Sunrise restores the installed entry from each copy and checks its SHA-256. A copy that fails the check does not merge.
Studio decides which tables a .merge file describes. See How a build works.
Rows added by several mods
Rows are found by their position. Two mods that each add one item both add the same row number. So Sunrise places the first mod’s rows first and the next mod’s rows after them. A mod’s merge data lists every reference it makes to a row it added. Sunrise adds to each such reference the number of rows that earlier mods added to that table. A reference to an installed row never moves.
The row merge has these rules:
- A listed reference must name a row the mod itself added. If not, all of that mod’s merge data is ignored.
- A mod must ship a
.mergefile with every package file, and each must be readable. If one is missing or cannot be used, all of that mod’s merge data is ignored. Its rows might have to move, and an entry without merge data could not follow them. - The merged row count must fit the table’s limit. Tables read with a signed 16-bit row number stop at 32,768 rows, and some at 32,767. How a build works lists each limit.
- Paired tables, such as the item index and the item display index, must grow by the same count for each mod.
- Item hashes, collectible hashes and string bank name hashes must stay unique across mods. The string bank index stays sorted when the installed one is.
Row merging is all or nothing for one startup. If any row table breaks a rule, no row table merges and no reference moves. This also happens when an entry of a row table cannot merge for any reason below, such as a mod without .merge files or a copy that fails its SHA-256 check. Each of those tables uses the later mod’s copy. Each earlier mod that added rows to one gets a warning, because its references may now point at other rows. Dependency records, manifests, sorted maps and field edits still merge.
What does not merge
These keep the later mod’s copy of the entry, and the later mod gets a warning:
- CUI screens, materials, textures and raw entry edits.
- An entry that Studio marks as not mergeable, or a table copy that fails its SHA-256 check.
- An entry from a mod without
.mergefiles, such as one built by an older Studio. - An entry of a mod whose merge data was ignored.
- Edits that change the size of an installed item, vendor or other record outside the tables in How a build works.
For a row table, these also turn row merging off for that startup.
Edits clash per aligned 8-byte word, so no field mixes two mods’ values. When two mods change bytes in the same word, the later mod’s whole word is used and it gets a warning. The rest of that entry still merges. The same holds when two mods add one key to a sorted map with different rows.
When the package merge itself fails, only the first patch loads and later mods get a warning. A dependency record change that is not an addition does this. A merge with required config bindings must succeed. Partial fallback is refused.
Merge warnings
warning
key
meaning
Replaces N assets in package 0x.... that <mod> also changes.
asset_override
entries that kept the later mod’s copy
Changes N bytes of entry 0x........ that <mod> also changes; its values were used.
asset_override
two mods changed bytes in the same 8-byte word
Merge data for package 0x.... was ignored: <reason>, so all of its merge data was ignored.
merge_failed
the .merge file is missing, unreadable or does not match its package, or a reference failed its check
Its rows added to entry 0x........ were not merged: <reason>. Its references to them may point at other rows.
merge_failed
row merging was off this startup
Its rows were not merged: package 0x.... failed: <reason>. Its references to them may point at other rows.
merge_failed
a package with merged entries could not be served, so no merged entry was served
Its changes to package 0x.... were not applied: <reason>.
merge_failed
the package merge failed, so only the first patch loaded
The log has ev=mods stage=table_plan once per startup when an entry is planned for merging, with result=ok or result=off and the table that turned row merging off. It has ev=mods stage=table_merge once per planned entry, with result=ok or result=fallback and the reason. A cached file of merged entries that cannot be written logs ev=mods stage=rewrite result=fail. When a package merge or a rewrite fails, every merged entry is dropped, and the log has ev=mods stage=table_merge result=dropped.
The merge cache
Merged and configured files live in bin/x64/Sunrise/cache/mods. Their cache keys include complete source contents. Rewritten files also key the merged entries, the exact typed recipes and the values. Startup redoes the table merge to build the key, then checks cached bytes against the current inputs. A damaged cache is rebuilt without changing mod files. Unused cache files are pruned only after the new overlay and config snapshots are published. A failed staging or publication step keeps files used by the prior overlay.
A single patch without bindings or merged entries is served directly. A single patch or new package gets a cache copy when it is bound, or when its references move because an earlier mod added rows.
How the game finds mod files
Sunrise answers the game’s content-list request itself. It adds one row per mod package file, then gives the list a new identity so the game does not reuse its package header cache from the old list.
The game drops any folder from a name in that list and opens packages\<name>.pkg. It builds that path in two places: the package file object, and the package header cache, which opens the file on its own. So Sunrise detours both the path builder and the file opener, and gives each mod stem its real path under the game folder. A stem no mod serves keeps the stock path. This is the one client hook the mod loader needs. It attaches only when a mod serves a package file. See Client hooks. If it cannot attach, a notice says mod packages cannot load. The Sunrise folder must be inside the game folder, which it is in a normal install.
Mission scripts and SDK modules
When an activity starts, Sunrise looks for its controller script in this order:
- each loaded mod’s
scripts\<stem>\<stem>.lua, the last loaded mod first, bin/x64/Sunrise/scripts\<stem>\<stem>.lua,- the generated script in
scripts\activities.
The Lua require path is searched in this order: the generated SDK in sdk\lua, each mod’s sdk folder in load order, each mod’s scripts folder from the last loaded mod back, and the base scripts folder. A mod cannot hide a generated SDK module behind one of its own.
Script files are read from disk again on a reload, so a changed script or Lua module of a loaded mod takes effect without a restart. Press Reload script on the Script page of the World window, or use Update running game in Sunrise Studio. A Studio reload covers the named mod and every loaded mod that depends on it. Which folders a mod has is read only at startup, so a scripts or sdk folder added later needs a restart. How the reload works is in The Activity Host.
Limits
- The server reads the package files Sunrise serves, merged and rewritten ones included, as well as the installed ones: the item, vendor and other mappings and the named tags see them. A change to a mod’s packages rebuilds those at the next start, which takes longer than a normal start. A mod with only scripts changes none of them. Entity position profiles still read installed packages only. This has not been run in game yet.
- The generated mission SDK is built from the installed packages only. Mods do not change it, and installing or updating a mod does not rebuild it. A mod that needs Lua bindings for its own content ships them in its
sdkfolder. - Mods may add at most 1024 package files together, and the whole content list holds at most 4000 rows, about 1800 more than a stock install.
