← back to handoffs

Sprite scaling for item mods

Why your custom item renders too big, off-center, or without an inventory icon — and how to fix each symptom.

About the decompiled source citations on this page. Files like decomp/Assembly-CSharp/Item.cs are decompiled C# recovered from the game's Assembly-CSharp.dll via ILSpy/dnSpy. They are ground truth for what fields the game actually reads. The data in data/*.json shows what modders write; the decomp shows what the engine does with it. When the two disagree, decomp wins. When this page cites Item.cs:271, that is the runtime code you would hit if you stepped through the game in a debugger. The JSON example next to it shows what data feeds that code path.

Snapshot caveat: the decomp in this repo predates the live game build. Structural claims (field names, footprint formulas, file layouts) move slowly and are still trustworthy. Specific engine-bug claims tagged below may have been patched in a more recent build — verify against your own decomp of the current Assembly-CSharp.dll if you depend on the bug's presence or absence.

The three symptoms

When a custom item's PNG doesn't match what the game expects, you'll see a consistent trio of problems:

All three trace back to a mismatch between two independent measures: the logical footprint (how many inventory-grid tiles the item occupies, derived from your JSON data) and the visual footprint (the pixel size of the source PNG, interpreted against a hard-coded tile-pixel constant). This page covers both, plus the inventory icon and normal-map fields that are easy to miss, and a section on installable items that need a separate sprite for their uninstalled (inventory) form.

How logical footprint works

The game reads an item's tile dimensions at load time from two fields on the item's items/ entry (the JsonItemDef struct):

nCols
Width in tiles. Integer; defaults to 1 if omitted. Confirmed in JsonItemDef.cs constructor: this.nCols = 1.
aSocketAdds
Array of loot-table strNames, one entry per body-socket slot. Length of this array divided by nCols gives the height in tiles. Single-tile items have one entry; a 2-tile-tall item has two entries for each column.

The C# that performs this calculation is at Item.cs:271-272:

this.nWidthInTiles  = this.jid.nCols;
this.nHeightInTiles = this.aSocketAdds.Count / this.jid.nCols;
Decomp proof — Item.cs:271-272
// Item.cs lines ~265-272 (inside Item.SetData / load path)
this.bHasSpriteSheet = this.jid.bHasSpriteSheet;
if (this.jid.ctSpriteSheet != null)
{
    this.ctSpriteSheet = DataHandler.GetCondTrigger(this.jid.ctSpriteSheet);
}
this.nWidthInTiles  = this.jid.nCols;
this.nHeightInTiles = this.aSocketAdds.Count / this.jid.nCols;

No height field in the JSON. There is no nRows or nHeight key. Height is always inferred from aSocketAdds.Count / nCols. The socket array entries themselves are loot-table names like "TILItemAdds" — their count is what determines footprint; their values govern what tiles the item may be placed on.

Canonical examples from data/items/items.json

ItemnColsaSocketAdds lengthLogical footprint
ItmDrinkFlask01 (4 L flask) 1 1 1 wide × 1 tall
ItmDrinkFlask02 (8 L flask) 1 2 1 wide × 2 tall
ItmWeaponNightstick02 2 2 2 wide × 1 tall

Edge case: aSocketAdds is empty. If you omit aSocketAdds entirely, the C# default is an empty list, so Count / nCols = 0 / 1 = 0. The item will have a 1×0 footprint — it will load but won't fit on any floor tile. Always include at least one socket entry for an item that's supposed to sit in the world.

How visual footprint works

For a static (non-animated, non-sprite-sheet) item, the game computes a scale vector from the source PNG dimensions at load time. The relevant lines are at Item.cs:285-286:

this.vScale.x = (float)Mathf.Max(MathUtils.RoundToInt(
    1f * (float)this.rend.sharedMaterial.GetTexture("_MainTex").width  / 16f), 1);
this.vScale.y = (float)Mathf.Max(MathUtils.RoundToInt(
    1f * (float)this.rend.sharedMaterial.GetTexture("_MainTex").height / 16f), 1);
