The standard every CSS outfit package should follow, so that a wardrobe behaves the same across every port and a player who has learned one has learned all of them.
Colour shipped in CSS 0.4.0. 1.0 widened it: a package declares controls, and
colour is one kind of control alongside switches, texture choices, material scalars and
live springs. The tab is called CUSTOMIZE, and the manifest block is customize.
Renamed in 1.0. The block used to be
colorsand this file used to becolor-convention.md. Nothing published breaks: the runtime and the authoring tools readcolorswhenevercustomizeis absent, and the old request actionscolorandreset_colorstill work. Naming both in one place is refused rather than guessed at.
colors.md stays the reference for how the dye system works: masks, surfaces, bindings, render targets. This document is about what a package should declare and why. Read that one for the mechanism, this one before authoring or porting.
The ports were built one at a time and drifted. A survey of every package installed on the development machine:
| package | controls | palettes |
|---|---|---|
| Seductress V2.1 | silk, trim, cloth, metal, skin, hair, glow, glow-strength, eyes, eye-glow |
4 |
| Seductress V2.0.2, V1 | cloth, metal, skin, face, eyes, eye-glow |
4 |
| HIT2 DE Scyther | clothing, metal, ribbons, gems, skin, face, hair |
2 |
| Beaute Genessa | clothing, metal, skin, face, eye-glow, eye-intensity |
2 |
| Beaute KnightLady | armor, metal, cloth |
2 |
| BHProxima, CurvyAndCute, LongHairProxima, MileenaOverTiel | none | none |
Three problems, all of them avoidable:
- The same thing is called
cloth,clothing,armorandsilkin four packages, and the same glow idea iseye-glow,eye-intensityandglow-strength. - Most packages ship only two palettes, named for the color rather than the look
(
crimson,midnight), and nothing tells the player which parts move together. - Four packages have no color support at all, so the whole tab is dead for them.
Three layers, each one on top of the last:
final = group tint ( player override or palette value )
- Palette sets every control. It is always the base.
- Player override replaces one control's color. This is a custom color.
- Group tint is a hue, saturation and brightness adjustment applied to a whole group.
The rules that keep this predictable:
- Choosing a palette takes over the parts it sets and the tint of the groups those
parts are in, and leaves everything else alone. Changing the dress does not undo a
custom skin.
originalis the exception: it means no dye at all, so nothing survives it. - Reset part drops one override. Reset all returns to the bare palette.
- Group tint applies to overrides too, so the pipeline above is the entire story.
- A control marked
hue_lockedignores the group's hue but still takes its saturation and brightness. - Group tint needs a palette. On
originalCSS applies no dye and never learns what the author's colors are, so there is nothing to shift; the tint rows are hidden there.
Rule 4 is what makes group tint usable. Metal, skin and gems are hue-locked by default, so dragging the Outfit hue from crimson to teal recolors the fabric and leaves gold as gold and skin as skin. Without it a group hue shift produces green "gold" and blue skin.
{
"id": "garment",
"name": "Garment",
"group": "outfit",
"role": "garment",
"kind": "color",
"hue_locked": false,
"default": [0.56, 0.14, 0.24, 1]
}| field | values | notes |
|---|---|---|
id |
stable string | never shown to the player; keep it stable across versions |
name |
"Garment" |
shown in the menu. Sentence case, a noun a player recognises |
group |
outfit | body |
chooses the section and which tint row drives it |
role |
see below | the shared vocabulary; drives defaults and palette portability |
kind |
see below | what the control is; everything but color is a single number |
hue_locked |
bool | defaults from role; set it explicitly when the default is wrong |
A color control can supply an ordered swatches list instead of the generated
strip. Provide 2 to 24 entries with distinct names (1 to 32 UTF-8 bytes), RGBA
colors within the control's limits, and the same alpha as its default value.
The first entry must have reset: true. All subsequent entries omit reset
or set it to false.
"swatches": [
{"name": "Default", "color": [0.8, 0.7, 0.6, 1], "reset": true},
{"name": "Warm tan", "color": [0.6, 0.4, 0.3, 1]}
]The first chip's color is a preview only. Selecting it removes that part's override, restoring its selected palette value, or its original texture when the palette does not own the part. It never paints the preview RGB onto the original texture. Group tint continues to apply to palette values. Other chips set the chosen RGB before group tint; cycling uses that stored value so tinting cannot make the selector skip or get stuck. Mouse and left/right controls share the same order, including the reset chip. Exact RGB editing remains available.
Omitting swatches preserves the generated strip for existing packages. The list
is optional author metadata, not a list of colors to apply on installation.
Colour is one kind of control, not the only one. A control declares which it is, and the menu, the saved look and the apply path all follow from that.
kind |
edited as | drives | notes |
|---|---|---|---|
color |
three channels | a dye layer | the default when kind is absent |
intensity |
one number | a named material scalar | a strength, like eye glow |
scalar |
one number | a named material scalar | anything else, like gloss or roughness |
toggle |
on or off | section visibility or material scalar | needs sections, bindings, or both; takes no min/max/step |
choice |
which option | a texture parameter | needs options and a binding, takes no min/max/step |
spring |
two/three numbers | the mesh's own spring bones | Bounce Hz, Settle %, optional Travel cm and explicit axis filters |
dynamics |
three numbers | post-process AnimDynamics chain roots | Stiffness, Damping, Gravity; experimental integration |
shape |
one number | a morph target on the package's own mesh | needs morph, optional joint formulas |
glow |
radiance & pulse | emissive material parameters | intensity cd/m², pulse Hz, combat reactivity |
opacity |
one number (0..1) | alpha / sheerness parameter | sheer fabrics, lace, stockings, chiffon |
A toggle lists the material sections it shows and hides, and needs no binding because
it drives them directly:
{"id": "hood", "name": "Hood", "kind": "toggle", "role": "piece",
"default": [1, 0, 0, 1], "sections": [2, 3]}CSS beta.8 also accepts material toggles and up to 64 controls per variant. Beta.7 has a 32-control limit and requires toggle sections; packages using these additions require a newer CSS build.
A material toggle writes exactly 0 or 1 to a scalar parameter. This can hide sheer fabric without hiding the metal underneath the same mesh section:
{"id": "fabric_visible", "name": "Sheer fabric", "kind": "toggle", "role": "piece",
"default": [1, 0, 0, 1],
"bindings": [{"slot": 12, "surface": "overlay", "parameter": "FabricVisible"}]}The shader must use that parameter to multiply fabric opacity. Use a separate parameter for an opacity slider so toggling visibility preserves its chosen value. The shader's authored defaults must match the declared control defaults. Original/reset restores the authored material. Setting opacity to zero does not remove the draw or disable cloth simulation. Native overlay effects still take precedence and the retained fabric settings return afterward.
Material toggles may also declare sections, but occludes_sections requires
nonempty sections. Empty section arrays and controls without any destination
are invalid. Existing section-only toggles retain their behavior.
sections and occludes_sections contain global mesh material-slot indices, not
render-section indices. UE can split one material into several render sections or
remap section materials at a LOD. Native calls use MaterialID=slot and
SectionIndex=INDEX_NONE for the player and menu components, including restoration.
This bypasses the section map while preserving the intended material ID. The pinned
UE 5.6.1 implementation is SkinnedMeshComponent.cpp:3731; a real component probe
with map [1, 0] confirms that the old (0, 0) call hides material 1 while
(0, -1) hides material 0. SeduXtress V30's four wardrobe states and reset also pass
component read-back. This is editor evidence; fresh game verification is pending.
The development control catalog lives at tests/fixtures/dev-control-kinds.css.json.
It is a test fixture and is excluded from the installer's catalog/*.css.json glob.
Optional occludes_sections lists body or underlayer sections covered while the toggle
is on. For example, footwear section 20 covers body feet 21 and stocking feet 22:
{"id": "boots", "name": "Footwear", "kind": "toggle", "role": "piece",
"default": [1, 0, 0, 1], "sections": [20], "occludes_sections": [21, 22]}Both arrays contain integer material indices from 0 to 127. An authored array cannot be empty. Covered indices must be unique and distinct from that toggle's own sections. Different controls may cover the same section. The runtime combines all hidden sections with item masks, so removing one garment restores a section only when no other active mask needs it hidden. A stocking toggle can therefore own section 22 while footwear covers it. Defaults also apply to Original and after clearing saved overrides. This changes visibility only; it does not reshape or delete body geometry. Host tests cover these decisions; live acceptance of the new occlusion field is pending.
A choice picks between textures the package ships. The value is which option, so the
range is the list and nothing else, and the default names one of them:
{"id": "pattern", "name": "Pattern", "kind": "choice", "role": "pattern",
"default": [1, 0, 0, 1],
"options": [{"name": "Plain", "texture": "/Game/CSS/<id>/T_Plain.T_Plain"},
{"name": "Lace", "texture": "/Game/CSS/<id>/T_Lace.T_Lace"}],
"bindings": [{"slot": 0, "parameter": "BaseColorMap non VT"}]}Between two and sixteen options. Each texture is a full object path, with the object
name after the dot, and has to be cooked into this package's own container. A choice
writes into a texture parameter, so unlike a toggle it does need a binding.
A shape drives a morph target, so a slider changes the geometry rather than a material:
{"id": "hips", "name": "Hips", "kind": "shape", "group": "body", "role": "figure",
"morph": "Hips", "min": 0, "max": 1, "step": 0.05, "default": [0, 0, 0, 1]}Authored meshes only, and this will never change. Every stock shell carries zero morph
targets: Sester Genessa V6, Shell KnightLady V04 and Tiel all read back an empty
MorphTargets array from the running game. A shape slider can only ever move a mesh CSS
cooked itself, where the shape came in from a Blender shape key. There is no path to a body
slider on an unmodded shell, and a package must not promise one.
The chain is: a Blender shape key, exported by tools/authoring/export_css_mesh.py into the
mesh JSON's morph_targets, imported by the CSSImportMesh commandlet, cooked, and named
here in morph. The name has to survive all of that, so it is letters, digits and
underscores only, at most 64 characters, and the exporter and the importer both refuse
anything else rather than letting the engine silently rename it.
min, max, step and default work exactly as they do for a scalar. A range that goes
negative is fine if the author cooked the shape to read that way.
A shape only works on a mesh bound to the game's skeleton. CSS refuses to wear a mesh
whose Skeleton asset differs from the one already worn, and CSSImportMesh always
creates an authoring stub. CSSBindClothV3 -SkeletonOnly -Mesh=... -Skeleton=/Game/Sparta/ Characters/Humans/_Shared/SKEL_Human_Skeleton is the step that assigns the real one, and
it refuses a mesh whose bones the skeleton does not have. That is the real reason the stub
must never ship: a mesh still bound to it cannot be worn at all, shapes or no shapes.
Check the morph exists before shipping. At runtime a control CSS cannot apply takes the
whole outfit off, which is the right call for a half-applied look and a miserable way to
find a typo. check_shapes(recipe, shape_names(mesh_json)) in tools/css_controls.py
compares the recipe against the mesh JSON and needs no editor. CSSInspectMesh reads a
saved asset back and reports every morph target with the number of vertices it moves and
its largest delta, which is the check to run after an import.
A spring tunes live secondary motion: how a bust, a belly or a hip moves when the
character does. It is the only control with two numbers in it, and the only one that
writes no material at all.
{"id": "bust", "name": "Bust", "kind": "spring", "group": "body", "role": "figure",
"nodes": ["brust001", "brust002"],
"frequency": {"min": 1.2, "max": 2.6, "default": 1.5915},
"damping_ratio": {"min": 0.4, "max": 0.95, "default": 0.65}}nodes names the bones, not the blueprint properties. A blueprint names its spring nodes
AnimGraphNode_SpringBone, _1, _2 and so on in compile order, and recompiling can
shuffle that; the bone a node drives does not move. Between one and thirty-two bones, each
spelled as the cooked skeleton spells it.
Frequency and damping ratio, not stiffness and damping. FAnimNode_SpringBone
integrates a = K*error - D*velocity at a fixed 1/120 s with no mass term, so the system
is x'' + D x' + K x = 0, K = (2*pi*f)^2 and D = 4*pi*zeta*f. CSS does that conversion,
so an author states what the part should do rather than which numbers happen to produce it,
and the player gets a slider in hertz instead of a slider in engine units. The menu calls
them Bounce and Settle.
Both defaults have to be what the animation blueprint already ships, or the menu opens on a
value the body is not at. spring_defaults(stiffness, damping) in tools/css_colors.py
converts the blueprint's own numbers, so copy what it prints rather than guessing.
Frequency tops out at 8 Hz and damping ratio at 2, and a range whose stiff-and-damped corner would cross the engine's own damping cutoff is refused: past that the engine scales damping down instead of using what it was given, and the slider would stop meaning what it says.
A spring is live and reversible. CSS records what the blueprint held the first time it touches a node and puts that back when the control is dropped or the outfit is removed, so nothing needs a mesh reload to undo.
translate and rotate are optional three-boolean arrays. Omitted arrays retain the
captured blueprint flags, including when a travel clamp is present. CSS does not infer
axis settings from names such as brust, butt, hip or thigh. Explicit rotate
flags can enable or disable the corresponding SpringBone rotation filters.
planar_constraint accepts none, x, y or z. It locks translation on the named
axis after explicit translation overrides, so translate: [true,true,true] cannot
bypass planar_constraint: "y". The other translation and rotation flags retain their
authored or explicitly overridden values. These SpringBone translation filters act
in world space in UE 5.6.1; they are not a character-relative anatomical collision
plane. A travel clamp bounds displacement but does not guarantee garment clearance.
world_damping, limit_angle, collision_radius and gravity_scale are reserved
metadata from the earlier design. Their presence and finite numeric ranges are checked
by the package and native parsers. Omission is retained separately from an explicit
zero. The current SpringBone runtime adapter does not apply them. Do not describe
these accepted fields as active collision, cone limits, gravity or Kawaii Physics.
The separate AnimDynamics experiment and remaining native adapter are tracked in
the dynamics investigation.
Packages written before this said "type": "scalar" and meant a strength, so that reads
as intensity, not as the new generic scalar. Nothing published changes meaning.
Presets tune CSS's own motion, so CSS ships the built-in set (1.0.0-beta.5):
Firm, Natural, Bouncy, Soft / Saggy and OMG! Earthquake! for a body part, and
Firm, Natural, Silky, Heavy and Floaty for a positional_hair rig. A body rig
gets the values for the region its regions bones name (brust* chest,
butt* glute, thigh* thigh, belly); a spring gets them from its nodes.
Only a part that declares neither falls back to words in its id or name.
AnimDynamics controls get no built-ins.
A spring, rig or dynamics control may add its own presets. They follow the built-ins in the part's Preset selector:
"presets": [
{ "id": "studio", "name": "Studio", "description": "Tuned for this mesh", "value": [3, 0.9, 0.5] }
]value is the control's first three channels in its own units (a body rig's
frequency, damping ratio and motion amount; a spring's frequency, damping ratio
and travel) and must sit inside the control's ranges. Up to sixteen presets;
name up to 48 characters, description up to 160 and optional. An id must be
unique on the control and may not reuse a built-in id. Applying a preset leaves
a rig's motion switch alone.
The native branch now accepts kind: "dynamics" for UE's built-in AnimDynamics
nodes. This path has offline parser, persistence and build checks. It is not yet
accepted for distribution: the reset impulse described in the
dynamics investigation
still needs synchronization with actual animation evaluation and live verification.
{
"id": "ponytail_dynamics",
"name": "Ponytail dynamics",
"kind": "dynamics",
"group": "body",
"role": "motion",
"nodes": ["CSS_Hair_Ponytail_01"],
"angular_spring": {"min": 0, "max": 200, "default": 80},
"damping": {"min": 0.7, "max": 1, "default": 0.8},
"gravity": {"min": -1, "max": 1, "default": 0.1}
}nodes names each solver's root bone, once, not every member of an articulated
chain. Each root has one control owner. CSS resolves roots on the active post-process
instance and rejects missing or ambiguous roots before applying a control. There are
at most 32 roots per control and 32 discovered solver nodes per instance.
The three saved channels contain direct solver values, in order:
| Channel | Field and supported range | Applied engine settings |
|---|---|---|
| 0 | angular_spring, 0..1000 |
AngularSpringConstant; zero disables bAngularSpring |
| 1 | damping, 0.7..1 |
Both damping overrides, with both override flags enabled |
| 2 | gravity, -5..5 |
GravityScale, with gravity override disabled |
| 3 | Reserved, always 1 | No engine write |
Author ranges may be narrower; defaults must match the cooked graph. The menu uses steps of 1, 0.01 and 0.05, capped by each range's width. Damping here is a solver override, not a SpringBone damping ratio; the engine documents a 0.7 floor. UE 5.6 AnimDynamics reference.
This kind does not accept material bindings, SpringBone ranges or the reserved SpringBone extension fields. Authored constraints, collision shapes, simulation space, component acceleration and angular targets stay in the graph. Runtime collision tuning is still unfinished.
Profiles and palettes can retain values above 32 and negative gravity. Unbound snapshots receive broad finite bounds first; installed control schemas then enforce the actual channel ranges. Existing color/scalar/spring bounds remain unchanged. An incompatible value is dropped during variant compatibility filtering.
The adapter captures the four numeric settings and four associated flags on first
touch, reads back writes, and restores the captured values on Original reset, removal
or shutdown. It requests ResetDynamics(ResetPhysics) when damping values/override
flags or the gravity-override mode change, because those are copied into solver
bodies during initialization. Angular spring settings and gravity scale update
during evaluation and do not require a reset. The reset enum is discovered
from the function's reflected parameter, and its signature is checked before any
node mutation. A successful reflected write/reset request alone does not establish
the solver's evaluated behavior.
A variant may list items instead of a single mesh. Exactly one item takes the body
slot and replaces the character mesh, which is what every package published before 1.0
does, so those load unchanged as one-item packages. The rest are accessories: their own
skeletal mesh components, attached to the body and posed by it.
"items": [
{"id": "body", "name": "Body", "slot": "body", "mesh": "/Game/CSS/<id>/SK_Body"},
{"id": "collar", "name": "Bunny collar", "slot": "neck", "order": 20,
"mesh": "/Game/CSS/<id>/SK_Collar", "hides": {"sections": [3, 4]}}
]Sixteen slots: body, head, hair, face, ears, neck, chest, back, hands,
waist, legs, feet, and trinket1 to trinket4 for pieces with no natural home. One
item per slot: two items claiming the same one is refused rather than silently stacked.
order layers them, low first.
hides.sections names body material sections the item covers, so a boot can stop a foot
poking through. It shares the bookkeeping a toggle uses, so taking the outfit off puts
back exactly what CSS hid and nothing the game hid itself.
An accessory mesh needs a real skeleton. It is posed by the body through a leader pose, so its bones have to be the body's bones. A mesh with a token skeleton will attach, report itself visible, sit at the right place and render nothing at all.
Use an existing role wherever the part reasonably fits. Adding a role is fine when nothing fits, but say so in the package notes.
| role | group | hue_locked default |
typical part |
|---|---|---|---|
garment |
outfit | no | the main fabric or leather |
accent |
outfit | no | trim, lining, ribbons, a secondary fabric |
leather |
outfit | no | straps and belts, when they are their own part |
fabric |
outfit | no | capes, cloaks, scarves, skirts, sashes, sheer lace |
headwear |
outfit | no | hats, crowns, tiaras, hairpins, veils |
jewelry |
outfit | yes | necklaces, chokers, earrings, rings, bracelets |
metal |
outfit | yes | jewellery, buckles, plate, filigree |
gem |
outfit | yes | stones and crystals |
glow |
outfit | no | emissive runes and trim |
skin |
body | yes | bare skin |
face |
body | no | a mask, veil, face paint or makeup |
hair |
body | no | hair, ponytails, bangs, braids |
eyes |
body | no | the iris |
eye-glow |
body | no | the emissive part of the eyes; usually kind: intensity |
breast |
body | no | breast & bust shape, volume, or jiggle motion |
butt |
body | no | buttocks & glute projection, shape, or jiggle motion |
thigh |
body | no | hips, upper thighs, inner thigh tissue |
waist |
body | no | waist curve, belly volume |
nipple |
body | yes | the nipple itself |
areola |
body | yes | the pigmented ring around it |
labia |
body | yes | the outer and inner lips |
vestibule |
body | yes | the inner surface between the inner lips |
clitoris |
body | yes | the clitoris and hood |
orifice |
body | yes | vaginal opening/depth, anal opening/depth |
body-hair |
body | no | pubic and body hair, separate from the head |
gloss |
outfit | no | a sheen or roughness slider on the outfit |
roughness |
outfit | no | surface roughness, when it is its own control |
opacity |
outfit | no | how sheer a garment is |
piece |
outfit | no | a part of the outfit a toggle shows or hides |
pattern |
outfit | no | which of several textures a garment wears |
skin-gloss |
body | no | the body's own sheen, sweat, or oil |
figure |
body | no | a spring on a part of the figure: bust, hips, belly |
motion |
body | no | a spring on something else that moves, like hair or a cloak |
Hue-locking metal, gems and skin by default is deliberate: those three read as a material rather than as a color, and rotating their hue is what makes a recolor look broken.
Many shells have variants that show the body, and those parts want their own controls: a player who can tint skin but not areolae ends up with a mismatch that is more obvious than having no control at all.
The intimate pigments are hue-locked with skin on purpose. They are a shade of the body,
not a separate color, so a body hue shift that left them behind would look worse than one
that moved them. A player who wants them pinker or darker sets that control directly,
which is the common case anyway. Pubic hair takes body-hair and follows the hair, since
it is normally the same color as the head.
Declare these only on the variants that show them. CSS supports a per-variant customize
recipe (colors.md), so a gowned variant should not list a control for a part
nobody can see. The masks are small, so follow the disjoint-mask rule closely: an areola
mask that bleeds into the breast will tint a patch of skin every time it is used.
originalis reserved and always present. It is not a palette a package declares: it means CSS applies no dye at all and the author's own materials and textures are used exactly as shipped. A palette that merely reproduced those colors would be a composite approximation of them, which is worse, sooriginalstays outside the palette list and first in the menu.- At least two palettes beyond
original. - Every palette sets every outfit control. A palette is a whole look for the dress, so it should never leave one strap behind from the palette before it.
- Body controls are optional in a palette, and usually left out. Skin, eyes and the intimate pigments belong to the shell the player chose, not to the look of the dress, and rule 1 keeps whatever they set. A control the palette leaves out is simply not dyed, so it shows the author's own colour: well defined, and the same every time. A palette that does mean to change the body (a drowned look, an ashen one) should set those controls and say so in its name.
- Palette names are for players, not authors:
"Midnight silver", not"midnight2".
"palettes": [
{"id": "crimson-vow", "name": "Crimson vow", "values": { "...": [] }},
{"id": "midnight-silver", "name": "Midnight silver", "values": { "...": [] }}
]Declaring a palette with the id original is refused, for the reason above.
group, role and hue_locked are optional. CSS infers them from the control id when
they are missing, so every package already published keeps working untouched:
| id contains | inferred role | group | hue locked |
|---|---|---|---|
cloth, clothing, armor, silk, garment, dress |
garment |
outfit | no |
trim, ribbon, accent, lining |
accent |
outfit | no |
metal |
metal |
outfit | yes |
gem, jewel, crystal |
gem |
outfit | yes |
skin |
skin |
body | yes |
face, mask |
face |
body | no |
hair |
hair |
body | no |
eye-glow, eye-intensity |
eye-glow |
body | no |
eyes |
eyes |
body | no |
glow, glow-strength |
glow |
outfit | no |
| anything else | garment |
outfit | no |
Inference is a compatibility measure, not a substitute. A package updated to this convention declares all three.
tools/css_colors.py has two functions, and they answer different questions.
validate(colors)decides whether CSS can load the recipe safely. It stays permissive about the convention on purpose, so that every package published before this document keeps working. It runs at package and verify time.lint_convention(colors)decides whether the recipe is a good one: roles declared and from the shared vocabulary, groups and hue locking consistent with the role, two or more palettes, every palette covering the outfit. Call it from the package's own builder and fail the build on it, which is whatseductressV2-eins0fx-CSS/v202/tools/build_colors_v2.pydoes.
A control for something the shell does not have is worse than no control: the player
drags a slider and nothing moves. Seductress V2 ships no hair control because the
shell wears a hood and has no hair surface to dye. Declare what the mesh actually has.
The same goes the other way for variants. CSS takes a customize recipe per variant, so a
gowned variant should carry the plain recipe and a bare one the recipe with the intimate
controls in it, rather than every variant offering every control.
- The block is called
customize, and every adjustable part has a control with a player-facingname, akind, agroupand arole. hue_lockedis correct for each colour: metals, gems and skin locked; fabric and hair free. It means nothing to the other kinds and they leave it out.- Two or more palettes, each setting every outfit control.
originalis not one of them: CSS provides it, and it already restores the source mod exactly. - Masks are disjoint where parts must stay independent, per colors.md.
- A
togglenames real material sections, achoicenames cooked textures inside this package's own container, aspringnames bones the cooked skeleton actually has, and ashapenames a morph target the cooked mesh actually carries. - A
spring's two defaults match what the animation blueprint ships, read withspring_defaults()rather than guessed. - Verified in game: choose each palette, drag each group tint end to end, flip every toggle, and move every slider to both ends. Nothing turns an impossible colour, nothing disappears that should not, and nothing keeps moving after you stop.
CSS 2.0 cleanly distinguishes between subsystem Templates and character Profiles:
Templates live inside outfit packages (outfits[].templates) and are author-curated presets for specific systems:
combinations: Multi-toggle & part combinations (e.g. "Topless Harness Set", "Battle-Damaged Gown", "Bikini Armor").palettes: Material & dye color palettes (e.g. "Crimson Vow", "Void Obsidian").archetypes: Body morph combinations (e.g. "Voluptuous", "Petite Seductress", "Athletic Amazon").physics: Jiggle & secondary dynamics presets (e.g. "Firm Athletic", "Sensual Bouncy", "Ultra Soft").hair: Hair style, ponytail sway, and Kawaii physics presets.accessories: Accessory sets and trinket toggles.fabrics: Sheerness and fabric opacity presets.anatomy: Intimate morphs, breast shape, glute curvature, and orifice depth presets.glow: Rune radiance, breathing pulse rate, and combat surge configs.
In Tab 1 (CUSTOMIZE), Row 0 provides the Template Selector:
- Shows current active template name and category subtitle.
- Left / Right cycles through all available templates and palettes.
- The right-side panel displays direct preset buttons and a "Browse templates..." button opening the Searchable Browse Modal.
The 4th Tab is PROFILE (renamed from TEMPLATES):
- Saves a global character snapshot spanning all tabs: worn outfit shell, variant, customized colors, tints, morph shapes, physics tuning, walk style, and toggles.
- Save new profiles with suggested naming or custom names.
- Load profiles with 1 click.
- Overwrite and delete actions are protected by Native Confirmation Dialog Modals.
Inspired by Stellar Blade and Better Jiggle Mod (Nexus 1570):
-
Dynamic Frequency & Damping Integration:
- Bounce frequency in Hz (stiffness
$K = (2\pi f)^2$ ) - Settle damping ratio (decay
$D = 4\pi \zeta f$ )
- Bounce frequency in Hz (stiffness
-
Travel Clamping (
max_displacement):- Restricts maximum displacement (cm) via
bLimitDisplacementandMaxDisplacement. Collision clearance still requires evaluation against the actual outfit.
- Restricts maximum displacement (cm) via
-
3-Axis Rotational Swing (
bRotateX/Y/Z):- Explicit
rotateflags control the node's rotation filters. Absent flags preserve the animation blueprint's values. The engine skips SpringBone evaluation when all translation axes are disabled, even if rotation flags are enabled.
- Explicit
-
Planar & Lateral Constraints (
planar_constraint):- Locks the named world-space translation axis after explicit translation settings. This does not implement body collision.
-
Reserved dynamics metadata, not applied by SpringBone:
-
world_damping: 0..1. -
limit_angle: 0..180 degrees. -
collision_radius: 0..100 cm. -
gravity_scale: -5..5. - Zero and omission are distinct. Solver-specific mappings and active collision are still under development.
-
- Sliders:
- HSL Tint Sliders (Hue -180..180°, Saturation 0..200%, Brightness 0..200%)
- Discrete RGB Channel Sliders (0..255 per channel in exact color mode)
- Opacity / Sheerness Slider (0..100%)
- Secondary Physics Sliders (Bounce Hz, Settle %, Travel cm)
- Morph Weight Sliders (0..100% or min..max)
- Glow Radiance & Pulse Sliders (Intensity cd/m², Pulse Hz)
- Color Swatches:
- 24-chip deterministic swatch strip for fast 1-click color palette application.
- Searchable Browse Modal (
native_picker_):- Fullscreen/modal searchable list with live text query filtering, mouse-wheel scrolling, keyboard/gamepad navigation, and apply/cancel routing.
- Used for
ui_browse_shells(all installed outfits & authors),ui_browse_templates(all combinations, palettes, archetypes, and physics presets), andui_browse_choice(multi-texture choices).
- Confirmation Modals (
confirm_action_):- Darkened backdrop modal dialog for destructive actions:
- Reset all customizations confirmation
- Delete profile confirmation
- Overwrite profile confirmation
- Keyboard/gamepad focus trap (Accept / Cancel) with camera motion suspended.
- Darkened backdrop modal dialog for destructive actions:
kind: "rig" addresses the outfit's post-process AnimBP inputs. It is separate from SpringBone frequency/ratio controls and AnimDynamics angular/damping controls. solver defaults to "positional_hair" for existing packages. One hair control may coexist with independent angular-body controls described below. Rig controls accept no node names or arbitrary property bindings.
{
"id": "hair_motion", "name": "Hair motion", "kind": "rig", "group": "body", "role": "motion",
"stiffness": {"min": 100, "max": 250, "default": 150},
"damping": {"min": 12, "max": 24, "default": 18},
"gravity": {"min": 0, "max": 0.2, "default": 0},
"enabled": true
}Saved values use [stiffness, damping, gravityScale, enabled]; enabled must be numeric zero or one. Schema limits are stiffness 1..1000, damping 0..120 and gravity scale -5..5. Authors must choose and verify narrower ranges for their mesh. These schema bounds do not certify visual behavior at every combination. Stiffness and damping are the solver's direct positional-force and exponential-velocity-decay coefficients. Gravity scale maps to world acceleration (0,0,-980*scale) in cm/s². A zero default preserves the measured rig's authored rest-following response.
The native adapter requires a real AnimInstance under /Game/CSS/, float CSSStiffness and CSSDamping, native double-precision FVector CSSGravity, bool CSSEnabled, and int32 CSSResetEpoch. It validates reflected names, scalar counts, exact property types and storage bounds before writing. It never touches a class default/archetype or scans objects each frame. Settings are captured once on the owning weak instance, restored when the control is removed, and reapplied after an instance replacement. Menu preview synchronization resolves and writes only when settings or the preview instance change.
The AnimBP forwards the first four inputs to the saved ControlRig. Its native animation update advances CSSUpdateSerial; repeated evaluation with the same serial reuses output. CSSResetEpoch reseeds on the next enabled evaluation. Disable passes through, and re-enable seeds the current incoming pose. Calling engine ResetDynamics alone does not reseed this rig; use the CSS epoch bridge. Tuning changes preserve the running clock and take effect on the next evaluation.
Validation: 283 portable control behavior checks, 54 Python package/control tests, and the Windows core build pass. The saved full-hair tuning AnimBP passes 216 component checks; its default output exactly matches the previous V10 wrapper at sampled positions. Native reflection writes, preview restoration, in-game UI, profile reapplication after travel/death and game performance still require live verification with the new candidate. CSSX is not required and remains disabled.
solver: "angular_body" selects independent region tuning on the same post-process AnimBP. Each control owns one or more distinct regions. Two body controls cannot own the same region, and a legacy SpringBone or AnimDynamics control cannot also drive that region.
{
"id": "chest_motion", "name": "Chest motion", "kind": "rig",
"solver": "angular_body", "group": "body", "role": "figure",
"regions": ["brust001", "brust002"],
"frequency": {"min": 0.5, "max": 6, "default": 2},
"damping_ratio": {"min": 0.1, "max": 2, "default": 0.7},
"motion_amount": {"min": 0, "max": 1, "default": 1},
"enabled": true
}Saved values are [frequencyHz, dampingRatio, motionAmount, enabled], with numeric zero or one for enabled. Schema bounds are 0.5..6 Hz, 0.1..2 damping ratio and 0..1 motion amount. Slider steps are 0.1, 0.05 and 0.05, bounded by the declared range width. These bounds are validation limits, not acceptance of all combinations on every outfit. Body controls reject hair stiffness, damping and gravity fields. Hair controls reject body fields. Unknown solvers are rejected.
Canonical region order is brust001, brust002, butt001, butt002, thigh_twist_02_l, thigh_twist_02_r, belly. The AnimBP must expose seven-entry float arrays CSSBodyRegionFrequencies, CSSBodyRegionDampingRatios, CSSBodyRegionMotionAmounts, a seven-entry bool array CSSBodyRegionEnabled, bool CSSBodyUseRegionSettings, float globals CSSBodyFrequency, CSSBodyDampingRatio, CSSBodyMotionAmount, and int32 CSSBodyResetEpoch.
The native adapter validates the real CSS AnimInstance, reflected types, bounds, array storage and finite values before writing. It captures player and preview defaults separately. Active body controls are composed from the captured player defaults, preserving untargeted regions. If authored region mode is off, global values initialize all seven regions before overrides enable region mode. Removing controls restores captured inputs; replacing an instance invalidates captured state. Preview synchronization writes the owned arrays and switch, leaving its global defaults intact for later restoration.
Changed body inputs advance only CSSBodyResetEpoch. Hair retains CSSResetEpoch. General dynamics reset advances both, while unchanged body input values perform no write or reset. No body solver or object scan is added to a per-frame native hook. The UI uses Frequency (Hz), Damping ratio, Motion amount and Motion on/off. Profiles use the existing four-channel saved-control representation.
Current evidence: Windows core compilation, 307 portable control checks and 16 metadata tests pass. The actual combined editor component passes 23 fixtures and 37542 checks, including independent reset isolation. This does not verify the UE4SS reflection adapter in the game. Native UI interaction, preview/profile recovery, cooked execution and live performance remain required. See the body-region checkpoint.