HeelAdjust3

Plugins + Scripts HeelAdjust3

Download [3.9 MB]
This is a remake of HeelAdjust2 by @CheesyFX based on Hub version 21. A huge thank you to all the authors who laid the foundation for this work.
I decided to rework this plugin in an attempt to fix many of the flaws I consider critical. This version is almost compatible and can serve as a full replacement.
All your ready-made presets work exactly the same, the collider mechanics, geometry, preset file format, are unchanged. The sound bundle is the same.

Before version 27 the presets are located in the same location and scene data blob unchanged. To achieve this, I left the script class name unchanged.
Since version 27 I changed it making less compatible with the original.

Main changes compared to the original:
1) Foot slope compensation calculation mechanics (deltas) - the controller value is taken into account; the application threshold value is independent of shoe tilt; auto compensation is disabled by default.
2) Footstep sound - sound delay has been eliminated, and the registration of sound-producing atoms has been changed internally.
3) Person height adjustment - the value is saved in the scene.
4) Restoring preset data from the scene data - now it works.
5) Removed support for legacy presets.

What's new:
1) Added a reference preset type for shoes with right and left parts (see video example).
2) Added ability to automatically remove the ShoeHelper script from a shoe with a HeelAdjust preset created.
3) Added ability to adjust person height through CLS (if attached).
4) Packaged scene presets feature.
5) Added a scene/session mode of the script.

Since version 27:
HeelAdjust3 ships as a single .dll that works in two modes depending on where you add it. It is unusual to see a .dll here but it implies the same security restriction as .cslist does.
HAS (HeelAdjust3 as a Scene/Session Plugin):
* One plugin can manage all persons from a single panel.
* Adds a Person chooser to the UI: manage every person from one place.
* Per-person "Enabled" toggle persisted with the scene.
* Outlives person plugin removal: the settings host lives on the atom.
HAP (HeelAdjust3 on a Person atom):
* One plugin instance per person; all preset editing happens through that person's panel.
* Only required when you need to edit a shoe preset - foot slope, collider geometry and linked items list.

Why the session concept?
VaM has a huge lack: no built-in high-heel support. We have many heeled shoes but VaM does not bend feet for them. The HAS plugin aims to eliminate that flaw. Install HAS once, add a character to the scene, and HAS manages all your persons automatically. Once you have a preset for every shoe, HAS handles everything in the background. Preset setup must be done in the person mode beforehand.
HAP is for creating and editing presets for a particular shoe. That is the only feature HAS does not have directly - you need HAP to build a new preset or adjust an existing one.
However, if you already have scenes with HeelAdjust instances on your persons and want to switch to the session concept, that requires manual work across all your scenes. The migration tool (see below) handles this for you.
Switching to the session concept is a serious decision: reverting requires some effort (though easy with the migration script). Therefore, always test first on separate scenes, especially scenes from other users' packages where you do not have full control. Pay particular attention to how the plugin interacts with pose presets, as the plugin hooks into that loading order. HAS only acts when the person wears shoes that have a matching preset. You can temporarily disable HAS before loading a scene from the Session Plugins panel. If anything is not working as expected, or if you find bugs or limitations, please report it in the discussion thread.

Installation
Session (HAS):
Open Main UI -> Session Plugins Tab -> Add Plugin
Browse to Nickon7777.HeelAdjust3.27 or higher
Choose HeelAdjust3.dll
To save as session default: Session Plugin Presets Tab -> Change User Defaults -> Set Current As User Defaults
From this moment HAS will launch on every VaM launch.
Person (HAP):
Select Person (N key) -> Plugins Tab -> Add Plugin
Browse to Nickon7777.HeelAdjust3.27 or higher
Choose HeelAdjust3.dll
Multiple HAP instances on the same person are prohibited and useless. HAS+HAP is supported and is the key possibility.

Shoe Presets
A preset is a JSON file that stores the complete foot pose and collider configuration for a specific clothing item. They are stored in: Saves/PluginData/Nickon/HeelAdjust3/
Each preset is a .json file named after the clothing item it was created for. The file contains foot slope, toe rotation, collider scale/offset/rotation for heel and toe, toe limits, and linked item UIDs. You can open and edit these files in any text editor, but values are not validated on load - incorrect values may cause unexpected behaviour or visual glitches. Edit with care.
How to get presets
Shipped with the plugin package:
Some clothing items come with ready-made presets included.
Community:
Ask in the discussion thread.
Create your own:
In HAP mode, select a clothing item, adjust the foot pose and colliders, then click Save Preset.
Scene package presets (for scene creators):
You can place preset .json files in the plugin's .var://Saves/PluginData/Nickon/HeelAdjust3/ folder inside your scene package. When another user loads your scene package, the plugin will automatically discover it and use (the users existing presets have higher priority). Importing such presets is also possible in HAP mode.