Decomp proof — Item.cs:282-289
// Item.cs lines ~282-289 (static-sprite path inside Item.SetData)
else
{
    this.rend.sharedMaterial = DataHandler.GetMaterial(this.rend,
        this.strImgOverride, this.strImgNormOverride,
        this.strImgDamagedOverride, this.strDmgColorOverride);
    this.vScale.x = (float)Mathf.Max(MathUtils.RoundToInt(
        1f * (float)this.rend.sharedMaterial.GetTexture("_MainTex").width  / 16f), 1);
    this.vScale.y = (float)Mathf.Max(MathUtils.RoundToInt(
        1f * (float)this.rend.sharedMaterial.GetTexture("_MainTex").height / 16f), 1);
}
this.rend.sharedMaterial.SetVector("_Aspect", new Vector4(
    (float)this.nWidthInTiles, (float)this.nHeightInTiles,
    (float)this.rend.sharedMaterial.GetTexture("_MainTex").width,
    (float)this.rend.sharedMaterial.GetTexture("_MainTex").height));

The tile size constant is 16 pixels. The game divides raw PNG width and height by 16 to get the number of tiles the sprite occupies visually. There is no config variable for this — it is a hard-coded integer literal throughout the rendering code. (The same constant appears in the inventory renderer at GUIInventoryItem.cs:173,177 and in the GetPos offset math at CondOwner.cs:7360.)

So for a sprite to display as an N×M tile item:

PNG width  = N * 16 px
PNG height = M * 16 px

A 1×1 item needs a 16×16 PNG. A 2×1 item needs a 32×16 PNG. A 1×2 item needs a 16×32 PNG.

The _Aspect shader vector. Immediately after computing vScale, the game also calls material.SetVector("_Aspect", ...) with both the logical dimensions (nWidthInTiles, nHeightInTiles) and the raw pixel dimensions. The shader uses this vector to correctly tile and position the texture within the logical footprint. If vScale and the logical dimensions agree, everything lines up. When they diverge, the shader sees inconsistent inputs and the sprite renders too large or clipped.

The two ways sprite size goes wrong

Symptom 1: PNG too big

Your PNG is, say, 64×64 pixels. The game divides by 16 and gets a vScale of (4, 4) — a 4×4-tile visual footprint. But your item's aSocketAdds has one entry (nWidthInTiles = 1, nHeightInTiles = 1), so the _Aspect vector says 1×1. The shader stretches a 64×64 texture across a 1-tile cell, and the item renders giant.

Fix: Resize the PNG so its pixel dimensions match the intended logical footprint:

target PNG width  = nCols                         * 16 px
target PNG height = (aSocketAdds.Count / nCols)   * 16 px

For a standard single-slot item: 16×16 px. For a 2-wide weapon: 32×16 px.

Symptom 2: Logical footprint wrong

The opposite: your PNG is the right size but you forgot to set nCols or aSocketAdds to match the intended footprint. The item visually occupies 1 tile but its socket array says it's 2×1, so it won't fit into 1-tile-wide inventory slots.

Fix: Audit nCols and aSocketAdds.Count together. The relationship must be:

nCols == intended width in tiles
aSocketAdds.Count == nCols * (intended height in tiles)

If you want a 2-wide, 1-tall item: set "nCols": 2 and supply exactly 2 entries in aSocketAdds.

Asymmetric mistake. A common trap is to set "nCols": 2 but only include one socket entry. Count / nCols = 1 / 2 = 0 (integer division rounds down). The item gets a zero-height footprint, which makes it impossible to place and will produce silent failures, not obvious error messages.

Installables — two items, two sprites

Wall-mounted fixtures — capacitors, pipes, light fixtures, reactor modules — exist in two separate condowners/ entries with two separate items/ item definitions. Neither entry is the "primary" one. They are genuinely different objects that the install/uninstall system swaps between.

