Clone and add a weapon¶
ContentTool creates a weapon by cloning a shipped WeaponDef. The clone supplies firing behavior,
tags, compatible actor animations, ammo machinery, effects and any values you do not override. Your
manifest supplies a stable identity, optional model/icon, changed stats and starting quantity.
Choose the donor by weapon class. Phoenix Point selects hold poses and firing animation sets by
membership in an EquipmentListDef (RiflesListDef, SnipersListDef, PistolsListDef, ...) —
NOT by ItemDef.Tags and NOT by HandsToUse. ContentTool appends the clone next to its donor
automatically. Consequence: a sniper mesh on a PDW donor gives wrong hands on the grips AND a
wrong-size fit. Choose the donor to match the MESH CLASS, not just the stats.
1. Find a donor and supporting defs¶
Use the live repository:
ct_list defs AssaultRifle WeaponDef
ct_list defs Fire DamageTypeBaseEffectDef
ct_list defs Burning DamageKeywordDef
The last command is <nameFilter> <typeFilter>: DamageKeywordDef is the type, while results such
as Burning_DamageKeywordEffectorDef and Damage_DamageKeywordDataDef are def names of that type.
In keywords, write one of those def names before =value; all three spellings are correct in their
own slots.
Copy complete def names, including bracketed owners when present. Start with a donor whose magazine, projectile behavior, damage keywords and hold pose are already close to the intended weapon.
2. Add a stat-only clone¶
A weapon can keep the donor's model. This is the smallest content shape:
{
"id": "yourname.myweapon",
"bundle": "MyWeapon.bundle",
"weapons": [
{
"id": "YourName_FieldSidearm_WeaponDef",
"clone": "SY_LaserPistol_WeaponDef",
"guid": "replace-this-with-your-own-dashed-uuid",
"name": "Field Sidearm",
"blurb": "A tuned laser sidearm.",
"damagetype": "Fire_StandardDamageTypeEffectDef",
"keywords": "Burning_DamageKeywordEffectorDef=20",
"count": 1,
"clips": 3
}
]
}
This is a generic no-model recipe, not the shipped WeaponAdd demo: all three Vulture rows now
declare their own model key (demos/WeaponAdd/ppcontent.json:26-77).
Do the first enable with damage and spread omitted. Every weapon prints the donor's real tuning
on the left of each arrow and the clone's value on the right. This captured line, for example, says
the donor dealt 40 damage with 2 degrees of spread before the demo overrode them:
ct_weapon PASS 'Vulture Sniper' (Morgott_VultureSniper_WeaponDef) cloned from SY_LaserSniperRifle_WeaponDef; icon ok; prefab load started for key c7a9f1d24b6e4a3c8f5b7d1e9a2c4b60; tuning dmg 80->60 spread 1->1 range 41->41 (source intact)
Omitting damage or spread keeps that donor value and still prints both sides. range has no
manifest key; it is recomputed from spread, so changing spread changes it. When maximum range is
infinite, the readout is 41 / spread. Read this line from the player's own installation before
choosing numbers; do not guess from a weapon name or another game version.
id, clone, and guid are required. No ContentTool command generates a GUID: invent it once and
keep it stable after release. It is an opaque def-identity string, not a parsed 128-bit value;
ContentTool requires it to be non-empty and at least two characters long. In practice, use any UUID
generator and follow the shipped convention of a dashed UUID with a hand-varied middle or tail, for
example c7a9f1d2-4b6e-4a3c-8f5b-7d1e9a2c4b01, then ...4b11 for another row. Replace the explicit
placeholder in the example above.
ContentTool uses the declared identity for the weapon, then derives the view and skin identities as
a plus its tail and b plus its tail. Every weapon, view and skin identity across the manifest
must be distinct. A GUID beginning with a or b would collide with its own derived identity, and
two entries that vary only their first character would still derive the same view and skin. Use a
different middle or tail digit for every row. A duplicate def identity also collides with the
repository.
Omit zero-valued overrides rather than relying on zero as a stat. damage and spread equal zero
mean “keep the donor.” count and clips are quantities added to starting storage; zero adds none.
That storage change affects new campaigns, not existing saves.
keywords is a semicolon-separated flat string because a nested object is not valid in a weapon
row. Each term is DefName=value.
3. Give the weapon a model¶
Put the GLB under Content\Models, publish it, and refer to the same key from the weapon row:
{
"id": "yourname.myrifle",
"bundle": "MyRifle.bundle",
"publish": [
{
"key": "yourname.myrifle/models/field_rifle",
"asset": "models/field_rifle",
"type": "GameObject",
"deps": "defaultlocalgroup_unitybuiltinshaders.bundle"
}
],
"weapons": [
{
"id": "YourName_FieldRifle_WeaponDef",
"clone": "PX_AssaultRifle_WeaponDef",
"guid": "replace-this-with-your-own-dashed-uuid",
"name": "Field Rifle",
"blurb": "An assault rifle fitted to Phoenix specifications.",
"icon": "Icons\\field_rifle.png",
"model": "yourname.myrifle/models/field_rifle",
"fit": "auto",
"offset": "0,-0.07,0",
"damage": 40,
"spread": 2.5,
"count": 1,
"clips": 3
}
]
}
fit: auto uniformly scales and offsets the model into the donor weapon's bounds — the largest
mesh by bounds diagonal, uniform scale = smallest of the 3 extent ratios. It cannot know which end
is the muzzle; add flip: true when the fitted direction is reversed.
Override the fit solver when it gives the wrong answer:
scale: float — explicit uniform mesh scale, replaces the computed value.rotate:"x,y,z"euler degrees — explicit mesh rotation, replaces the axis-aligned auto rotation andflip.offset:"x,y,z"metres — adds a local-position nudge after the fit has solved rotation, scale and centre. It does not replace the solved position. The derived shoot, aim and shell sockets move by the same vector (WeaponBuild.cs:797-840). Withoutfit: auto, it adds to the baked mesh child's existing local position (WeaponBuild.cs:906-920).
All three are written to the prefab's mesh CHILD, not the root — see Why the fit must live below the prefab root below.
Fit the model in the workbench¶
The automatic solve can match one bounding box to another, but it cannot see a trigger or decide where a hand should close around a grip. The weapon fit workbench puts the selected weapon in a unit's hand inside the running game, so the last centimetres can be fitted by eye instead of by editing numbers, rebuilding and relaunching after every guess.
The fit is not baked into the AssetBundle. The bundle carries the baked model and its render assets;
scale, rotate and offset remain in the content mod's ppcontent.json and are applied when
WeaponBuild.Build creates the weapon. Tuning those fields never needs another ct_project, and
rebaking changed art does not remove the tuning from the manifest.
First bake the model, build the content mod's DLL and enable the mod so its WeaponBuild.Build call
has created the weapon. Start or load a campaign and wait for the geoscape to finish loading, then
press Ctrl+Alt+B. The workbench uses the geoscape SquadBay and refuses to open from the main menu,
during a load, or in any other level that has no SquadBay. The console reaches the same entry and
exit paths:
With no argument, ct_bench toggles the workbench. Close it with the on-screen CLOSE
(Ctrl+Alt+B) button, the same key chord, or ct_bench close. ct_bench reset and RESET VIEW
restore the workbench's default camera, orbit, zoom, model turn and animation state without changing
the fit.
Do not rebind this tool to F4, F5, F9 or F10. Phoenix Point owns those keys: F5 and F9 are quicksave and quickload, while F4 and F10 invoke other game tools. Unity sends one press to both the mod and the game, so assigning one of those keys would run both actions. ContentTool refuses to arm a workbench hotkey on any of them.
Use this first-fit path:
- Open unit, type part of a def name if needed, and select a buildable
TacCharacterDef. Soldiers, Pandorans and vehicles are available when their template has the view and addons data needed to stand it in the SquadBay. - Open weapon and select the new weapon. ContentTool asks
CommonCharacterUtils.CanSwapItemwhich shipped weapons that body can carry. Weapons built by ContentTool remain at the top even when that test refuses them, so a bad donor or body choice is visible rather than missing; the unit's hand stays empty in that case.*marks a ContentTool weapon, and* livemeans its fit record is loaded this session and its controls are ready. - Left-drag beside the panel to orbit around the unit and use the wheel to zoom. invert X and invert Y change the orbit direction for this session. Right-drag turns the whole model for inspection; it does not change the saved weapon fit. Use RESET VIEW if the view becomes unusable.
- Drag an arrow on the weapon for a coarse move along that parent-local axis. Drag a ring to turn the weapon about its matching axis. Both gestures use the same fit service as the buttons, so the manifest values, derived sockets and every live copy of the weapon follow what is on screen.
- Use the move row's X-, X+, Y-, Y+, Z- and Z+ buttons, and the matching turn row, for exact changes. The step buttons labelled move, turn and scale cycle their increments; use the scale row's - and + buttons for uniform scale. There is no per-axis scale.
- Use the strip under the model to choose one of the live controller's playable states. It provides
PLAY / PAUSE, loop, a normalized scrub slider and a speed button that cycles from
x0.05tox2. Return to IDLE before judging the ordinary hold pose. - Watch the status above the controls. MODIFIED means the live numbers differ from the last values read from or written to the manifest; no file has changed yet. REVERT discards the live experiment and re-reads that weapon row. RESET AUTO discards all three live overrides and recomputes the bounding-box solve. Neither button writes to disk.
- Press SAVE TO FILE when the grip, motion and scale are correct. The answer box prints the
exact
ppcontent.jsonwritten and, when source mirroring is configured, the exact source path that received the same bytes. Do not close the workbench until that answer names the copy you intend to keep.
SAVE splices only scale, rotate and offset into that weapon's flat manifest row. It preserves
the file's other bytes, including key order, indentation, line endings and BOM. It does not write the
AssetBundle, GLB or any shipped Phoenix Point file. After saving, close the workbench and verify
muzzle position, aim, firing, reload, holster and the full animation set in play. If only the fit
changed, go directly to ct_package; do not rebake the model.
The older console dial remains available when the panel or drag handles cannot be used:
The second form adds the declared delta to the prefab and matching live instances, then prints a
paste-ready scale / rotate / offset block. Repeating it accumulates deltas. The workbench's
SAVE TO FILE button writes that block into the correct row, so no manual paste is needed.
Know which ppcontent.json SAVE changes¶
The live weapon remembers the absolute ppcontent.json path passed to WeaponBuild.Build. SAVE
always writes that file first because it is the copy from which the running game built the weapon.
In a project kept directly under <Phoenix Point>\Mods\MyRifle, that file is already the author's
working copy and no second destination is needed. Without a marker, the answer says that no source
was recorded; that is expected when the one file is both the working and loaded copy.
When a repository source folder is copied to a separate game installation, put a one-line
.contenttool-source file beside the deployed ppcontent.json. Its line is the absolute path to the
source mod folder. ContentTool's deploy.ps1 writes this marker for ContentTool and every demo it
deploys. After the deployed manifest has been saved successfully, the workbench reads the marker and
copies the exact saved bytes to <recorded source folder>\ppcontent.json.
Source mirroring belongs to the workbench's SAVE TO FILE path. The console form
ct_fit <weapon> save writes the originating manifest but does not perform this second copy.
In the separate-repository layout, the source manifest remains authoritative. A successful answer
names the deployed path and then says AND mirrored back to the source: followed by the repository
path. Continue editing, committing and packaging from that source folder. If the marker is absent,
points to a folder that no longer exists, or cannot be written, the answer says so and confirms that
the deployed file was still saved. Copy that newer deployed ppcontent.json back to the repository
before the next deploy; otherwise the next copy from source can overwrite the fit. The supplied
deploy.ps1 also warns before it overwrites a deployed demo whose manifest differs from its source
copy.
Workbench limits¶
- A fully loaded geoscape campaign is required because the preview stands in its SquadBay.
- A shipped weapon can be held for comparison, but it has no content-mod manifest row and therefore no fit controls or SAVE operation.
- The gizmo has translation arrows and rotation rings, but no scale handle; use the scale row's - and + buttons.
- An arrow or ring nearly edge-on to the camera is dimmed and refuses the drag. Its screen projection cannot produce an accurate distance or angle, so the workbench does not approximate one; orbit a little or use that axis's buttons.
- Rotation rings also refuse a mirrored or unevenly scaled parent because the world-space turn cannot be represented as an exact child-local rotation there. The local turn X/Y/Z buttons remain exact.
- The animation strip lists only clip names that the live Animator confirms are playable state names. A controller with no such names remains in the weapon-appropriate idle.
- RESET AUTO applies only to a row with
"fit": "auto"; an offline-placed model has no automatic solve to restore, so use REVERT instead.
The weapon needs attachment transforms for projectile origin/muzzle flash, aim/IK and shell
ejection. With fit: auto, ContentTool derives them. For a model pre-fitted in your art tool, provide
explicit local coordinates:
{
"id": "yourname.myrifle",
"bundle": "MyRifle.bundle",
"publish": [
{
"key": "yourname.myrifle/models/field_rifle",
"asset": "models/field_rifle",
"type": "GameObject",
"deps": "defaultlocalgroup_unitybuiltinshaders.bundle"
}
],
"weapons": [
{
"id": "YourName_FieldRifle_WeaponDef",
"clone": "PX_AssaultRifle_WeaponDef",
"guid": "replace-this-with-your-own-dashed-uuid",
"name": "Field Rifle",
"model": "yourname.myrifle/models/field_rifle",
"shoot": "0.00435,0.06109,0.76880",
"aim": "0.00435,0.06109,0.41911",
"shell": "0.02021,0.06109,0.41911",
"count": 1,
"clips": 3
}
]
}
0,0,0 is a legal socket. ContentTool tests whether shoot was declared, not whether its value is
nonzero. A model with neither fit: auto nor shoot is refused because a weapon without a projectile
origin fails during firing.
4. Choose a projectile, colour and muzzle flash¶
The clone inherits its donor's projectile and muzzle effects. Override any of these with manifest keys:
projectile¶
Name of a shipped WeaponDef (takes its DamagePayload.ProjectileVisuals) or a ProjectileDef
name directly. The projectile prefab is what travels from the muzzle to the target — there is no
hitscan in Phoenix Point. Every non-melee shot is a moving Projectile with
DamagePayload.Speed; a "beam" is just a projectile whose prefab has a long TrailRenderer.
DamageDeliveryType (Melee/DirectLine/Parabola/Sphere/Cone) is the only delivery selector.
flash¶
Name of a shipped WeaponDef — takes its VisualEffects (EquipmentVisualEffectsDef: Flash,
Smoke, Shell). Useful when the donor's muzzle effect does not match the projectile you chose.
Caveat: NJ_FlameThrower_WeaponDef's ProjectileVisuals is an EMPTY prefab shared with
Bash_WithWhateverYouCan — copying it gives an INVISIBLE shot. Its fire lives in
VisualEffects.Flash. Good example of "check the donor's prefab, not its name."
tint¶
#RRGGBB — clones the ProjectileDef and takes a PRIVATE copy of its prefab, then recolours
every TrailRenderer colorGradient key and every ParticleSystem startColor/colorOverLifetime.
Format is #RRGGBB only (no #RGB, no alpha).
Why this key exists: shot colour is NOT in any def. All laser projectile prefabs are pure
white (trail gradient keys + PS start colour = (1,1,1,1)); the hue comes from a SHARED trail
material. You cannot get a colour by picking a differently-coloured donor — you must tint.
Caveat: tint is vertex-colour only, the material stays shared, so the landed hue = tint x
material. Clean on white laser prefabs; an already-coloured donor bolt needs a private material
instance (not implemented).
trail¶
Float seconds — TrailRenderer.time on the private prefab copy = beam LENGTH. Implies the same
private clone as tint.
Shared vs private copies¶
Donor visual defs are SHARED references — assigning a donor's ProjectileVisuals/VisualEffects
directly is safe, but mutating them repaints every weapon in the game that uses them. tint and
trail therefore clone first. The engine logs projectile=<name> (own copy) vs (shared) in
Player.log — check this to confirm your weapon has a private copy.
5. Why the fit must live below the prefab root¶
Addon.AttachVisuals (Assembly-CSharp, Addon.cs:1079-1080) does
VisualRoot.SetParent(attachTransform); VisualRoot.ResetTransform(); — that zeroes
localPosition/localRotation/localScale of the prefab ROOT on attach. ContentTool therefore writes
fit/scale/rotate/offset to the prefab's existing mesh CHILD (WeaponBuild.cs:906-920). Known ceiling: a foreign
prefab whose mesh sits ON the root has nowhere below the root to write, and keeps the erased-at-attach
behaviour.
6. Understand the four pieces¶
The builder creates:
- a weapon def cloned from
clone; - view/skin defs tied to stable derived GUIDs;
- an optional prefab loaded from the published
modelkey, with shoot, aim, aim-IK and shell transforms; - starting-storage entries according to
countandclips.
Omitting model skips the skin replacement entirely, so the clone keeps the donor's art. Publishing
a model without naming its key in a weapon row does not attach it. When icon is present, the
builder loads it and assigns the cloned view's inventory, small and large icons itself.
7. Build from your DLL¶
Weapons require a DLL because the manifest cannot call the def builder by itself. Build your normal
Phoenix Point mod assembly from the complete project, references and ModMain
skeleton, declare it in meta.json, and keep its WeaponBuild.Build call in
OnModEnabled. Before adding any assembly reference, read the shared
profile-wide Managed\ module warning. Substitute
your weapon project name for MyMod in that project file.
The API is public static List<WeaponDef> Build(string modDir, Action<string> log). It registers all
declared defs and applies their starting-storage quantities, so normally ignore the return value.
Keep the returned list only when later behaviour needs those defs.
The builder handles the declared rows and starting storage. If your design uses research, manufacturing, rewards or existing-save migration instead, leave starting quantities at zero and wire the returned defs into that behavior yourself.
Use a complete code-mod meta.json:
{
"ID": "yourname.myrifle",
"AssemblyName": "MyRifle.dll",
"Version": "1.0.0",
"Author": [
{ "Key": "English", "Value": "Your Name" }
],
"Name": [
{ "Key": "English", "Value": "My Rifle" }
],
"Description": [
{ "Key": "English", "Value": "Adds a weapon. Requires ContentTool." }
],
"Dependencies": [
"com.morgott.ContentTool"
]
}
8. Bake, test and package¶
For a model-backed weapon:
The two catalog commands are an author preview, not a player or release step.
Unticking removes a published model key, but it does not remove weapon defs created earlier in the session. The weapon can therefore remain while its art is unavailable. Restart for a clean undo.
A stat-only weapon needs no ct_project: the bake does not read weapons[] and reports that there is
nothing to bake. Its complete authoring path is the two manifests, the DLL built directly into the
mod folder, and ct_package:
The packager accepts that manifest-only content payload and stages the DLL. Start a new campaign
when testing count/clips.
For a model-backed weapon, rebuild the DLL after code changes, rerun ct_project after model or
publish changes, then:
The packager picks up the DLL named by AssemblyName but does not compile it. Install the staged
folder as a player and repeat the test after a cold start. During authoring, follow the shared
build-to-mod-folder and restart loop.
Equip the weapon and verify inventory icon, hold pose, muzzle position, aim, firing, overwatch,
reload, holster, shell effect, damage type, ammunition behavior, projectile colour (tint) and
beam length (trail). Check Player.log for (own copy) vs (shared) on the projectile line
to confirm private copies when using tint or trail.
Limits¶
- Cloning does not create research, manufacturing or localization beyond fields the builder writes.
- Existing saves do not receive starting-storage additions.
- A model does not bring actor hold/firing animations; the donor's
EquipmentListDefmembership selects them. - Auto-fit is a bounding-box fit, not semantic weapon setup. Inspect all sockets in live firing.
Override with
scale(uniform) androtate("x,y,z"euler degrees); useoffset("x,y,z"metres) to move the grip without discarding the solve. - Damage keywords and damage type defs must already exist and be found by exact def name.
- Weapon GUID checks inside one manifest do not protect against another mod. If two mods use the
same weapon
guid, the first enabled mod wins; the second weapon is never created, so its stats, name, and icon never apply. The only sign is the success-looking linect_weapon PASS '<id>' already built this session. On a cold first enable, that line means a cross-mod collision. Generate a genuinely random UUID and vary it somewhere other than the first character, because the view and skin identities replace that character withaandb.