Backward compatibility
Preset files are fully backward compatible. The JSON format inside preset files has not changed. Presets from HeelAdjust2 work with HeelAdjust3 without modification, and vice versa. Both the legacy path (Saves/PluginData/CheesyFX/HeelAdjust2/) and the new path are scanned; saving always uses the new path.
Scene data compatibility is not preserved since version 27. The internal storage architecture changed from a monolithic plugin blob to a dedicated atom-level host storable. Old scenes will load with default values - the plugin will not crash, but your previous settings will be gone unless you migrate.

Migration tool
There are three approaches:
Option 1: Start fresh (simplest)
If you do not need to preserve your old HeelAdjust settings:
1. Delete all older HeelAdjust plugin packages from VaM/AddonPackages (any version - CheesyFX.HeelAdjust2, Nickon7777.HeelAdjust3 less than 27).
2. Open your scene. VaM will show dead plugin references - these are harmless and can be removed.
3. Add the new HeelAdjust3.dll to a Person if you don't use the session concept.
4. Save the scene - the dead plugin references will be cleaned up.
Option 2: Manual scene file edit (for ChessyFX.HeelAdjust2.21 scenes):
1. Make a backup of your .json scene file.
2. Open the scene file in a text editor.
3. For each Person atom that has a HeelAdjust plugin blob (storable id starting with "plugin#_CheesyFX.HeelAdjust2"):
- Rename the storable "id" from the old plugin id to "HeelAdjustSettingsHost".
- Inside the blob, rename the "HeelAdjust2" key to "Preset".
4. Save the file and load the scene in VaM.
Option 3: Migration script (recommended for most cases)
The !migrate_heel_adjust.py script handles scenes from both ver 21 and ver 25 automatically. It requires Python 3. Backup files (.bak) are created.
Basic usage - migrate a single scene file:
python !migrate_heel_adjust.py VaM/Saves/scene/scene.json
Migrate all .json files in a directory:
python !migrate_heel_adjust.py --dir VaM/Saves/scene/path/to/scenes
Migrate to the session concept - removes HeelAdjust plugin references from a scene, converts the current person setting:
python !migrate_heel_adjust.py --session scene.json
python !migrate_heel_adjust.py --session --dir VaM/Saves/scene/path/to/scenes
Restore from backup:
python !migrate_heel_adjust.py --restore scene.json
python !migrate_heel_adjust.py --restore --dir VaM/Saves/scene/path/to/scenes

Python:
"""Migrate old HeelAdjust plugin blobs to HeelAdjustSettingsHost storable.
Replaces plugin paths and creates HeelAdjustSettingsHost atom-level storables
from old plugin data blobs so the new host-based architecture finds its data.
For each atom:
  1. PluginManager path: "CheesyFX.HeelAdjust2.*" or "Nickon7777.HeelAdjust3.*"
     → "Nickon7777.HeelAdjust3.latest:/Custom/Scripts/Nickon/HeelAdjust2/HeelAdjust3.dll"
  2. Person atom: copy the first matching plugin blob → new storable id=HeelAdjustSettingsHost
     (skipped if HeelAdjustSettingsHost already exists)
  3. CoreControl atom: the blob may contain per-person data keyed by atom uid
     under "HeelAdjust" key.  For each uid found, create HeelAdjustSettingsHost
     on the matching Person atom with that person's settings.
Usage:
    python !migrate_heel_adjust.py scene.json [scene2.json ...]
    python !migrate_heel_adjust.py --dir /path/to/scenes
    python !migrate_heel_adjust.py --session scene.json  # remove plugin refs instead of replacing
    python !migrate_heel_adjust.py --restore scene.json  # restore from .bak
    python !migrate_heel_adjust.py --restore --dir /path/to/scenes
"""
import json
import os
import sys
import glob
OLD_PREFIXES = ("CheesyFX.HeelAdjust2.", "CheesyFX.HeelAdjustScene.", "Nickon7777.HeelAdjust3.", "Custom/Scripts/Nickon/HeelAdjust")
NEW_PATH = "Nickon7777.HeelAdjust3.latest:/Custom/Scripts/Nickon/HeelAdjust3/HeelAdjust3.dll"
HOST_ID = "HeelAdjustSettingsHost"

def _is_old_plugin(storable_id):
    s = storable_id or ""
    if not s.startswith("plugin#"):
        return False
    return any(p.rstrip(".") in s for p in OLD_PREFIXES)