The install/uninstall transition is mediated by an entry in data/installables/installables.json (deserialized as JsonInstallable). An installable record ties the two forms together: strActionCO names the loose CondOwner, aLootCOs names the installed CondOwner that replaces it, and strStartInstall names the installed item definition used to position the installed form on the ship grid.

Neither form uses a special sprite field — each uses the standard strImg on its own JsonItemDef. The sprite scaling rules (nCols × 16 px wide, aSocketAdds.Count / nCols × 16 px tall) apply independently to each form.

Worked example: fusion reactor capacitor

The fusion reactor capacitor is the clearest example in the base game data. Its two entries are in data/items/items.json and data/condowners/condowners_reactor.json:

Installed form — ItmCapacitor01

{
  "strName"       : "ItmCapacitor01",
  "strImg"        : "ItmCapacitor01",
  "strImgNorm"    : "ItmCapacitor01n",
  "nCols"         : 2,
  "aSocketAdds"   : [
    "TILFixtureAdds", "TILFixtureAdds",
    "TILFixtureAdds", "TILFixtureAdds",
    "TILFixtureAdds", "Blank",
    "TILFixtureAdds", "Blank",
    "TILFusionReactorPartsFixtureAdds", "Blank"
  ]
}

The matching condowners_reactor.json entry starts with IsInstalled=1.0x1 and IsHiddenInv=1.0x1, which keep it invisible to the inventory UI while mounted.

Loose form — ItmCapacitor01Loose

{
  "strName"       : "ItmCapacitor01Loose",
  "strImg"        : "ItmCapacitor01Loose",
  "strImgNorm"    : "ItmCapacitor01Loosen",
  "nCols"         : 2,
  "aSocketAdds"   : [
    "TILItemAdds", "TILItemAdds",
    "TILItemAdds", "TILItemAdds",
    "TILItemAdds", "TILItemAdds"
  ]
}

The installable record connecting them is in data/installables/installables.json:

{ "strName": "Capacitor01Install",
  "strActionCO": "ItmCapacitor01Loose",
  "aLootCOs": [ "ItmCapacitor01" ],
  "strStartInstall": "ItmCapacitor01",
  "strJobType": "install", ... }

{ "strName": "Capacitor01Uninstall",
  "strActionCO": "ItmCapacitor01",
  "aLootCOs": [ "ItmCapacitor01Loose" ],
  "strJobType": "uninstall", ... }

The sprite scaling formula is the same for both forms. There is no separate sprite-scaling code path for installable items. Both the installed and the loose form go through the same Item.SetData load path at Item.cs:271-272 and Item.cs:285-286. The only difference is which socket type each form's aSocketAdds references — fixture sockets vs. floor sockets — and which entry has IsInstalled / IsHiddenInv in its starting conditions.

What to do when modding an installable

  1. Create two items/ entries and two condowners/ entries: one installed form (fixture sockets, IsInstalled, IsHiddenInv), one loose form (floor sockets, pickup/drop interactions).
  2. Create two installables/ entries: one strJobType: "install" and one strJobType: "uninstall", linking the loose CondOwner to the installed CondOwner and back.
  3. Size each PNG independently. The installed form's PNG should match its wall-mount footprint (nCols × 16 wide, socket-count-rows × 16 tall); the loose form's PNG should match its carry/inventory footprint, which is typically smaller.
  4. Both entries can have their own strImgNorm. The loose form often uses the same normal map as the installed form (as the capacitor does: both reference ItmCapacitor01n / ItmCapacitor01Loosen), but they can differ.

Inventory icon

The inventory icon is not a separate sprite field. The inventory grid renders the same strImg PNG that the floor view uses, loaded via item.ImgOverride (which defaults to jid.strImg). The rendered size in the inventory grid is determined at GUIInventoryItem.cs:154-182:

  1. Start from the raw PNG width/height.
  2. If the item's CondOwner definition (in data/condowners/condowners.json) has a non-zero inventoryWidth or inventoryHeight, override the pixel count: num = inventoryWidth * 16 (same 16-px constant). Then pass those dimensions to the _Aspect shader vector.
  3. If both inventoryWidth and inventoryHeight are zero or absent (the common case — no base-game item in the current data uses these fields), the inventory falls back to the raw texture dimensions.

