Skip to content

Mixin reference

The complete reference for TSPML's Tier-2 patch system. For a gentler introduction, start with the mixin guide.

Mixins are transform-time JSON descriptors: the portal collects every enabled mod's patches, resolves stable names through the mappings, and applies them to the game bundle in one pass as it is served. There is no runtime api.mixin.* object.

Config file shape

jsonc
{
  "patches": [
    {
      "op": "after",
      "symbol": "Car",                 // stable name → mappings (fail-closed)
      "inject": "(function(){ /* … */ })();"
    },
    {
      "op": "before",
      "target": {                      // OR an inline structural target
        "anchor": { "literals": ["CreateCar", "ControlCar"], "minHits": 2 },
        "selector": { "kind": "method", "name": "controlCar" }
      },
      "inject": "/* statements injected at HEAD */"
    }
  ]
}

Reference it from mod.json as "mixins": [{ "config": "mixins.json" }]. A descriptor may carry "environment"; the web portal skips configs declared for desktop/worker.

Operations

opBehavior
beforeInject statements at the head of the target's body
afterInject before each return (or at the end if none)
aroundOriginal body rebound to proceed; your inject becomes the new body
replaceOverwrite the body (last resort, single-winner)
modifyArgReplace argument index of calls to callee inside the target
modifyReturnWrap each returned value: return (wrap)(X)
modifyConstantReplace an object-property value selected by key

Payloads (inject / wrap / replaceWith) are JavaScript source strings, parsed and inserted into the AST; malformed source fails the patch with bad-inject-source.

Per-op fields

Every patch has op, a target (symbol or target), and an optional priority (higher runs first; default 0). The rest depends on the op:

opFieldsNotes
before / after / replaceinjectStatement source (one or more statements)
aroundinject, proceedName?inject is the new body; proceedName renames the proceed binding if your code needs the default name for something else
modifyArgcallee, index, replaceWithcallee matches an identifier or member-property name called inside the target; index is the 0-based argument to replace; replaceWith is expression source
modifyReturnwrapExpression source that evaluates to a function, e.g. "(v) => v * 2"; each return X becomes return (wrap)(X)
modifyConstantreplaceWithExpression source for the new property value; the target's selector must pick a property

around semantics

proceed() invokes the original body, preserving parameters and this. Nested around patches compose: the highest-priority patch wraps outermost, first in on the way in, last out on the way out. Not calling proceed short-circuits the original entirely. That is the point of around, but use it knowingly: every other mod's before/after on the same target still applies to the body proceed runs.

Worked examples

jsonc
// Log every call to controlCar with its real first argument:
{ "op": "before", "symbol": "Car.controlCar",
  "inject": "console.log('controlCar', __TSPML_PARAM0__);" }

// Skip the original entirely when a flag is set (otherwise run it unchanged):
{ "op": "around", "symbol": "Car.controlCar",
  "inject": "if (window.__myModFrozen) return; return proceed();" }

// Double whatever the target returns (any pinned symbol works here):
{ "op": "modifyReturn", "symbol": "Car.createCar", "wrap": "(v) => (console.log('created', v), v)" }

Parameter placeholders

Reference the target function's parameters by ordinal, never by minified name:

jsonc
{ "op": "before", "symbol": "Car.controlCar",
  "inject": "console.log('carId', __TSPML_PARAM0__);" }

__TSPML_PARAM<n>__ is renamed at apply time to the located function's actual nth parameter name, so payloads survive re-minification. Placeholder-shaped text inside string literals is left untouched (it's data). In around payloads, placeholders resolve against the wrapped function's own parameters, so proceed(__TSPML_PARAM0__) forwards the real first argument.

Resolution is fail-closed with reason param-unresolvable when:

  • the ordinal is out of range for the located function,
  • the parameter is a destructuring or rest pattern (not a plain identifier),
  • the target has no parameter list (e.g. modifyConstant),
  • your payload declares a local binding that would shadow the resolved name, or
  • a block around any injection site shadows the parameter.

Targets

A patch targets one of:

  • symbol: a stable name resolved fail-closed through the mappings' pinned targets. This is the supported path; the pinned set grows with the mappings. Pinned in the current (0.6.2) map:

    SymbolWhat it locates
    CarThe car module's factory
    Car.controlCarThe per-frame input method (car.control hooks here)
    Car.createCarCar creation (car.created hooks here)
    TrackSelectionUiThe track menu's constructor
    TrackCodecThe track import/export codec factory
  • target: an inline structural locator: a module anchor (distinctive string/numeric literals + minHits) plus a selector (method {name} / property {key} / factory). The power-user escape hatch. It skips the stable-name layer, so it inherits none of its update-resilience.

Conflict policy

OpsAcross mods
before / after / around / modifyArg / modifyReturn / modifyConstantChain: multiple patches on one target compose, priority-ordered (descending, stable within equal priority)
replaceSingle-winner: two replace patches on the same target is a load-time conflict. Both fail with conflict-replace-single-winner; neither applies

Failure reasons

Per-patch, reported (never thrown), isolated from other patches and mods:

ReasonMeaning
not-foundThe target couldn't be located in the bundle
symbol-unresolvedThe stable name isn't in the current mappings
hash-mismatchThe mappings don't match the running bundle: fail-closed, no patches from a stale map
conflict-replace-single-winnerTwo mods replace the same target
bad-inject-sourceThe payload isn't parseable JavaScript
op-not-applicableThe op doesn't fit the selected target kind
param-unresolvableA __TSPML_PARAMn__ couldn't be resolved safely (see above)

The per-mod verdict (1/1 applied, 0/1 + reason) shows in the portal's Your mixins section after each game load. User-mod patch failures can never take down TSPML's own bridge patches; the base transform and your patches fail independently.

TSPML is a fan-made tool. It never redistributes PolyTrack; the portal transforms your own live copy of the game.