def _replace_plugin_paths(storables, session=False):
    changed = False
    for storable in storables:
        if storable.get("id") != "PluginManager":
            continue
        plugins = storable.get("plugins", {})
        for key, value in list(plugins.items()):
            if not isinstance(value, str):
                continue
            for prefix in OLD_PREFIXES:
                if value.startswith(prefix):
                    if session:
                        del plugins[key]
                        print(f"  removed: {key}")
                    else:
                        plugins[key] = NEW_PATH
                        print(f"  path: {key} -> {NEW_PATH}")
                    changed = True
                    break
    return changed

def _find_settings_key(blob):
    if "HeelAdjust" in blob:
        return "HeelAdjust"
    if "HeelAdjust2" in blob:
        return "HeelAdjust2"
    if "HeelAdjust3" in blob:
        return "HeelAdjust3"
    return None

def _has_host(storables):
    return any(s.get("id") == HOST_ID for s in storables)

CLS_SUFFIX = "Custom/Scripts/Nickon/CLS/CLS.cslist"

def _person_has_cls(person):
    for s in person.get("storables", []):
        if s.get("id") != "PluginManager":
            continue
        for path in s.get("plugins", {}).values():
            if isinstance(path, str) and path.endswith(CLS_SUFFIX):
                return True
    return False

def process_file(filepath, data, session=False):
    print(f"\n{filepath}")
    atoms = data.get("atoms", [])
    if not atoms:
        print("  no atoms")
        return False
    persons = {a["id"] for a in atoms if a.get("type") == "Person"}
    changed = False
    for atom in atoms:
        storables = atom.get("storables", [])
        atom_id = atom.get("id", "?")
        atom_type = atom.get("type", "")
        changed |= _replace_plugin_paths(storables, session)
        old_blobs = [s for s in storables if _is_old_plugin(s.get("id", ""))]
        if atom_type == "Person":
            if _has_host(storables) or not old_blobs:
                continue
            blob = old_blobs[0]
            blob["id"] = HOST_ID
            # Rename key to "Preset" which is what Mechanics.RestoreFromJSON reads
            for old_key in ("HeelAdjust", "HeelAdjust2"):
                if old_key in blob:
                    blob["Preset"] = blob.pop(old_key)
            # CLS auto-detection for Person atoms
            ach = blob.get("adjustControllerHeight")
            ha = blob.get("Preset", {})
            if not ach:
                ach = ha.get("adjustControllerHeight") if isinstance(ha, dict) else None
            if ach == "Root" and _person_has_cls(atom):
                # Update in the outer fields (new host format)
                if "adjustControllerHeight" in blob:
                    blob["adjustControllerHeight"] = "CLS"
                elif isinstance(ha, dict) and "adjustControllerHeight" in ha:
                    ha["adjustControllerHeight"] = "CLS"
                print(f"    adjustControllerHeight: Root -> CLS (CLS plugin found on '{atom_id}')")
            changed = True
            print(f"  Person '{atom_id}': created {HOST_ID}")
        elif atom_type == "CoreControl" and old_blobs:
            for blob in old_blobs:
                key = _find_settings_key(blob)
                if key is None or not isinstance(blob[key], dict):
                    continue
                per_person = blob[key]
                # Shared scene-level fields to copy into each person's host
                skip_keys = {"id", "enabled", "pluginLabel", key}
                shared = {k: v for k, v in blob.items() if k not in skip_keys}
                for uid, settings in list(per_person.items()):
                    if uid not in persons:
                        continue
                    person = next((a for a in atoms if a.get("id") == uid), None)
                    if person is None:
                        continue
                    ps = person.get("storables", [])
                    if _has_host(ps):
                        continue
                    host = {"id": HOST_ID, "Preset": settings}
                    host.update(shared)
                    ach = host.get("adjustControllerHeight") or settings.get("adjustControllerHeight")
                    if ach == "Root" and _person_has_cls(person):
                        settings["adjustControllerHeight"] = "CLS"
                        print(f"    adjustControllerHeight: Root -> CLS (CLS plugin found on '{uid}')")
                    ps.append(host)
                    person["storables"] = ps
                    changed = True
                    print(f"  CoreControl '{atom_id}' -> Person '{uid}': created {HOST_ID}")
    if not changed:
        print("  no changes")
        return False
    return True

def write_file(filepath, data):
    backup = filepath + ".bak"
    if os.path.exists(backup):
        print(f"  backup exists, overwriting")
    else:
        os.rename(filepath, backup)
        print(f"  backup: {backup}")
    with open(filepath, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2, ensure_ascii=False)
    # Touch .bak so its mtime is >= the new .json we just wrote.
    # Without this, --restore's freshness check (json_mtime > bak_mtime)
    # would falsely trigger after a re-migration where .bak predates .json.
    os.utime(backup)