The grid slot size itself comes from GUIInventoryItem.GetWidthHeightForCO (GUIInventoryItem.cs:18-37): it starts with item.nWidthInTiles / nHeightInTiles, then overrides with inventoryWidth / inventoryHeight from the CondOwner def if set.

Decomp proof — GUIInventoryItem.cs:156 (no separate icon field)
// GUIInventoryItem.cs:154-157 (inside DrawItem)
// Inventory renders from item.ImgOverride, which is jid.strImg
// No strImgIcon or strImgInventory field exists on JsonItemDef.
texture = DataHandler.LoadPNG(item.ImgOverride + ".png", false, false);
material.SetTexture("_MainTex", texture);

If your item has no inventory icon. The most common cause is that strImg in the item def resolves to a file that doesn't exist, or resolves to "blank". The game calls DataHandler.LoadPNG(item.ImgOverride + ".png") — the .png extension is appended automatically; don't include it in strImg. If the file is missing, the texture load silently returns a blank and the inventory slot appears empty.

strImg
On the items/ entry (JsonItemDef). Path to the PNG without extension. Used for both the floor render and the inventory icon. Example: "strImg": "ItmDrinkFlask01" loads ItmDrinkFlask01.png.
inventoryWidth / inventoryHeight
On the condowners/ entry (JsonCondOwner). Optional override for the inventory grid slot size, in tiles. Rarely needed — only set these if you want the inventory representation to differ from what the PNG dimensions imply. Zero (or absent) means "use the PNG dimensions."

strImgNorm — the normal map

strImgNorm is the field on JsonItemDef (in your items/ entry) that points to the normal-map texture. The game passes it to DataHandler.GetMaterial alongside strImg, and the shader reads it as the _BumpMap texture to compute per-pixel lighting direction. Without it, the item renders with flat lighting.

Decomp proof — DataHandler.cs:3464-3468 (world render BumpMap binding)
// DataHandler.cs:3461-3468 (inside DataHandler.GetMaterial)
material.SetTexture("_MainTex", DataHandler.LoadPNG(strImg + ".png", false, false));
material.mainTextureScale  = new Vector2(1f, 1f);
material.mainTextureOffset = default(Vector2);
if (strImgNorm != "blank")
{
    material.SetTexture("_BumpMap", DataHandler.LoadPNG(strImgNorm + ".png", true, false));
    material.SetTextureScale("_BumpMap",  new Vector2(1f, 1f));
    material.SetTextureOffset("_BumpMap", default(Vector2));
}

The field is optional. If you omit strImgNorm or set it to "blank", the item will still render; it just won't respond to the ship's dynamic lighting the way vanilla items do. For quick prototyping, you can skip it entirely and add it later.

Inventory bump map bug (verified against an older decomp — may be patched in the live build). In the decomp snapshot this repo holds, Item.SetUpInventoryMaterial at Item.cs:447-449 correctly checks whether strImgNormOverride is set, but then loads the wrong file: it loads strImgOverride (your diffuse texture) as the _BumpMap instead of strImgNormOverride. The world-render path (DataHandler.GetMaterial) does this correctly. The inventory path does not.

In practice for that snapshot: even when you set strImgNorm, the inventory icon receives no functional normal map — the _BumpMap slot is just a second copy of the diffuse texture, which is a degenerate flat normal. Set strImgNorm for the benefit of the world (floor) rendering; expect no extra per-pixel shading from it in the inventory view.

Patch status unknown. The game has received patches since this decomp was taken. If you care whether the inventory normal map actually works in your current build, decompile your local Assembly-CSharp.dll and re-check Item.SetUpInventoryMaterial — the fix would be a one-line change from strImgOverride to strImgNormOverride on the LoadPNG call.

