This document is a draft and may or may not represent the final API or the current development implementation. See issues for discussion.
Implementation of the modding API can be found at or near /src/mods.js
Mods can be written according to the API specification below.
Mods should be distributed as either single-file .js
scripts or as compressed archives containing a mod folder.
There is a wiki page, Third Party Mods, that collects an index of mods people have written. If you write a (public, appropriate) mod, feel free to link it there.
A single-file mod with the filename mymod.js
should be installed in the form
{asset folder}/mods/mymod.js
A mod folder with the folder name mybigmod
should be installed in the form
{asset folder}/mods/mybigmod/
such that the file {asset folder}/mods/mybigmod/mod.js
exists.
On startup, UHC will scan the mods folder for zip archives and automatically extract any archives it finds.
Zip archives packed in this way must pack folders or js files, as per above. UHC will not create a wrapper folder, so you must distribute zip files correctly in order for unpacking to work.
Other archives like .7z
files are not recognized as mods and need to be extracted as per the above.
Installed mods will be available in the SETTINGS
page (jump /settings
), under the header Mod Settings. Detailed instructions will be available there.
Some changes don't require any sort of reload at all. Some require a soft reload, and some require a full application restart.
Basically, anything that requires the main process to reload requires an application restart. This is usually if you change an actual file in the mods directory. Anything that modifies vue or adds CSS requires a soft reload, and stuff that just modifies the archive or adds footnotes can reload within vue.
As per Installing mods above, there are two forms of mods: single-file scripts and mod folders.
Single-file js scripts behave much the same as standard mods, but they do not have a folder to scope and thus cannot reference local files. As such, the routes
and treeroute
interfaces are not available in singlefile mods.
The rest of this specification will be built around the mod folder structure, with the understanding that single-file mods have a subset of the same functionality.
Names in module.exports
are exposed directly to the modding API, and are the meat of your mod. You can put other sub functionality in your js file, but your exports
variable is what interfaces with the main collection.
These are basic metadata attributes used in the settings screen for user selection. These are all required.
title
(string): The title of your mod. This should be as short as possible while being recognizable.summary
(string): A short description of your mod and its basic functionsdescription
(string): A longer description of the mod. HTML formatting is allowed here.author
(string): The name of the person or group who authored the mod.modVersion
(number): A javascript number specifying the version of the mod. This may be used for update checking, so this number should strictly increase with subsequent releases.locked
(string, optional): If your mod's name or description are spoilers, or if your mod unlocks spoiler content in some way, the mod will be hidden until the reader reaches this MSPA page number. For instance, use"001901"
to unlock once the reader starts reading Homestuck.
While are free to define your own names internally, you should not use names starting with an underscore (_data
, for instance) as these may be overridden without warning.
The edit()
function is the main way mods should edit data in the archive. When your mod is loaded, yourmod.edit(archive)
is called with the entire archive passed by reference. edit
can edit that object arbitrarily. The return value of edit()
is ignored.
Examples:
edit(archive) {
archive.mspa.story['001901'].content = `A young man stands in his bedroom. It just so happens that today, the 13th of April, is this young man's birthday.
Though it was thirteen years ago he was given life, it is only today he will be given a name!
<br /><br />
What will the name of this young man be?`
}
This replaces the content
of page 001901
with a new block of HTML (here, the original date, without the year specified.) This clobbers any data previously in content
, including modifications other mods may have made, and should be avoided. A better way to do this same operation would be:
edit(archive) {
archive.mspa.story['001901'].content =
archive.mspa.story['001901'].content.replace(
"the 13th of April, 2009, ",
"the 13th of April, "
)
}
Here, the specific phrase "the 13th of April, 2009, " is replaced, and any other text in content
is left to pass through.
The archive
object is the main data object that drives the story. It's defined in src/background.js:loadArchiveData, and stores the information from the json files in archive/data.
So archive.mspa
is mspa.json
, etc.
There are some other objects exposed by the archive used to tweak behavior that is not found directly in json files, usually in archive.tweaks
.
archive.tweaks.modHomeRowItems
is a list of extra icons to be added to the home screen. A brief example:
edit(archive) {
const myIcon = {
href: "/zombo", // linked page
thumbsrc: "assets://archive/collection/archive_ryanquest.png", // icon src
date: "", // string
title: 'Zombocom', // short title
description: `<p>You can do anything</p>` // HTML description
}
archive.tweaks.modHomeRowItems.unshift(myIcon)
}
unshift
is used here because modHomeRowItems
is a list, and mods at the top of the list are applied last, so unshift
puts icons from mods at the top of the list at the top of the homescreen card.
Caution: archive.music
is currently scheduled to be restructured, and should be considered unstable.
An obvious use for modding is replacing files and images with new ones you provide. If the image is a new one you're adding, you do not need to use edit()
for this. A naive way to replace the first image of Homestuck, for example, would be this:
edit(archive) {
archive.mspa.story['001901'].media = [
"/some/new/path/here/mymod/mynew.gif"
]
}
However, there isn't a good way to construct /some/new/path/here
.
Instead, you should use the Routes system. Instead of telling the collection to change ['001901'].media
image to mynew.gif
, you can tell the collection to route "/storyfiles/hs2/00001.gif"
to mymod/mynew.gif
.
The syntax is a simple key/value mapping, where keys are asset paths (prefixed with the protocol "assets://" in place of your assets directory) and the values are local file paths.
trees
: Map<AssetPath(String), LocalPath(String)>
So, to write our first-panel replacement mod, we would simply need the following:
mymod/
mymod/mynew.gif
mymod/mod.js
And in /mymod/mod.js
routes: {
'assets://storyfiles/hs2/00001.gif': './file.gif'
}
Note that ./
here means a local path relative to your mod folder.
With this enabled, any time any story page references that file, your file is substituted in place. You can think of this as something akin to Docker volume mapping, but much simpler.
In the above example, we replace a panel already in the collection with one we provide. But what if we want to redirect it to a file already in the collection? This is easy:
routes: {
'assets://storyfiles/hs2/00001.gif': 'assets://storyfiles/hs2/00002.gif'
}
Just reroute the first file's path to another's. (Note that it is possible to use this syntax to create an infinite loop, which the collection will detect and treat as an error.)
The left-hand side of a route does not need to point to a file that exists in the asset pack. You can use this behavior to create "pseudo-files" that you can reference from anywhere.
The test mod does this explicitly:
routes: {
'assets://storyfiles/hs2/05235/toxic1.mp3': './toxic1.mp3',
'assets://storyfiles/hs2/01940/cascade.mp3': './cascadebeta.mp3'
},
You might have a case where you want to patch a large number of files at once without manually typing in each route pair yourself. While it is entirely legal to programatically generate your own routes
object, there is a convenience system to handle this for you: Treeroutes
Treeroutes build a routes
object for you using an entire folder tree. This has the effect of a traditional patch done with a simple folder merge.
trees
, like routes, is a key/value mapping, but it maps local folders to logical folder routes.
trees
: Map<LocalPath(String), AssetPathDirectory(String)>
(note that this is not parallel with routes
)
Example:
Given a mod directory structure
damara/
damara/mod.js
damara/dialogs/
damara/dialogs/damaraDialog.xml
damara/dialogs/daveDialog.xml
damara/dialogs/subfolder/subfile.xml
...
A mod.js
with
trees: {
"./dialogs/": "assets://storyfiles/hs2/05395/levels/openbound_p3/dialogs/"
}
would be the same as specifying
routes: {
'assets://storyfiles/hs2/05395/levels/openbound_p3/dialogs/': './dialogs/damaraDialog.xml',
'assets://storyfiles/hs2/05395/levels/openbound_p3/dialogs/': './dialogs/daveDialog.xml',
'assets://storyfiles/hs2/05395/levels/openbound_p3/dialogs/subfolder': './dialogs/subfolder/subfile.xml'
...
}
SPECIAL CASE: If your treeroute maps the whole asset folder is in the form of
trees: {
"./[mytree]/": "assets://"
}
you can use the shorthand
treeroute: "./[mytree]/"
with the same effect.
Mods can inject custom CSS into the whole app. styles
declares a list of local css files to be injected.
styles
: List<CustomStyle>
A CustomStyle has fields
source
: A relative path to a stylesheet
body
: A string with a style body
Use only one of these per style!
styles: [
// Inject css file
{
source: "./test.css"
}
],
Specific page/context selectors should be included in the CSS file.
Instead of just blindly injecting CSS into the app, mods can also register themes that integrate with the collection's existing theme system.
Themes will be automatically scoped via SASS, so the author does not need to handle any theme selection logic.
themes
: List<CustomTheme>
A CustomTheme has fields
label
: The name of the theme, displayed in the settings menu.
source
: A relative path to a stylesheet
themes: [
{
label: "Super retro",
source: "./theme.scss"
}
]
Mods can add to the global library of footnotes (which is empty, by default) by defining their footnotes
field. Each footnote has HTML content and an author name. Any given page can have any number of footnotes.
The footnotes
object is a List<FootnotesScope>
FootnotesScope
is your main object to manipulate. It has fields
author
(string): The author of the footnote. Note that this is not necessarily the author of the mod.class
(string, optional): A custom CSS class the footnote container will inherit. Use this if you want to do custom styling.preface
(bool, optional): If set totrue
, notes will appear before pages instead of after them.story
:Map<PageNum, List<Note>>
, adds footnotes to MSPA story pages by PageNum.
Individual notes are as follows:
content
(string): The actual content of the footnote. This can include HTML including formatting tags. Be sure to escape HTML if you're defining it in JSON.author
(string, optional): An explicitly defined author for this particular note. This does not need to be set and will inherit from theFootnotesScope
if note defined.class
(string, optional): An explicitly defined class for this particular note. This does not need to be set and will inherit from theFootnotesScope
if note defined.preface
(bool, optional): An explicitly defined preface-state for this particular note. This does not need to be set and will inherit from theFootnotesScope
if note defined.
So, putting that all together, here is a valid footnotes object:
[{
"author": "Default author",
"story": {
"001901": [{
"content": "Footnote <i>html content</i>"
},{
"content": "Footnote <i>author</i>",
"author": "Author override"
},{
"content": "Footnote <i>class</i>",
"class": "css-override"
},{
"content": "Footnote <i>force clear author</i>",
"author": null
}],
"001902": [{
"content": "Footnote <b>a2</b>",
"author": "username_a2",
"class": "css_a2"
}],
"001903": [
{"content": "Footnote <b>a3a</b>"},
{"content": "Footnote <b>a3b</b>"}
]
}
}]
Optionally, your footnotes
field can instead be set to a string, which will be treated as a local json path to a footnotes
object, which will be loaded. E.g.
footnotes: "./footnotes.json"
Aside: Internally, there is no such thing as a FootnoteScope
. Instead the parser constructs explicit maps of footnotes, computing inheritance at load time.
There are some resources your mod might want to request from TUHC at runtime, like a namespaced logger object or access to a settings store. For this, use the computed
function.
(There is no relation between the module.exports.computed
field and the vue conception of computed values, except for the general idea of computation.)
While loading the mod, if there is a computed
function defined in your mod, the loader will call computed
and merge the return value with the rest of the mod. This lets you assign static fields (like locked
, or footnotes
) based on logic computed during runtime.
The computed
function is passed the api
object as an argument, which currently exposes the following:
api = {
logger,
store
}
The logger
object is a standard logger, with info
and debug
methods that output information at different levels depending on user settings.
The store
object is a special namespaced store you can use for reading settings or other persistent data from the store.
set(k, v)
: Set the keyk
to the valuev
.get(k, default_)
: Get the value of keyk
, ordefault_
ifk
is not yet set.has(k)
delete(k)
clear()
The store provided is namespaced. This means it is safe to use commonly used keys in your mod without any risk of conflicting with the main program or other mods.
Note that values in computed
are only computed if your mod is enabled, so you can't compute things like the title and summary. If you compute values, please also include placeholders directly in your exports object so the settings modal can properly describe your mod. Example:
module.exports = {
edit: true,
computed(api){
return {
edit(archive){
...
}
}
}
let logger = null
let store = null
module.exports = {
`...`
computed(api) {
logger = api.logger
store = api.store
},
}
You can then use the logger
or store
objects in code.
For assigning values to settings, look below:
Use the settings
field to define a data model. The archive will automatically generate an interactive settings UI and attach it to the mod entry on the settings screen.
-
settings
: Contains two (optional) objects,boolean
(List<boolSetting>
) andradio
(List<radioSetting>
) -
boolSetting
model
: The storage key this setting models. The value assigned will be true, false, or undefined.label
: A short label for the checkboxdesc
: A longer description. Optional.
-
radioSetting
model
: The storage key this setting models. This will be one of the values you specify, or undefined.label
: A short label for the whole settingdesc
: A longer description for the whole setting. Optional.options
:List<radioOption>
: The values of the option
-
radioOption
value
: The value that will be set as the key.label
: A short label for this optiondesc
: A longer description for this option. Optional.
A full example:
settings: {
boolean: [{
model: "booltest",
label: "Mod bool test",
desc: "Mod bool test desc"
}],
radio: [{
model: "radiotest",
label: "Mod radio test",
desc: "Mod radio test desc",
options: [
{
value: "value_a",
label: "Value A",
desc: "the a value"
},
{
value: "value_b",
label: "Value B",
desc: "the b value"
}
]
},{
model: "radiotest2",
label: "Mod radio test (Compressed)",
desc: "Mod radio test desc 2",
options: [
{
value: "value_a",
label: "Value A"
},
{
value: "value_b",
label: "Value B"
}
]
}]
}
Note that there is no setting for a default option. Values will always be undefined (falsey) until the user interacts with the settings screen. You can override this behavior by including logic in your computed
handler, for example
computed(api) {
// Default to on
api.store.set("default_yes", store.get("default_yes", true))
}
VueHooks are a bare-metal way to make changes, and should be considered highly unstable. Using stable, supported API methods like edit(archive)
is highly preferred whenever posssible, as VueHooks are liable to break with even minor patch versions.
Vue hooks are the most complicated and the most powerful method of modifying the collection, and modify the Vue.js pages directly using mixins.
vueHooks
: List<VueHook>
Each VueHook
has the following properties:
match(t)
(function): Gets the page's vuethis
object as an argument. Should returntrue
if this hook is relevant for the page, and should not mutate state. Try to usematchName
instead, though:matchName
(string, optional): Works likematch(c) {return c.$options.name == "pageText"}
, but is much more performant. Helpful for matching specific.vue
files by their vue name. Do not define both amatchName
and amatch(t)
function in the same VueHook.
Ways to hook Vue data, in order from most to least recommended:
data
(optional): Thedata
object is a collection of values and functions. Objects indata
are merged with thedata
function of the vue page, overriding any previous data. Functions in thedata
object take a$super
argument that contains any previous data from that name, and should return a modified or replaced version of that object.computed
(optional): Thecomputed
object is a collection of functions that are used to override the page's existingcomputed
values. Unlikevue
's computed, these functions are given a$super
argument that contains the previous result of the computation.methods
(optional): Themethods
object is a collection of functions that are used to override the page's existingmethods
. Unlikevue
's computed, these functions are passed an additional$super
argument at the end of the arguments list that contains the overwritten method.updated
/mounted
(optional): Called every time the component updates/mounts. See vue documentation. If you absolutely have to do arbitrary userjs-style DOM modifications, this is the place to do it.
Examples of VueHooks:
vueHooks: [{
match(c) {return c.$options.name == "pageText"},
computed: {
logButtonText($super) {
return "MOD " + $super()
}
}
}
This hooks the pageText
vue page, which contains the spoiler button logic. It adds the word "MOD" before any log button text, so pages will read "MOD Show Pesterlog" or "MOD Hide Pesterlog". This demonstrates user of the $super
argument within computed
.
vueHooks: [{
matchName: "navBanner",
data: {
urls: [
[ "/"
],
[ "https://www.homestuck.com",
"toggleJumpBox"
],
[ "/map",
"/log",
"/search"
],
[ "toggleBookmarks"
],
[ "/settings",
"/credits"
]
],
labels($super){
let labels = $super
labels['']["https://www.homestuck.com"] = "VIZ"
labels['']["/"] = "HOMESTUCK"
return labels
}
},
}]
This hook uses the matchName
shorthand to match the navBanner
page, which is the top navigation bar.
It replaces the underlying url
object with a new one, discarding any data that was previously there. It also replaces the labels
, but this time it only modifies the two labels relevant to the change, again using the $super
syntax.
All functions within vuehooks have this
bound to the component element, so syntax should be parallel to .vue
files.
Note that within all vue hooks you have access to the this
element, and thus this.$logger
as a namespaced logger for the element in context. Use this logger if a logger is needed.
browserPages
: Map<Name: PageDefinition>
Name
s must be all-caps and represent the base URL of the page.
Each PageDefinition
has the following properties:
component
(object): The object defining the vue component. See documentation.scss
(string): Page-specific css. Note that this will be scoped to the page instance. You should not include root-level specifiers like.pageBody
.
Good SCSS and good scss
:
& {
background: black;
.navBanner {
margin: 0 auto;
background: black;
}
}
Good scss
:
background: black;
.navBanner {
margin: 0 auto;
background: black;
}
Overscoped (won't work!):
.pageBody {
background: black;
.navBanner {
margin: 0 auto;
background: black;
}
}
Note that the component
object has two special page functions that take, as their argument, the context state of the tabframe element, as the page itself will usually be unloaded.
title
(function(ctx)
): A function that should return the tab title of the page.theme
(function(ctx)
) (optional): A function that should return a theme id bassed on the url of the page. This may or may not style the app window depending on user settings. Return anything falsey to use the default theme.
browserActions
: Map<Name: BrowserAction>
Like browserPages, but define a vue component to be used as a browserAction (address bar button).
BrowserAction.component
: the main vue component.
An example BrowserAction, from oddities:
const browserActions = {
HQAudioToggle: {
component: {
methods: {
toggle() {
this.$localData.settings.hqAudio = !this.$localData.settings.hqAudio
}
},
render: function(){with (this) {
return _c(
// Main component: a div with the systemButton class.
"div",
{ staticClass: "systemButton",
// Specify what property makes the button "active":
class: { active: $localData.settings.hqAudio },
// Specify the action onClick
on: { click: toggle }
},
// Graphic used in the icon. Here is a fontAwesome icon
[ _c("fa-icon", {
attrs: { icon: "music" }
}),
// An optional badge, to display additional information
_c("span", { staticClass: "badge" },
[_v($localData.settings.hqAudio ? "HQ" : "LQ")]
)
], 1
)
}}
}
}
}
browserToolbars
: Map<Name: BrowserToolbar>
Like browserActions, but define a vue component to be used as a toolbar.
BrowserToolbar.component
: the main vue component.
Toolbar components should have a main div element.
The archive has a flags
object designed to be used for asynchronous, cooperative mod communication. For instance, if Mod B wants to check if Mod A is loaded, Mod A can run
archive.flags['MOD_A_LOADED'] = true
and Mod B can check
archive.flags['MOD_A_LOADED']
Note that there is no special namespacing done on these flags; any mod can theoretically read and write to any flag at any time. Also note that in the above example, Mod A must be loaded before Mod B in order to recognize its presence. This is intentional behavior.