def main():
    if len(sys.argv) < 2:
        print(__doc__.strip())
        sys.exit(1)
    session = "--session" in sys.argv
    restore = "--restore" in sys.argv
    args = [a for a in sys.argv[1:] if a not in ("--session", "--restore")]
    if restore:
        if args and args[0] == "--dir":
            files = sorted(glob.glob(os.path.join(args[1], "*.json.bak")))
        elif args:
            files = [a + ".bak" for a in args if os.path.isfile(a + ".bak")]
        else:
            print("usage: --restore file.json [--dir /path]")
            sys.exit(1)
        for bak in files:
            if not os.path.isfile(bak):
                print(f"  not found: {bak}")
                continue
            original = bak[:-4]
            if os.path.isfile(original):
                bak_mtime = os.path.getmtime(bak)
                json_mtime = os.path.getmtime(original)
                if json_mtime > bak_mtime:
                    rel = os.path.basename(original)
                    ans = input(f"  {rel}: scene was saved after backup was created, restore anyway? [y/n] ").strip().lower()
                    if ans != "y":
                        print(f"  skipped: {original}")
                        continue
            os.replace(bak, original)
            print(f"  restored: {original}")
        return
    if args[0] == "--dir":
        files = sorted(glob.glob(os.path.join(args[1], "*.json")))
        if not files:
            print(f"no .json files in {args[1]}")
            sys.exit(1)
        for f in files:
            with open(f, "r", encoding="utf-8") as fh:
                data = json.load(fh)
            if process_file(f, data, session):
                write_file(f, data)
    else:
        for arg in args:
            if os.path.isfile(arg):
                with open(arg, "r", encoding="utf-8") as fh:
                    data = json.load(fh)
                if process_file(arg, data, session):
                    write_file(arg, data)
            else:
                print(f"not found: {arg}")

if __name__ == "__main__":
    main()
- Replaces old plugin paths (CheesyFX.HeelAdjust2.*, Nickon7777.HeelAdjust3.*) with the new path.
- Converts old plugin blobs into HeelAdjustSettingsHost atom-level storables.
- Handles both Person atoms (direct blob migration) and CoreControl atoms (per-person data extraction).
- Auto-detects CLS plugin and adjusts adjustControllerHeight from "Root" to "CLS" where appropriate.
- Creates .bak backups of all modified files.

Technical reference for scene automation:
Atom-level storable id: "HeelAdjustSettingsHost"
Every managed Person atom carries a "HeelAdjustSettingsHost" storable (registered via Atom.RegisterAdditionalStorable). This is where all per-person settings live, independent of any plugin instance.
FieldTypeDefaultDescription
`Version`float
27​
Data format version
`enabled`booltruePer-person enabled state (scene toggle)
`Foot Slope`float
0​
Foot pose angle (-40..80)
`Max Foot Slope`float
20​
Maximum allowed foot slope
`Left Foot Slope Delta`float
0​
Left foot slope compensation
`Right Foot Slope Delta`float
0​
Right foot slope compensation
`Auto Calculate Deltas`boolfalseAuto-calculate slope deltas from bone angles
`Lock Toe Joint`boolfalseLock the toe joint
`Lock Individual Toes`boolfalseLock individual toe joints
`Collision Enabled`booltrueEnable foot colliders
`Cloth Collision Enabled`booltrueEnable cloth collision
`Show Colliders`boolfalseVisualize colliders
`Footsteps Enabled`booltrueEnable footstep sounds
`Spacial Blend`float
1​
Sound spatial blend (0=2D, 1=3D)
`adjustControllerHeight`string"None"Height adjustment mode: "None", "Feet", "Root", "CLS"
`soundLibrary`string"heels"Footstep sound library
`soundProducers`string"..."Sound producer filter
`onlySoundFromThis`string"None"Restrict sound to specific clothing item
`Base Volume`float
5​
Base volume for footstep sounds
`Base Pitch`float
1​
Base pitch for footstep sounds

Creating a reference preset example:
React to this content...

Share this resource

More resources from Nickon7777

Latest updates

  1. Version 27. Please read before update from version 6.

    This update is huge: * the session mode is the main mode, person mode work too, * delivered in a...
  2. Version 5. Critical bugfix only: broken when run from a packaged scene with no presets

    Critical bugfix: the plugin broken when run from a packaged scene with no presets provided in...
  3. Version 4. Scene version script features parity

    The scene/session version of the script now has no any limitation against the main script: 1)...

Latest reviews

Positive
Version: 2
Posted:
Great work thanks! works well with heels that have plugins, always broke scenes
Nickon7777
Nickon7777
Thank you, it's nice to know you're helping someone. Please pay attention to this information https://hub.virtamate.com/threads/heeladjust3.76994/post-237646 If I understand correctly, it will help you understand what's really going on.
Upvote 0
Back
Top Bottom