Decomp proof — Item.cs:447-449 (inventory bump map bug)
// Item.cs:447-449 (inside Item.SetUpInventoryMaterial)
if (this.strImgNormOverride != "blank" && this.strImgNormOverride != "" && this.strImgNormOverride != null)
{
    // BUG: loads strImgOverride (the diffuse texture) instead of strImgNormOverride
    material.SetTexture("_BumpMap", DataHandler.LoadPNG(this.strImgOverride + ".png", false, false));
}

// Compare: DataHandler.GetMaterial (world render, correct):
// material.SetTexture("_BumpMap", DataHandler.LoadPNG(strImgNorm + ".png", true, false));

When you are ready to add a normal map, the standard workflow (used for all vanilla assets) is:

  1. Open your strImg PNG in GIMP.
  2. Go to Filters → Generic → Normal Map.
  3. Adjust depth to taste (vanilla items typically use modest depth values so the lighting reads subtly). Export as a separate PNG.
  4. Set "strImgNorm": "<your-normal-map-filename-without-extension>" in the item def.

Naming convention. Vanilla items follow the pattern <strImg>n for the normal map: ItmDrinkFlask01ItmDrinkFlask01n. This is convention only — the field is a free path — but matching it keeps your mod's asset folder readable.

Off-center rendering and mapPoints

The floor render positions the item's GameObject at the world coordinates (fX, fY) from the ship JSON, then sets localScale to (vScale.x, vScale.y, 1) (Item.cs:591-593). The origin of a Unity sprite is the center of its renderer bounds — so a 16×16 sprite on a 1×1-tile cell is centered on the tile grid point. A 32×16 sprite is centered on the midpoint between its two tile columns.

What causes off-center rendering. If your PNG is not an integer multiple of 16 px in each dimension, the MathUtils.RoundToInt(... / 16f) call rounds to the nearest integer tile count. A 20-px-wide sprite rounds to 1 tile of visual scale (20/16 = 1.25, rounds to 1), so the extra 4 px get cropped. A 24-px sprite rounds to 2 tiles (24/16 = 1.5, rounds to 2), so it renders at 2-tile width with most pixels unused — both cases look "off" in different ways. Keep PNG dimensions to exact multiples of 16.

mapPoints — named interaction offsets. The mapPoints field (on the JsonItemDef entry, and also on JsonCondOwner entries) defines named pixel offsets from the item's world position. Each entry is a comma-separated string "name,x,y" where x and y are in pixels relative to the item's center, divided by 16 to convert to world units (CondOwner.cs:7360). For example, "use,24,0" places the "use" interaction point 24 px (1.5 tiles) to the right of center.

Decomp proof — CondOwner.cs:7357-7363 (mapPoints is offsets only)
// CondOwner.cs:7357-7363 (inside CondOwner.GetPos)
// mapPoints values are used ONLY to offset interaction points.
// The sprite transform is set independently in Item.ResetTransforms.
foreach (string entry in this.mapPoints)
{
    string[] parts = entry.Split(',');
    if (parts[0] == strPointName)
        return new Vector2(float.Parse(parts[1]) / 16f, float.Parse(parts[2]) / 16f);
}

mapPoints does not shift the sprite itself — it only affects where crew stand when interacting with the item, and where things like power cables connect. The sprite's visual anchor is always at the sprite center; mapPoints cannot be used to "nudge" the rendered image relative to the tile grid.

Common off-center pitfall. Resizing a PNG in a tool that pads to a power-of-two (e.g. from 20×20 to 32×32 with transparent padding) will center the art content inside a larger canvas. The game sees a 32×32 sprite (2 tiles wide, 2 tiles tall) and renders it over 2×2 tiles, but the actual art content is in the center quarter. The fix is to crop the canvas tightly to the art and then resize to an exact tile multiple.

Confidence — what was verified vs. inferred

ClaimStatusSource
Tile size constant is 16 px verified Item.cs:285-286 (floor renderer), GUIInventoryItem.cs:173,177 (inventory renderer), CondOwner.cs:7360 (mapPoints offset math) — the literal 16f appears independently in all three
Logical footprint: nWidthInTiles = jid.nCols, nHeightInTiles = aSocketAdds.Count / jid.nCols verified Item.cs:271-272; confirmed against four items in data/items/items.json
Visual footprint: vScale = round(px / 16) with minimum 1 verified Item.cs:285-286; same formula in animated path at Item.cs:196-197
Inventory icon uses same strImg PNG; no separate icon field on JsonItemDef verified GUIInventoryItem.cs:156 loads item.ImgOverride + ".png"; JsonItemDef.cs has no strImgIcon or similar field
strImgNorm drives the normal-map (_BumpMap) shader slot in world render verified Item.cs:284 passes strImgNormOverride to DataHandler.GetMaterial; DataHandler.cs:3464-3466 binds it as _BumpMap
SetUpInventoryMaterial binds diffuse texture (strImgOverride) to _BumpMap instead of strImgNormOverride — inventory normal map is broken (in the decomp snapshot; may be patched in the live build) verified vs. older decomp Item.cs:447-449: the condition guards on strImgNormOverride but the LoadPNG call passes strImgOverride; contrast with DataHandler.GetMaterial which passes strImgNorm correctly. The decomp in this repo predates the current game build; re-verify against a fresh decomp before depending on the bug's presence.
mapPoints controls interaction offsets only, not sprite anchor verified CondOwner.cs:7357-7363: mapPoints values used only inside GetPos(strPointName); sprite transform set independently in Item.ResetTransforms at Item.cs:588-605
Installables use two separate items/ entries (installed + loose); each has its own strImg and independent sprite sizing verified data/items/items.json (ItmCapacitor01 and ItmCapacitor01Loose); data/installables/installables.json (Capacitor01Install/Capacitor01Uninstall); no special sprite path in Item.cs for installables — same SetData code runs for both
Installed form distinguished by IsInstalled=1.0x1 and IsHiddenInv=1.0x1 starting conditions; loose form has neither verified data/condowners/condowners_reactor.json (ItmCapacitor01 CondOwner entry vs. ItmCapacitor01Loose entry); installable socket type (TILFixtureAdds vs. TILItemAdds) also distinguishes the two

Summary: 9 verified, 0 inferred. All nine claims are grounded in specific lines of decompiled C# and/or confirmed against live data entries.

Quick reference — sprite misbehaving checklist

  1. Measure your PNG. Width and height should each be an exact multiple of 16 px and equal to nCols * 16 and (aSocketAdds.Count / nCols) * 16 respectively.
  2. Count your sockets. aSocketAdds.Count must equal nCols × intended_height_in_tiles. A count that's not divisible by nCols gives fractional tiles (C# integer division truncates).
  3. Check your PNG path. strImg must be a filename without the .png extension. The game appends it. A typo or missing file gives a blank inventory icon.
  4. Off-center? Crop the PNG canvas tightly to the art. No power-of-two padding. Keep pixel dimensions at multiples of 16.
  5. No lighting response? Add strImgNorm pointing to a normal-map PNG (GIMP: Filters → Generic → Normal Map on your base sprite). Affects world (floor) rendering. The inventory normal map looked broken in our decomp snapshot but may be patched in the current build — see the bump map note above.
  6. Inventory slot wrong size? The slot size comes from nWidthInTiles / nHeightInTiles, which come from nCols / aSocketAdds. Fix step 2. If you specifically need an inventory size that differs from the world footprint, set inventoryWidth / inventoryHeight on the CondOwner entry (rare; no base-game item currently uses this override).
  7. Modding an installable (e.g. a wall-mount fixture)? You need two item defs and two CondOwner entries: one installed form (TILFixtureAdds sockets, IsInstalled, IsHiddenInv) and one loose form (TILItemAdds sockets, no IsInstalled). Add two entries to installables/ (one install, one uninstall) linking the loose and installed CondOwners. Size each PNG independently against its own nCols/aSocketAdds.