Docs

The Roblox Studio plugin

meshpup puts one small plugin into Roblox Studio. Here's exactly what it does, what it never does, and every line of its code.

How it gets onto your computer

The app writes one file, MeshpupCompanion.lua (it removes an older AltushkaCompanion.lua there), into Roblox Studio's local plugins folder. That's the whole install — nothing is uploaded and nothing else is touched.

  • macOS: ~/Documents/Roblox/Plugins/
  • Windows: %LOCALAPPDATA%\Roblox\Plugins\

It's on by default. To remove it, turn off Keep Roblox Studio plugin installed in the app's Settings and the app deletes the file. When you update the app, it rewrites the file with the new version. Studio loads plugins when it starts, so restart Studio after an install or an update.

What happens when you press Assemble Pet

  1. It finds your pet. If you selected a model, it uses that. If nothing is selected, it looks for one fresh meshpup import in the Workspace. Every export carries a tiny hidden marker, and with nothing selected the plugin only picks a model that has one, so it never grabs some other model by mistake. If you select a model without the marker, it still assembles it (you pointed at it) but warns you first.
  2. It reads the hidden markers, then removes them. Import 3D throws away almost everything except names, so the export hides a few settings in the names of tiny nodes: which parts are double-sided, your effects, and the Main box if you placed one. The plugin reads them and takes them out, so they never end up in your pet.
  3. It tidies the parts. Each MeshPart gets its name from the app and moves to the top of the model. The empty folders Import 3D left behind are removed.
  4. It builds the root. An invisible part called Main goes at the pet's feet (or where you placed your Main box in the app, at its size). It's anchored, doesn't collide and casts no shadow. It becomes the model's PrimaryPart.
  5. It welds everything to Main. Every part is welded with a WeldConstraint and set to massless, non-colliding and SmoothPlastic. Parts you made double-sided in the app get DoubleSided turned on.
  6. It adds your effects. Each effect layer becomes a ParticleEmitter on the part you picked for it in the app (or on Main if you didn't pick one).
  7. It puts the pet down. The pet drops onto whatever is below it, gets selected, and the camera frames it.

The whole thing is one undo step. If anything goes wrong partway through, the plugin cancels and your import stays exactly as it was.

What it never does

  • No web requests. It doesn't use HttpService and never contacts anything.
  • No files. It doesn't read or write anything on your computer.
  • No scripts in your game. The only things it creates are one Part, WeldConstraints, one Attachment and ParticleEmitters, all inside your pet.
  • Nothing in your live game. Plugins only run inside Studio, and only when you press the button.

What you get

One Model, named as you imported it, with Main as its PrimaryPart and every part welded to it. Main also holds a RootAttachment (a ready anchor for trails, particles or a nametag) and a few attributes your game code can read instead of hardcoding numbers per pet:

Attribute on MainWhat it means
AltushkaPettrue — marks the model as an assembled meshpup pet.
AltushkaBaseOffsetStuds from Main's centre down to the pet's feet. Pivot to ground + this and the feet touch the ground.
AltushkaPartCountHow many parts are welded to Main.
AltushkaVersionThe app version that made the plugin.
AltushkaFXIdThe pet's effects, comma-separated. Only on pets with effects.
AltushkaFXEmitterCountHow many particle emitters the effects added.

The plugin is generic, so it always uses the default settings. If you set a pet height or a solid root in the app, paste the export's _RobloxSetup.lua into the command bar instead. It runs the same assembly with your export's exact settings. The import tutorial walks through both ways.

Check it's the same file

The code below is the plugin from meshpup 0.1.0. To check it matches the one on your computer, compare its SHA-256:

12cf04c14f919a537d6cc6465551259379123b239d143beb69819f25a47175ae

macOS (Terminal):

shasum -a 256 ~/Documents/Roblox/Plugins/MeshpupCompanion.lua

Windows (PowerShell):

Get-FileHash $env:LOCALAPPDATA\Roblox\Plugins\MeshpupCompanion.lua

On a different app version the hash won't match, even when the code is the same: the version number is written into line 1 and line 23. Those two lines are the only ones that change between versions when the code doesn't.

Using this code

It's here so you can see exactly what runs on your computer. You're welcome to read it, and to adjust your own copy for your own use. The plugin is part of meshpup, so our Terms still apply to it — please don't republish or share it as your own.

Full source

MeshpupCompanion.lua · 1,532 lines · Luau

Download
-- meshpup companion — Roblox Studio plugin (generated by meshpup 0.1.0)
-- Adds a "meshpup" toolbar with one button: assemble the SELECTED imported pet
-- model into the canonical welded structure — identical logic to the per-export
-- _RobloxSetup.lua, with the baked parameters at their defaults (imported name kept,
-- fixed pack-convention root, no count check, no size normalization — use the
-- per-export script when you need those). Double-sided zones, glow colours, Roblox
-- surfaces, the pet's effects and a Main placed with Spawn Main are read from the
-- imported model.
--
-- Install: drop this file into Studio's local plugins folder (Plugins tab →
-- Plugins Folder). Re-install from meshpup's Settings to upgrade.

local ChangeHistoryService = game:GetService("ChangeHistoryService")

local toolbar = plugin:CreateToolbar("meshpup")
local assembleButton = toolbar:CreateButton(
    "Assemble Pet",
    "Assemble the selected imported meshpup pet(s) — or a lone fresh import, nothing selected — into the canonical welded structure",
    "")

local function assembleSelection()
local PET_NAME = ""
local EXPECTED_PARTS = 0
local APP_VERSION = "0.1.0"
local NORMALIZE_HEIGHT = 0
local PART_POSES = {}
local DOUBLE_SIDED = {}

local Selection = game:GetService("Selection")

-- Provenance (AD-0220). Every export carries exactly one ASPet|<schema> marker, baked as a
-- named node because the node NAME is the only channel that survives Import 3D. This is the
-- POSITIVE proof: without it "assemblable" means "a Model containing a MeshPart", which is
-- most things in Roblox — so one unrelated purchased model alone in the Workspace could be
-- welded, de-collided, renamed and ground-snapped with no confirmation, just because it was
-- the only candidate.
local function altushkaHasProvenance(candidate)
    for _, descendant in ipairs(candidate:GetDescendants()) do
        if string.match(descendant.Name, "^ASPet|") then
            return true
        end
    end
    return false
end

-- Which Models can be assembled: holds at least one MeshPart and does not already carry
-- the synthetic meshpup root. Structure only — never names or categories.
local function altushkaIsAssemblable(candidate)
    if not candidate:IsA("Model") then
        return false
    end
    local candidateMain = candidate:FindFirstChild("Main")
    if candidateMain and candidateMain:GetAttribute("AltushkaPet") then
        return false
    end
    return candidate:FindFirstChildWhichIsA("MeshPart", true) ~= nil
end

local modelsToAssemble = {}
local selectedItems = Selection:Get()
if #selectedItems > 0 then
    -- Explicit selection wins. Already-assembled pets are skipped with a warn instead
    -- of aborting the batch around them.
    local skippedAssembled = 0
    for _, item in ipairs(selectedItems) do
        if altushkaIsAssemblable(item) then
            if not altushkaHasProvenance(item) then
                -- Explicit selection is the user pointing at something, so it is honoured —
                -- but assembly is destructive and irreversible-looking, so it must not be
                -- silent about assembling something it cannot prove came from this app.
                warn(string.format(
                    "meshpup: '%s' carries no meshpup export marker — assembling it anyway " ..
                    "because you selected it. If this is not a pet exported from meshpup, " ..
                    "undo now.", item.Name))
            end
            table.insert(modelsToAssemble, item)
        elseif item:IsA("Model") then
            local itemMain = item:FindFirstChild("Main")
            if itemMain and itemMain:GetAttribute("AltushkaPet") then
                skippedAssembled = skippedAssembled + 1
                warn(string.format("meshpup: '%s' is already assembled — skipped.", item.Name))
            end
        end
    end
    if #modelsToAssemble == 0 then
        if skippedAssembled > 0 then
            error("meshpup: everything selected is already assembled.", 0)
        end
        error("meshpup: the selection contains no pet model (a Model holding MeshParts).", 0)
    end
else
    -- Nothing selected: find the import on our own. Top-level workspace Models only
    -- (Import 3D inserts there). Exactly one candidate proceeds; several must be
    -- disambiguated by hand; zero explains the first step.
    -- AUTO-PICK REQUIRES PROVENANCE (AD-0220). Nothing was selected, so the user is not
    -- pointing at anything — the plugin must not guess destructively. A meshpup export
    -- always carries the marker, so requiring it costs a real pet nothing and protects
    -- everything else in the Workspace.
    local candidates = {}
    local unprovenCandidates = 0
    for _, child in ipairs(workspace:GetChildren()) do
        if altushkaIsAssemblable(child) then
            if altushkaHasProvenance(child) then
                table.insert(candidates, child)
            else
                unprovenCandidates = unprovenCandidates + 1
            end
        end
    end
    if #candidates == 0 and unprovenCandidates > 0 then
        error(string.format(
            "meshpup: found %d model(s) in the Workspace, but none carry a meshpup export " ..
            "marker. Import the .glb this app exported, or select a model explicitly to " ..
            "assemble it anyway.", unprovenCandidates), 0)
    end
    if #candidates == 1 then
        modelsToAssemble[1] = candidates[1]
        print(string.format(
            "meshpup: nothing selected — assembling the imported model '%s'.",
            candidates[1].Name))
    elseif #candidates > 1 then
        -- Cap the list (15.166 field find: a lived-in workspace produced a 35-name wall).
        -- The message's job is "be specific, select one" — not a workspace inventory.
        local candidateNames = {}
        for i = 1, math.min(#candidates, 5) do
            table.insert(candidateNames, "'" .. candidates[i].Name .. "'")
        end
        local listedNames = table.concat(candidateNames, ", ")
        if #candidates > 5 then
            listedNames = string.format("%s and %d more", listedNames, #candidates - 5)
        end
        error(string.format(
            "meshpup: nothing is selected and %d models could be the pet (%s) — select the one to assemble, then run again.",
            #candidates, listedNames), 0)
    else
        error("meshpup: no imported pet found — use Home → Import 3D to insert the exported .glb, then run again.", 0)
    end
end

local assembledModels = {}

for _, model in ipairs(modelsToAssemble) do

-- The pet's own Main, when it has one: the box placed with Spawn Main in meshpup,
-- carried in the model as an ASMain| node. It is taken out of the model before anything
-- below counts, flattens, measures or scales the pet — it is not a part — and Main is built
-- on it after the root block. Parent = nil, never Destroy, so undo can put the import back
-- as it was. A pet without one has no such node, and nothing here or in the two Main steps
-- below changes it.
local altushkaMainCubeCenter = nil
local altushkaMainCubeSize = nil
local altushkaMainCubeScale = model:GetScale()
local altushkaMainCubePartsBefore = {}
local altushkaMainCubeTags = {}
for _, descendant in ipairs(model:GetDescendants()) do
    if string.match(descendant.Name, "^ASMain|") then
        table.insert(altushkaMainCubeTags, descendant)
    end
end
for _, tag in ipairs(altushkaMainCubeTags) do
    if tag:IsDescendantOf(model) then
        -- Import 3D brings a node either as the MeshPart itself or as a Model named after the
        -- node holding a MeshPart named after the mesh; both shapes are read.
        local box = tag
        if not box:IsA("BasePart") then
            box = tag:FindFirstChildWhichIsA("BasePart", true)
        end
        if box and not altushkaMainCubeCenter then
            -- The world box, whatever rotation the import gave the part.
            local cf, size = box.CFrame, box.Size
            altushkaMainCubeCenter = cf.Position
            altushkaMainCubeSize = Vector3.new(
                math.abs(cf.RightVector.X) * size.X + math.abs(cf.UpVector.X) * size.Y + math.abs(cf.LookVector.X) * size.Z,
                math.abs(cf.RightVector.Y) * size.X + math.abs(cf.UpVector.Y) * size.Y + math.abs(cf.LookVector.Y) * size.Z,
                math.abs(cf.RightVector.Z) * size.X + math.abs(cf.UpVector.Z) * size.Y + math.abs(cf.LookVector.Z) * size.Z)
        end
        tag.Parent = nil
    end
end
if altushkaMainCubeCenter then
    -- Where every part stands now, before anything scales them: the scale block below reads one
    -- part's move to carry the box along by the same scale, about the same point.
    for _, descendant in ipairs(model:GetDescendants()) do
        if descendant:IsA("MeshPart") then
            altushkaMainCubePartsBefore[descendant] = descendant.Position
        end
    end
end

-- Assembled-guard (15.74): only the SYNTHETIC meshpup root counts — a legitimate
-- imported part that happens to be named 'Main' must not block first assembly.
local existingMain = model:FindFirstChild("Main")
if existingMain and existingMain:GetAttribute("AltushkaPet") then
    error("meshpup: this model already has an assembled meshpup 'Main' root.", 0)
end

-- Metadata tags first. ("AS" = Altushka Studio — short on purpose: Import 3D truncates
-- names at 50 chars.)
--
-- Face and pose tags come only from exports made BEFORE the app stopped applying
-- decal faces (row G, 15.765). A face is painted into the part's texture now, so a new export
-- carries neither, and this script applies no Decal. An old export still carries them, and
-- two things are still owed to it: its tags are removed so they are not welded in as parts,
-- and a rotated face carrier gets its pose back. That carrier was written with its mesh in
-- its OWN axes at the correct bounds center (Import 3D would otherwise bake the rotation into
-- the geometry), and three compact pose tags carry what this pass restores:
--   ASPoseX|right-vector
--   ASPoseY|up-vector
--   ASPoseS|positive-scale
-- Without the restore that part would stand unrotated and at its raw mesh size.
--
-- Import 3D is creative about hierarchy (three field-observed shapes), so association
-- remains structural and tags of ANY class are consumed before the real-part count.
local faceTags = {}
local poseTags = {}
local provenanceTags = {}
local doubleSidedTags = {}
local glowTags = {}
local surfaceTags = {}
local effectTags = {}
local lightTags = {}
local lightAimTags = {}
for _, descendant in ipairs(model:GetDescendants()) do
    if string.match(descendant.Name, "^ASFace|%d+|%a+")
        or string.match(descendant.Name, "^AltushkaFace|%d+|%a+$") then
        -- The long "AltushkaFace|" prefix is the pre-15.90 export format (decal form
        -- only — pose tags never shipped under it); accepted so old .glbs keep working.
        table.insert(faceTags, descendant)
    elseif string.match(descendant.Name, "^ASPose[XYS]|") then
        table.insert(poseTags, descendant)
    elseif string.match(descendant.Name, "^ASDbl|") then
        -- Double-sided, marker channel (2026-08-06 field defect). Presence only: which
        -- MeshPart it means is the node it hangs under, exactly like a pose tag — which
        -- is what makes it correct per ZONE (AD-0324) with no name baked into the tag.
        table.insert(doubleSidedTags, descendant)
    elseif string.match(descendant.Name, "^ASGlow|") then
        -- A glowing zone's painted colour (15.937): carried in the name, meant for the node it hangs under.
        table.insert(glowTags, descendant)
    elseif string.match(descendant.Name, "^ASSurf|") then
        -- A zone's Roblox surface (1.6.0): its material, colour and see-through, carried in the name.
        table.insert(surfaceTags, descendant)
    elseif string.match(descendant.Name, "^ASFxE|")
        or string.match(descendant.Name, "^ASFx|") then
        -- The pet's effect, cut into numbered pieces of a node name (15.704, AD-0976). Both
        -- vehicles collect and remove them; only the companion plugin decodes them, because the
        -- per-export script bakes the same effect as its own readable table.
        table.insert(effectTags, descendant)
    elseif string.match(descendant.Name, "^ASLt|") then
        -- A light (1.6.0): its values in the name, its point where the marker stands, meant for
        -- the node it hangs under.
        table.insert(lightTags, descendant)
    elseif string.match(descendant.Name, "^ASAim|") then
        -- A spot's or surface light's aim: the point one stud along the way the light shines.
        table.insert(lightAimTags, descendant)
    elseif string.match(descendant.Name, "^ASPet|") then
        -- The provenance marker (2026-08-01 audit). It proves the model came from meshpup;
        -- it carries nothing else, so it is only collected here to be removed.
        table.insert(provenanceTags, descendant)
    end
end

local function altushkaIsMetadataPart(candidate)
    local cursor = candidate
    while cursor and cursor ~= model do
        if string.match(cursor.Name, "^ASFace|")
            or string.match(cursor.Name, "^ASPose[XYS]|")
            or string.match(cursor.Name, "^ASDbl|")
            or string.match(cursor.Name, "^ASGlow|")
            or string.match(cursor.Name, "^ASSurf|")
            or string.match(cursor.Name, "^ASPet|")
            or string.match(cursor.Name, "^ASFxE|")
            or string.match(cursor.Name, "^ASFx|")
            or string.match(cursor.Name, "^ASLt|")
            or string.match(cursor.Name, "^ASAim|")
            or string.match(cursor.Name, "^AltushkaFace|")
        then
            return true
        end
        cursor = cursor.Parent
    end
    return false
end

local function altushkaFindCarrier(tag)
    local tagParent = tag.Parent
    if not tagParent or tagParent == model then
        return nil
    end
    if tagParent:IsA("MeshPart") then
        return tagParent
    end
    for _, candidate in ipairs(tagParent:GetDescendants()) do
        if candidate:IsA("MeshPart")
            and candidate ~= tag
            and not candidate:IsDescendantOf(tag)
            and not altushkaIsMetadataPart(candidate)
        then
            return candidate
        end
    end
    for _, candidate in ipairs(model:GetDescendants()) do
        if candidate:IsA("MeshPart")
            and candidate.Name == tagParent.Name
            and not altushkaIsMetadataPart(candidate)
        then
            return candidate
        end
    end
    return nil
end

local function altushkaParseVector(text)
    local ax, ay, az = string.match(text or "", "^([^,]+),([^,]+),([^,]+)$")
    local x, y, z = tonumber(ax), tonumber(ay), tonumber(az)
    if not (x and y and z) then
        return nil
    end
    return Vector3.new(x, y, z)
end

local nativePoseByCarrier = {}
for _, tag in ipairs(poseTags) do
    local kind, payload = string.match(tag.Name, "^ASPose([XYS])|(.+)$")
    local carrier = altushkaFindCarrier(tag)
    local value = altushkaParseVector(payload)
    if carrier and kind and value then
        local pose = nativePoseByCarrier[carrier]
        if not pose then
            pose = {}
            nativePoseByCarrier[carrier] = pose
        end
        if kind == "X" then
            pose.x = value
        elseif kind == "Y" then
            pose.y = value
        else
            pose.s = value
        end
    else
        warn(string.format(
            "meshpup: native-pose tag '%s' was unreadable or had no carrier.",
            tag.Name))
    end
end

local restoredNativePoses = {}
local function altushkaNativePoseIsValid(pose)
    if not (pose and pose.x and pose.y and pose.s) then
        return false
    end
    if pose.x.Magnitude < 0.999 or pose.y.Magnitude < 0.999
        or math.abs(pose.x:Dot(pose.y)) > 0.01
        or pose.s.X <= 0 or pose.s.Y <= 0 or pose.s.Z <= 0
    then
        return false
    end
    local right = pose.x.Unit
    local up = pose.y.Unit
    local back = right:Cross(up)
    if back.Magnitude < 0.999 then
        return false
    end
    return true
end

local function altushkaRestoreNativePose(carrier, pose)
    if restoredNativePoses[carrier] then
        return true
    end
    if not altushkaNativePoseIsValid(pose) then
        return false
    end
    local right = pose.x.Unit
    local up = pose.y.Unit
    local back = right:Cross(up)

    -- The exported carrier is unrotated and unscaled but already centered at its final
    -- position. Imported Size therefore gives the raw mesh dimensions including the
    -- user's Import 3D unit scale; multiplying per axis restores authored scale while
    -- remaining immune to that global import scale.
    local position = carrier.Position
    local baseSize = carrier.Size
    carrier.Size = Vector3.new(
        baseSize.X * pose.s.X,
        baseSize.Y * pose.s.Y,
        baseSize.Z * pose.s.Z)
    carrier.CFrame = CFrame.fromMatrix(position, right, up, back.Unit)
    restoredNativePoses[carrier] = true
    return true
end

-- Validate EVERY carrier that declares native geometry before mutating any part. The |N
-- flag on an old export's face tag makes complete marker loss detectable; without it an
-- unrotated raw mesh could be assembled silently. The setup script that shipped with that
-- export can recover from its own name-keyed PART_POSES; this script and the generic plugin
-- declare it empty, so they fail clearly, and the plugin leaves its undo recording
-- cancellable.
for _, tag in ipairs(faceTags) do
    local _, _, nativeFlag =
        string.match(tag.Name, "^ASFace|(%d+)|(%a+)|([N])$")
    if nativeFlag then
        local carrier = altushkaFindCarrier(tag)
        local pose = carrier and nativePoseByCarrier[carrier] or nil
        if carrier and not altushkaNativePoseIsValid(pose) then
            pose = PART_POSES[carrier.Name]
            if not pose and tag.Parent then
                pose = PART_POSES[tag.Parent.Name]
            end
            if pose then
                nativePoseByCarrier[carrier] = pose
            end
        end
        if not carrier or not altushkaNativePoseIsValid(nativePoseByCarrier[carrier]) then
            error(string.format(
                "meshpup: rotated face carrier for tag '%s' is missing native-pose metadata. Re-import the current .glb with Merge Meshes OFF; nothing was assembled.",
                tag.Name), 0)
        end
    end
end

for carrier, pose in pairs(nativePoseByCarrier) do
    if not altushkaRestoreNativePose(carrier, pose) then
        warn(string.format(
            "meshpup: native pose for '%s' was incomplete — that part may keep the wrong rotation and size.",
            carrier.Name))
    end
end
-- BELT AND BRACES (2026-08-01 audit, Stage 6 item 6 — CORRECTED 2026-08-02, Phase 15.196, from
-- a field report). A node matching a metadata prefix that carries REAL GEOMETRY is not a marker
-- — it is a real part wearing a reserved name, and unparenting it would silently delete geometry.
--
-- The first version asked "does the tag have children?", on the belief that a marker is always
-- a leaf. IT NEVER IS: the export writes a marker as a NODE carrying a millimetre triangle, and
-- Import 3D splits that node into a Model named after the NODE holding a MeshPart named after
-- the MESH ("AltushkaFaceTag"). Every marker therefore looked like "a tag with children",
-- every marker was kept, and the flatten pass below handed each marker mesh its wrapper Model's
-- name and welded it into the pet as a real part.
--
-- The honest test is SIZE: the marker triangle is 0.001 studs and lands at Roblox's minimum part
-- size, while real pet geometry is orders of magnitude larger. Checking the tag itself as well as
-- its descendants also closes the childless case the old guard admitted it could not catch.
--
-- The primary defence is still app-side (the export projection escapes reserved names before they
-- reach the .glb); this fails toward keeping geometry for anything that arrives another way.
local ALTUSHKA_MARKER_MAX_STUDS = 0.25

local function altushkaIsMarkerSized(part)
    local size = part.Size
    return size.X <= ALTUSHKA_MARKER_MAX_STUDS
        and size.Y <= ALTUSHKA_MARKER_MAX_STUDS
        and size.Z <= ALTUSHKA_MARKER_MAX_STUDS
end

local function altushkaCarriesRealGeometry(tag)
    if tag:IsA("BasePart") and not altushkaIsMarkerSized(tag) then
        return true
    end
    for _, descendant in ipairs(tag:GetDescendants()) do
        if descendant:IsA("BasePart") and not altushkaIsMarkerSized(descendant) then
            return true
        end
    end
    return false
end

local function altushkaRemoveTag(tag, what)
    if altushkaCarriesRealGeometry(tag) then
        warn(string.format(
            "meshpup: '%s' looks like %s metadata but contains real geometry — keeping it, "
            .. "because removing it would delete real parts. Rename it if it is not metadata.",
            tag.Name, what))
        return false
    end
    -- Unparenting the tag takes its whole subtree with it, which is exactly the point: in
    -- Studio's import shape the marker's own mesh is the tag's CHILD, not the tag itself.
    tag.Parent = nil
    return true
end

-- Removal is Parent = nil, NEVER Destroy (15.166 field find): Destroy permanently locks
-- the instance's Parent, which makes the recorded change history non-invertible — his
-- first real ⌘Z after assembly produced a wall of "Parent property is locked" restores
-- and a mangled pet. Unparented instances stay resurrectable, so undo cleanly rebuilds
-- the raw import; they are no longer descendants, so every count stays honest.
for _, tag in ipairs(poseTags) do
    altushkaRemoveTag(tag, "pose")
end

for _, tag in ipairs(provenanceTags) do
    altushkaRemoveTag(tag, "provenance")
end

-- The effect tags hang under the provenance marker, so they usually left with it just above;
-- each is still removed on its own, because Import 3D is creative about hierarchy and a tag
-- that arrives anywhere else must not be counted, flattened and welded in as a part. Their
-- NAMES stay readable after unparenting, which is all the plugin's effect step needs.
for _, tag in ipairs(effectTags) do
    altushkaRemoveTag(tag, "effect")
end

-- Double-sided, marker channel (2026-08-06 field defect: "double sided did not get turned
-- on when plugin ran imported asset that had double sided on"). Resolved to its carrier
-- HERE, while the tag is still parented, and remembered by the part INSTANCE — the flatten
-- pass below renames parts, and an instance key is indifferent to that (a name key would
-- have to be read before the rename and applied after it). The write itself lives in the
-- per-part weld loop beside the table channel, so the two sources cannot diverge.
local altushkaDoubleSidedCarriers = {}
for _, tag in ipairs(doubleSidedTags) do
    local carrier = altushkaFindCarrier(tag)
    if carrier then
        altushkaDoubleSidedCarriers[carrier] = true
    else
        warn(string.format(
            "meshpup: double-sided tag '%s' found no carrier part — that zone stays single-sided.",
            tag.Name))
    end
    altushkaRemoveTag(tag, "double-sided")
end

-- The painted glow's colour, marker channel (15.937). Roblox colours a glow by the part's
-- SurfaceAppearance.EmissiveTint, and Import 3D sets that white, so a cyan glow arrived white:
-- over the cyan colour map a white tint glowed white at any strength, a cyan one cyan (his
-- Studio test, 2026-10-15). Each glowing zone carries ASGlow|<schema>|RRGGBB under its node;
-- resolved to its carrier here by INSTANCE, for the double-sided tags' reason, and set in the
-- weld loop.
--
-- The tag also CARRIES the zone's glow mask, as its own colour map (2026-10-18): Import 3D
-- screens a part's emissive map into the colour map it uploads, which made every glowing paint
-- arrive lighter, so the part ships with none and its mask rides the tag, whose colour map
-- uploads untouched. Read here, before the tag is removed; moved into the part's mask slot in
-- the weld loop. Import 3D puts a material that has only a colour map on the MeshPart's own
-- TextureContent and gives it no SurfaceAppearance (measured in Studio, 2026-10-18), so that is
-- read first. A tag from an older export carries the part's own material, so this reads the
-- part's colour map — the weld loop sets a mask only when the part has none of its own.
local altushkaGlowColours = {}
local altushkaGlowMasks = {}
for _, tag in ipairs(glowTags) do
    local carrier = altushkaFindCarrier(tag)
    local hex = string.match(tag.Name, "^ASGlow|%d+|(%x%x%x%x%x%x)")
    if carrier and hex then
        altushkaGlowColours[carrier] = Color3.fromRGB(
            tonumber(string.sub(hex, 1, 2), 16),
            tonumber(string.sub(hex, 3, 4), 16),
            tonumber(string.sub(hex, 5, 6), 16))
        local altushkaMaskPart = tag:IsA("MeshPart") and tag or tag:FindFirstChildWhichIsA("MeshPart", true)
        local altushkaMaskSurface = tag:FindFirstChildWhichIsA("SurfaceAppearance", true)
        pcall(function()
            if altushkaMaskPart and altushkaMaskPart.TextureContent.SourceType ~= Enum.ContentSourceType.None then
                altushkaGlowMasks[carrier] = altushkaMaskPart.TextureContent
            elseif altushkaMaskSurface then
                altushkaGlowMasks[carrier] = altushkaMaskSurface.ColorMapContent
            end
        end)
    else
        warn(string.format(
            "meshpup: glow tag '%s' was unreadable or found no carrier part — that glow stays white.",
            tag.Name))
    end
    altushkaRemoveTag(tag, "glow")
end

-- A zone's Roblox surface, marker channel (1.6.0): ASSurf|<schema>|<material>|RRGGBB|<see-through %>
-- under its node. Import 3D makes every textured part SmoothPlastic-with-a-SurfaceAppearance, and a
-- SurfaceAppearance hides the base material's look, so the weld loop takes it off for these parts.
-- Resolved to its carrier here by INSTANCE, for the double-sided tags' reason. Only the four words
-- the app writes are read; anything else keeps the part SmoothPlastic and says so.
local altushkaSurfaces = {}
local altushkaSurfaceMaterials = {
    smoothplastic = Enum.Material.SmoothPlastic,
    forcefield = Enum.Material.ForceField,
    glass = Enum.Material.Glass,
    neon = Enum.Material.Neon,
}
for _, tag in ipairs(surfaceTags) do
    local carrier = altushkaFindCarrier(tag)
    local word, hex, seeThrough = string.match(tag.Name, "^ASSurf|%d+|(%a+)|(%x%x%x%x%x%x)|(%d%d?)$")
    if carrier and word and altushkaSurfaceMaterials[word] and tonumber(seeThrough) <= 95 then
        altushkaSurfaces[carrier] = {
            word = word,
            material = altushkaSurfaceMaterials[word],
            colour = Color3.fromRGB(
                tonumber(string.sub(hex, 1, 2), 16),
                tonumber(string.sub(hex, 3, 4), 16),
                tonumber(string.sub(hex, 5, 6), 16)),
            transparency = tonumber(seeThrough) / 100,
        }
    else
        warn(string.format(
            "meshpup: surface tag '%s' was unreadable or found no carrier part — that zone stays SmoothPlastic.",
            tag.Name))
    end
    altushkaRemoveTag(tag, "surface")
end

-- The pet's lights (1.6.0). Each ASLt| marker carries one light in its name and stands AT the
-- point it shines from; a spot or surface light also has an ASAim| marker one stud along the way
-- it shines. POSITIONS, never rotations: Import 3D brings every marker in unrotated, but where its
-- geometry stands is exact. Read here, while the markers are parented and before anything moves.
-- The Attachment each point or spot light rides is made NOW, on its part, so the size
-- normalization below carries it with the part; the light itself is made in the weld loop, keyed
-- by the part INSTANCE as the double-sided and glow marks are, after the part count has passed.
-- A surface light shines from a whole face of its part, the face nearest its aim. A schema above
-- the one this block knows is skipped with a warning rather than guessed at.
local altushkaLightAims = {}
for _, tag in ipairs(lightAimTags) do
    local schema, index = string.match(tag.Name, "^ASAim|(%d+)|(%d+)$")
    local marker = tag:IsA("BasePart") and tag or tag:FindFirstChildWhichIsA("BasePart", true)
    if schema and tonumber(schema) <= 1 and marker then
        altushkaLightAims[index] = marker.Position
    end
    altushkaRemoveTag(tag, "light aim")
end

local altushkaLightsByCarrier = {}
for _, tag in ipairs(lightTags) do
    local schema, index, kind, shadows, hex, brightness, range, angle = string.match(tag.Name,
        "^ASLt|(%d+)|(%d+)|([PSF])([01])|(%x%x%x%x%x%x)|([%d%.]+)|([%d%.]+)|([%d%.]+)$")
    local carrier = altushkaFindCarrier(tag)
    local marker = tag:IsA("BasePart") and tag or tag:FindFirstChildWhichIsA("BasePart", true)
    if schema and tonumber(schema) > 1 then
        warn(string.format(
            "meshpup: light tag '%s' was written by a newer meshpup — that light is left out. Update the meshpup plugin.",
            tag.Name))
    elseif schema and carrier and marker then
        local at = marker.Position
        local aim = altushkaLightAims[index]
        local direction = aim and (aim - at) or Vector3.new(0, -1, 0)
        if direction.Magnitude < 0.0001 then
            direction = Vector3.new(0, -1, 0)
        end
        direction = direction.Unit
        local spec = {
            index = index,
            kind = kind,
            shadows = shadows == "1",
            color = Color3.fromRGB(
                tonumber(string.sub(hex, 1, 2), 16),
                tonumber(string.sub(hex, 3, 4), 16),
                tonumber(string.sub(hex, 5, 6), 16)),
            brightness = tonumber(brightness),
            range = tonumber(range),
            angle = tonumber(angle),
        }
        if kind == "F" then
            local onPart = carrier.CFrame:VectorToObjectSpace(direction)
            local ax, ay, az = math.abs(onPart.X), math.abs(onPart.Y), math.abs(onPart.Z)
            if ax >= ay and ax >= az then
                spec.face = onPart.X > 0 and Enum.NormalId.Right or Enum.NormalId.Left
            elseif ay >= az then
                spec.face = onPart.Y > 0 and Enum.NormalId.Top or Enum.NormalId.Bottom
            else
                spec.face = onPart.Z > 0 and Enum.NormalId.Back or Enum.NormalId.Front
            end
        else
            local up = math.abs(direction.Y) > 0.99 and Vector3.new(0, 0, 1) or Vector3.new(0, 1, 0)
            local attachment = Instance.new("Attachment")
            attachment.Name = "AltushkaLight" .. index
            attachment.Parent = carrier
            attachment.WorldCFrame = CFrame.lookAt(at, at + direction, up)
            spec.attachment = attachment
        end
        local carried = altushkaLightsByCarrier[carrier]
        if not carried then
            carried = {}
            altushkaLightsByCarrier[carrier] = carried
        end
        table.insert(carried, spec)
    else
        warn(string.format(
            "meshpup: light tag '%s' was unreadable or found no carrier part — that light is left out.",
            tag.Name))
    end
    altushkaRemoveTag(tag, "light")
end

-- An old export's face tags (see the note above the tag scan): removed like every other
-- marker, and nothing is applied from them. Said ONCE per model rather than once per tag, so
-- a pet exported with a face before the change assembles with one line explaining why it has
-- no face, not a line for every tag it carried.
if #faceTags > 0 then
    warn(string.format(
        "meshpup: '%s' was exported by an older meshpup with a decal face. " ..
        "Decal faces are no longer applied, so it assembles without one — place the face " ..
        "in meshpup and export again to paint it on.",
        model.Name))
end
for _, tag in ipairs(faceTags) do
    altushkaRemoveTag(tag, "face")
end

-- Collect the pet's MeshParts — AFTER the tags died, so this count is honest.
local parts = {}
for _, descendant in ipairs(model:GetDescendants()) do
    if descendant:IsA("MeshPart") then
        table.insert(parts, descendant)
    end
end

if #parts == 0 then
    error("meshpup: no MeshParts found under the selected model.", 0)
end

-- EXPECTED_PARTS = 0 means "no expectation" (the companion plugin's generic path).
-- A per-export mismatch HARD-FAILS before any mutation (15.74): the usual cause is
-- Import 3D settings (Merge Meshes ON collapses every zone into one MeshPart).
if EXPECTED_PARTS > 0 and #parts ~= EXPECTED_PARTS then
    -- "that pet", not "nothing" (15.165): in a command-bar batch, pets assembled before
    -- this one stay assembled. The plugin path stays fully atomic — its cancelled undo
    -- recording rolls the whole batch back.
    error(string.format(
        "meshpup: '%s': expected %d parts but found %d — that pet was not assembled. Re-import with Merge Meshes OFF and Scale Unit = Studs (Studio's defaults), then run this script again.",
        model.Name, EXPECTED_PARTS, #parts), 0)
end

-- Flatten: each MeshPart takes its wrapper Model's name (the meshpup display name)
-- and moves to the top. Final names are unique via a COMPLETE reserved-name set
-- (15.74 — the old per-base counter could emit 'Ear 2' twice for Ear/Ear/'Ear 2');
-- 'Main' is pre-reserved for the synthetic root, so a part named Main becomes 'Main 2'.
local usedNames = { Main = true }
for _, part in ipairs(parts) do
    local wrapper = part.Parent
    local baseName = part.Name
    if wrapper ~= model and wrapper:IsA("Model") then
        baseName = wrapper.Name
    end
    local finalName = baseName
    local suffix = 1
    while usedNames[finalName] do
        suffix = suffix + 1
        finalName = string.format("%s %d", baseName, suffix)
    end
    usedNames[finalName] = true
    part.Name = finalName
    part.Parent = model
end

-- Remove the emptied wrapper Models (Parent = nil, never Destroy — undo must be able
-- to reparent the parts back into them).
for _, child in ipairs(model:GetChildren()) do
    if child:IsA("Model") and #child:GetChildren() == 0 then
        child.Parent = nil
    end
end

-- Per-export fallback for native-pose marker delivery, by final part name, for when Import 3D
-- discarded or rearranged a marker. Only a setup script that shipped WITH an old export ever
-- filled PART_POSES; this script and the generic plugin declare it empty, so for them the
-- markers above are the only channel.
for partName, pose in pairs(PART_POSES) do
    local target = model:FindFirstChild(partName)
    if target and target:IsA("MeshPart") then
        if not altushkaRestoreNativePose(target, pose) then
            warn(string.format(
                "meshpup: native pose for part '%s' was unreadable — that part may keep the wrong rotation and size.",
                partName))
        end
    else
        warn(string.format(
            "meshpup: native-pose target part '%s' not found.",
            partName))
    end
end

-- Bounds are computed only after every native carrier has regained its final Size/CFrame.
local boundsCFrame, boundsSize = model:GetBoundingBox()

-- Optional size normalization (15.47): scale the whole pet so its HEIGHT is
-- NORMALIZE_HEIGHT studs (0 = keep the authored size). Runs BEFORE the root is
-- created, so Main and the AltushkaBaseOffset contract derive from the final size.
if NORMALIZE_HEIGHT > 0 then
    local _, preScaleSize = model:GetBoundingBox()
    if preScaleSize.Y > 0.001 then
        model:ScaleTo(model:GetScale() * (NORMALIZE_HEIGHT / preScaleSize.Y))
        boundsCFrame, boundsSize = model:GetBoundingBox()
    end
end

-- This pet's own Main, scaled with the pet: the normalization above scaled the parts with the
-- box out of the model (so it measured the parts alone); the box takes the same factor and
-- follows one part's move, which carries it about whatever point the scale used.
if altushkaMainCubeCenter then
    local altushkaMainCubeFactor = model:GetScale() / altushkaMainCubeScale
    local altushkaReferencePart = parts[1]
    local altushkaReferenceBefore = altushkaMainCubePartsBefore[altushkaReferencePart]
    if altushkaReferenceBefore then
        altushkaMainCubeCenter = altushkaReferencePart.Position
            + (altushkaMainCubeCenter - altushkaReferenceBefore) * altushkaMainCubeFactor
    elseif altushkaMainCubeFactor ~= 1 then
        warn(string.format(
            "meshpup: '%s': the placed Main could not follow the size normalization, so it may " ..
            "stand off the pet. Check Main's position after assembly.", model.Name))
    end
    altushkaMainCubeSize = altushkaMainCubeSize * altushkaMainCubeFactor
end

-- Synthetic root: a fixed 2x2x2 'Main' resting on the pet's base plane (asset-pack
-- convention — the pivot sits 1 stud above ground contact, like the packs' Main).
-- Non-colliding by default (pets are cosmetics); flip CanCollide for a solid statue.
--
-- The cube centres on what the pet STANDS on, not on the full bounds — a long tail
-- would otherwise drag it off the body. Parts whose world-AABB bottom sits within a
-- tolerance of the pet's lowest point are the standing parts; their footprint-weighted
-- centre gives Main's X/Z (footprint weighting keeps a floor-grazing tail tip from
-- pulling it). World AABBs, so a rotated import cannot fake its base plane.
local lowestBottom = math.huge
local partBottoms = {}
for i, part in ipairs(parts) do
    local cf, size = part.CFrame, part.Size
    local halfHeight = 0.5 * (math.abs(cf.RightVector.Y) * size.X
        + math.abs(cf.UpVector.Y) * size.Y
        + math.abs(cf.LookVector.Y) * size.Z)
    partBottoms[i] = cf.Position.Y - halfHeight
    if partBottoms[i] < lowestBottom then
        lowestBottom = partBottoms[i]
    end
end

local contactTolerance = math.max(0.5, boundsSize.Y * 0.05)
local mainX, mainZ, contactWeight = 0, 0, 0
for i, part in ipairs(parts) do
    if partBottoms[i] <= lowestBottom + contactTolerance then
        local weight = part.Size.X * part.Size.Z
        mainX = mainX + part.Position.X * weight
        mainZ = mainZ + part.Position.Z * weight
        contactWeight = contactWeight + weight
    end
end
if contactWeight > 0 then
    mainX = mainX / contactWeight
    mainZ = mainZ / contactWeight
else
    -- Degenerate (every standing part has a zero footprint): the old bounds centre.
    mainX = boundsCFrame.Position.X
    mainZ = boundsCFrame.Position.Z
end

local main = Instance.new("Part")
main.Name = "Main"
main.Size = Vector3.new(2, 2, 2)
main.CFrame = CFrame.new(mainX, lowestBottom + 1, mainZ)
main.Transparency = 1
main.CanCollide = false
-- Invisible, so it must not cast a shadow (his ruling, 2026-09-10). Transparency = 1 is
-- documented as "not rendered at all", but nothing in Roblox's documentation says that
-- suppresses shadow casting, and CastShadow is the only property that does — so a 2x2x2
-- square of shadow under every assembled pet was, at best, unverified. Safe either way:
-- the only caution the docs attach to disabling CastShadow is artifacts on shadows cast
-- ONTO the part, and this part is never rendered.
main.CastShadow = false
main.Anchored = true -- keep anchored for PivotTo-style followers (the usual pattern); unanchor only for physics constraints
main:SetAttribute("AltushkaPet", true)
-- Pivot-to-feet distance (studs): PivotTo(ground + Vector3.new(0, offset, 0)) puts the
-- feet exactly on the ground; a follow script's height tuning reads this instead of
-- hardcoding (e.g. FollowHeightOffset = -offset to cancel the hover).
main:SetAttribute("AltushkaBaseOffset", 1)
main.Parent = model
model.PrimaryPart = main

-- This pet's own Main: the root made above moves onto the placed box and takes its size;
-- every other property stays the root block's. AltushkaBaseOffset is where the box really
-- is — its centre's height above the pet's lowest point.
if altushkaMainCubeCenter then
    local altushkaLowestBottom = math.huge
    for _, part in ipairs(parts) do
        local cf, size = part.CFrame, part.Size
        local bottom = cf.Position.Y - 0.5 * (math.abs(cf.RightVector.Y) * size.X
            + math.abs(cf.UpVector.Y) * size.Y
            + math.abs(cf.LookVector.Y) * size.Z)
        if bottom < altushkaLowestBottom then
            altushkaLowestBottom = bottom
        end
    end
    main.Size = altushkaMainCubeSize
    main.CFrame = CFrame.new(altushkaMainCubeCenter)
    main:SetAttribute("AltushkaBaseOffset", altushkaMainCubeCenter.Y - altushkaLowestBottom)
end

-- Weld every part to Main; welded parts stop colliding and carrying mass.
for _, part in ipairs(parts) do
    local weld = Instance.new("WeldConstraint")
    weld.Name = part.Name
    weld.Part0 = main
    weld.Part1 = part
    weld.Parent = main
    part.Anchored = false
    part.CanCollide = false
    part.Massless = true

    -- SmoothPlastic on every part (his call, Phase 15.241). Studio's import default is
    -- Plastic, whose subtle speckle reads as noise on a small pet and fights the flat
    -- colours these parts are authored in; SmoothPlastic is the neutral surface that
    -- shows the mesh and the texture rather than a material on top of them.
    --
    -- Set here rather than left to the importer because the importer's default is not
    -- ours to rely on: it has changed before, and a pet that looks right only until
    -- Roblox adjusts a default is not a pet that was exported correctly.
    --
    -- Applies to a MeshPart through BasePart.Material, so it costs nothing and cannot
    -- fail on a part that carries a TextureID or a SurfaceAppearance.
    part.Material = Enum.Material.SmoothPlastic

    -- Double-sided, per MeshPart (Phase 15.242). TWO channels, one outcome:
    --
    --   DOUBLE_SIDED[part.Name]            the per-export table the generated script bakes,
    --                                      keyed by final assembled name;
    --   altushkaDoubleSidedCarriers[part]  the ASDbl| markers embedded in the .glb, which is
    --                                      the ONLY channel the generic companion plugin has
    --                                      (2026-08-06 field defect — it bakes an empty table
    --                                      by necessity, so before this the plugin path
    --                                      applied nothing at all).
    --
    -- Read in the SHARED block so one line reaches both delivery vehicles (AD-0098) and the
    -- two paths cannot drift apart again. A compound part is several MeshParts (AD-0119) and
    -- each one answers for itself.
    if DOUBLE_SIDED[part.Name] or altushkaDoubleSidedCarriers[part] then
        part.DoubleSided = true
    end

    -- The painted glow's colour (15.937), from the glow tags above, onto the SurfaceAppearance
    -- Import 3D made — and its strength (15.999): Import 3D leaves it at 1; 2 lets a full mask
    -- run its brightest channel to the top (an orange core toward yellow, its edge still orange)
    -- and stays under the 3 where a cyan core went white. No Bloom is added: that belongs to the
    -- game's Lighting. In a pcall: a Studio whose SurfaceAppearance has no EmissiveTint keeps
    -- the import's white glow rather than stopping the assembly.
    --
    -- The mask first (2026-10-18), from the glow tag above, and only onto a part that has no mask
    -- of its own: a new export's part carries none (its colour would arrive lightened by it), an
    -- older export's part does and keeps it. Its own pcall, so a Studio without the property still
    -- gets its tint.
    local altushkaGlow = altushkaGlowColours[part]
    if altushkaGlow then
        local altushkaSurface = part:FindFirstChildWhichIsA("SurfaceAppearance")
        if altushkaSurface then
            local altushkaMask = altushkaGlowMasks[part]
            if altushkaMask then
                pcall(function()
                    if altushkaSurface.EmissiveMaskContent.SourceType == Enum.ContentSourceType.None then
                        altushkaSurface.EmissiveMaskContent = altushkaMask
                    end
                end)
            end
            pcall(function()
                altushkaSurface.EmissiveTint = altushkaGlow
                altushkaSurface.EmissiveStrength = 2
            end)
        end
    end

    -- The zone's Roblox surface (1.6.0), from the surface tags above. A SurfaceAppearance hides the
    -- base material, so it is taken off — Parent = nil, never Destroy (15.166: undo must be able to
    -- put it back). ForceField keeps the colour map as the part's own texture, which drives its
    -- shimmer, and shows its inside (DoubleSided) without a shadow; Glass and Neon are a flat colour.
    -- Each Content write in a pcall, so a Studio without the property still gets its material.
    local altushkaSurfaceOf = altushkaSurfaces[part]
    if altushkaSurfaceOf then
        local altushkaLook = part:FindFirstChildWhichIsA("SurfaceAppearance")
        if altushkaSurfaceOf.word == "forcefield" then
            pcall(function()
                if altushkaLook and part.TextureContent.SourceType == Enum.ContentSourceType.None then
                    part.TextureContent = altushkaLook.ColorMapContent
                end
            end)
            part.DoubleSided = true
            part.CastShadow = false
        elseif altushkaSurfaceOf.word ~= "smoothplastic" then
            pcall(function()
                part.TextureID = ""
            end)
        end
        if altushkaLook and altushkaSurfaceOf.word ~= "smoothplastic" then
            altushkaLook.Parent = nil
        end
        part.Material = altushkaSurfaceOf.material
        if altushkaSurfaceOf.word ~= "smoothplastic" then
            part.Color = altushkaSurfaceOf.colour
        end
        part.Transparency = altushkaSurfaceOf.transparency
    end

    -- The lights this part carries (1.6.0), from the light tags above: a PointLight or SpotLight
    -- on the Attachment made there (a spot shines along the Attachment's front), a SurfaceLight on
    -- the part itself from the face nearest its aim. Range is clamped by Roblox itself.
    local altushkaLights = altushkaLightsByCarrier[part]
    if altushkaLights then
        for _, spec in ipairs(altushkaLights) do
            local light
            if spec.kind == "F" then
                light = Instance.new("SurfaceLight")
                light.Face = spec.face
                light.Angle = spec.angle
            elseif spec.kind == "S" then
                light = Instance.new("SpotLight")
                light.Face = Enum.NormalId.Front
                light.Angle = spec.angle
            else
                light = Instance.new("PointLight")
            end
            light.Name = "AltushkaLight" .. spec.index
            light.Color = spec.color
            light.Brightness = spec.brightness
            light.Range = spec.range
            light.Shadows = spec.shadows
            light.Parent = spec.attachment or part
        end
    end
end

-- Introspection for game code (read these instead of hardcoding per pet).
main:SetAttribute("AltushkaPartCount", #parts)
if APP_VERSION ~= "" then
    main:SetAttribute("AltushkaVersion", APP_VERSION)
end

-- A stable anchor for effects (trails, particles, nametags) at the pivot.
local rootAttachment = Instance.new("Attachment")
rootAttachment.Name = "RootAttachment"
rootAttachment.Parent = main

-- Ground-snap (Phase 15.164): Import 3D inserts the model camera-relative — usually
-- hovering mid-air — and assembly used to leave it there for a manual move. Ray straight
-- down from just above the pet (so an already-sunk pet is found from above), ignoring
-- the pet itself and anything non-collidable; the feet land exactly on the hit via the
-- AltushkaBaseOffset contract. No hit (void below) leaves the pet where it is.
-- Only the pivot's Y moves — X/Z and orientation are preserved (FullBounds pivots can
-- legitimately be rotated, so the target CFrame keeps the current rotation).
local rayParams = RaycastParams.new()
rayParams.FilterType = Enum.RaycastFilterType.Exclude
rayParams.FilterDescendantsInstances = { model }
rayParams.RespectCanCollide = true
local pivot = model:GetPivot()
local rayOrigin = Vector3.new(
    pivot.Position.X,
    boundsCFrame.Position.Y + (boundsSize.Y / 2) + 1,
    pivot.Position.Z)
local groundHit = workspace:Raycast(rayOrigin, Vector3.new(0, -10000, 0), rayParams)
if groundHit then
    model:PivotTo(pivot.Rotation + Vector3.new(
        pivot.Position.X,
        groundHit.Position.Y + main:GetAttribute("AltushkaBaseOffset"),
        pivot.Position.Z))
end

-- An empty PET_NAME keeps the imported name (the companion plugin's generic path).
if PET_NAME ~= "" then
    model.Name = PET_NAME
end

table.insert(assembledModels, model)
print(string.format("meshpup: '%s' assembled — %d parts welded to Main.", model.Name, #parts))

-- The texture table the effect block below closes over. The per-export script bakes
-- it; the plugin starts it empty and fills it from the ghost tags.
local ALTUSHKA_FX_TEXTURES = {}

-- meshpup VFX. Parents each layer's emitter to the part of the pet that layer rides.
--
-- WHY A PART OF THE PET, AND NOT A HOST PART OF OUR OWN: one invisible host part per layer
-- ran clean in Studio, and putting the emitters straight onto the pet's root instead read
-- "much better" — so the host parts went. Riding a part the USER PICKS is the step past that:
-- it makes placement a control, so fire can sit on the tail and sparkles round the head instead
-- of everything emitting from the ankles. A BasePart takes any number of ParticleEmitter
-- children and they all emit from its volume, so N layers on one part is the platform's own
-- model rather than a trick.
--
-- THE PART'S Size IS THE EMISSION VOLUME, ON EVERY AXIS INDEPENDENTLY. Measured: a 2x2x2 cube
-- gives a round ball, a 1x8x1 bar a tall narrow column, an 8x1x1 bar a long flat ellipse. It is
-- NOT sized to the smallest axis. So a long tail gives a long thin cloud, and nothing in this
-- block sizes anything — there is no host part left to size.
--
-- WHY NOT THE RootAttachment, which is the other obvious anchor: an emitter parented to an
-- Attachment emits from a ZERO volume — Roblox treats anything else as a bug (release v0.683)
-- and the guide says outright that Sphere and Cylinder "will not display correctly" there. So
-- every shape control would be a lie.
--
-- ALTUSHKA_FX_TEXTURES is declared ABOVE this block by whichever vehicle carries the effect:
-- the per-export script bakes it from the app's sprite manifest, and the companion plugin
-- declares it empty and fills it from the effect tags inside the model. A layer whose sprite
-- is not in it keeps the engine default and is named once at the end, rather than warning per
-- layer.
--
-- A pet can wear several effects, and the same effect more than once (two copies for two
-- wings). altushkaApplyFx applies ONE of them; altushkaApplyEffects, below it, applies them all.

local function altushkaApplyFx(model, main, fx)
    -- !! IT SAYS SO RATHER THAN RETURNING QUIETLY, AND THAT IS THE WHOLE POINT OF THE MESSAGE.
    -- The failure this guard catches is a chunk appended where `main` does not exist yet — which
    -- two of our own documents told a reader to do. Returning 0 in silence is what would make
    -- that mistake invisible: green tests here, and a pet with nothing on it in Studio.
    if not (model and main and fx and fx.layers) then
        warn("meshpup: the effect block was called before the pet's Main root existed, so no " ..
            "emitters were created. The chunk belongs AFTER the Main block, between SetupSuffix " ..
            "and the end of the per-model loop.")
        return 0
    end

    local missingTextures = {}
    local fellBackToMain = {}
    local hostParts = {}
    local emitterCount = 0

    -- A copy past the first of the same effect carries its number in every emitter's name,
    -- AltushkaFX_core_2, the way the app calls it "Ember Aura 2" — so two copies of ONE effect
    -- riding one part stay told apart. The first copy's names are the ones every export has always
    -- written. Two DIFFERENT effects that share a layer id (two effects both with a "core") can
    -- give one part two emitters of the same name: Studio allows that, and nothing here or in
    -- the app looks an emitter up by its name.
    local nameSuffix = ""
    if fx.tag and fx.tag ~= "" then
        nameSuffix = "_" .. fx.tag
    end

    for _, layer in ipairs(fx.layers) do
        -- The part this layer rides, by FINAL assembled name — the same channel FACE_DECALS and
        -- DOUBLE_SIDED use, and deterministic for the same reason: the exported node name is
        -- unique app-side and the flatten pass above reproduces it exactly. An empty name means
        -- the pet never chose a part, which is the ordinary state.
        local host = main
        local wanted = layer.host

        if wanted and wanted ~= "" then
            local chosen = model:FindFirstChild(wanted)
            if chosen and chosen:IsA("BasePart") then
                host = chosen
            else
                -- FALL BACK, KEEP THE REFERENCE, AND SAY SO. The part can be missing here for
                -- reasons the app could not see: excluded from the export, or renamed in Studio
                -- between the import and this paste. Main always exists, so an emitter has
                -- somewhere to go in every state and a layer is never silently dropped.
                table.insert(fellBackToMain, string.format("%s (wanted '%s')", layer.id, wanted))
            end
        end

        -- Configure fully, THEN parent. Documented twice by Roblox, for replication rather
        -- than taste: "This ensures the object replicates once, instead of replicating many
        -- property changes."
        local emitter = Instance.new("ParticleEmitter")

        -- Prefixed, because this emitter now lives among the pet's own children — beside a face
        -- Decal and whatever the owner adds later. A bare layer id would be ours only by
        -- coincidence.
        emitter.Name = "AltushkaFX_" .. layer.id .. nameSuffix
        emitter.Rate = layer.rate
        emitter.Lifetime = layer.lifetime
        emitter.Speed = layer.speed
        emitter.SpreadAngle = layer.spread
        emitter.Shape = layer.shape
        emitter.ShapeStyle = layer.style
        emitter.ShapeInOut = layer.inOut
        emitter.ShapePartial = layer.partial
        emitter.Acceleration = layer.acceleration
        emitter.Drag = layer.drag
        emitter.Size = layer.size
        emitter.Transparency = layer.transparency
        emitter.Squash = layer.squash
        emitter.Color = layer.color
        emitter.LightEmission = layer.lightEmission

        -- Brightness: Roblox documents it as scaling the light the emitter gives off "when
        -- LightInfluence is 0", which this block always writes, below. Measured in Studio
        -- (2026-09-24): the colour is multiplied by it and capped, so a solid colour goes towards
        -- white while a soft sprite's fringe keeps its hue. There is no maximum to respect.
        emitter.Brightness = layer.brightness
        emitter.ZOffset = layer.zOffset
        emitter.Rotation = layer.rotation
        emitter.RotSpeed = layer.rotSpeed
        emitter.Enabled = true

        -- "Stick to part" in the app. A follow script moves an anchored pet with PivotTo, so the
        -- pet has no velocity for VelocityInheritance to pass on: without this, particles stay
        -- where they were born and the pet walks away from them. Locked, they move with the
        -- part, as the auras on the Sim Pack's Huge pets do. Off unless the layer asked for it.
        emitter.LockedToPart = layer.lockedToPart == true

        -- !! THE ONE PROPERTY THAT MUST BE WRITTEN EXPLICITLY. It is the only Studio-vs-script
        -- default divergence in the entire API: "By default, this value is 1 if inserted with
        -- Studio tools. If inserted using Instance.new(), it is 0." Leaning on the default is
        -- how Brightness appears dead and an effect renders unlit at full brightness — and
        -- Brightness is written above, so it is the property this line exists for.
        emitter.LightInfluence = 0

        local assetId = ALTUSHKA_FX_TEXTURES[layer.textureId]
        if assetId then
            emitter.Texture = assetId
        else
            table.insert(missingTextures, layer.textureId)
        end

        emitter.Parent = host

        emitterCount = emitterCount + 1
        hostParts[host] = true
    end

    -- Distinct parts carrying emitters. Several layers sharing one part is the ordinary shape —
    -- it is what per-layer host parts were given up to allow — so this is a key count, not #.
    local partCount = 0
    for _ in pairs(hostParts) do
        partCount = partCount + 1
    end

    -- !! AltushkaPartCount IS NOT TOUCHED HERE. It is documented as "the REAL welded count" and
    -- follow scripts read it. There is no longer any FX part that could inflate it, which is a
    -- stronger guarantee than the old separation — but the separation stays written down,
    -- because the reason for it outlived the parts.
    --
    -- Nor are the effect's own two attributes: altushkaApplyEffects writes them once, after every
    -- copy. Written here, the pet's second effect would overwrite the first's.

    if #fellBackToMain > 0 then
        -- warn, not print, and the distinction is deliberate: an unpublished texture is the
        -- system working, while a layer that lost its part is a divergence from what the person
        -- set up, and they can act on it.
        warn(string.format(
            "meshpup: '%s' — %d layer(s) could not find the part they ride and emit from Main " ..
            "instead: %s. Re-export the pet, or check whether the part was renamed in Studio.",
            fx.name, #fellBackToMain, table.concat(fellBackToMain, ", ")))
    end

    if #missingTextures > 0 then
        print(string.format(
            "meshpup: '%s' applied — %d emitter(s) on %d part(s); %d texture id(s) not yet published.",
            fx.name, emitterCount, partCount, #missingTextures))
    else
        print(string.format(
            "meshpup: '%s' applied — %d emitter(s) on %d part(s).",
            fx.name, emitterCount, partCount))
    end

    return emitterCount
end

-- Every effect the pet wears, in the order the app lists them — each through altushkaApplyFx —
-- and then the pet's two effect attributes, ONCE: AltushkaFXId, the effects' ids joined with
-- commas (the same id twice is two copies of it), and AltushkaFXEmitterCount, every copy's
-- emitters summed. Both vehicles call this: the per-export script with the list it bakes, the
-- companion plugin with the list it reads from the model.
local function altushkaApplyEffects(model, main, effects)
    -- The same misplacement altushkaApplyFx's own guard names, said here as well because the two
    -- attributes below are written on main.
    if not (model and main and effects) then
        warn("meshpup: the effect block was called before the pet's Main root existed, so no " ..
            "emitters were created. The chunk belongs AFTER the Main block, between SetupSuffix " ..
            "and the end of the per-model loop.")
        return 0
    end

    local effectIds = {}
    local emitterCount = 0

    for _, fx in ipairs(effects) do
        emitterCount = emitterCount + altushkaApplyFx(model, main, fx)
        if fx.id then
            table.insert(effectIds, fx.id)
        end
    end

    main:SetAttribute("AltushkaFXId", table.concat(effectIds, ","))
    main:SetAttribute("AltushkaFXEmitterCount", emitterCount)

    return emitterCount
end

-- meshpup VFX, read back from the model itself (15.704, AD-0976).
--
-- The plugin is generic, so it cannot carry a pet's effects the way the per-export setup
-- script bakes ALTUSHKA_FX_EFFECTS. The export writes the RESOLVED effects into the NAMES of
-- tiny ghost nodes instead, because a node name is the one thing Import 3D keeps — and only 50
-- characters of it, so the effects are cut into numbered pieces:
--   ASFxE|<format>|<pieces>        one header
--   ASFx|001|<up to 41 characters>    one per piece, in any order
-- The assembly core above collected them into effectTags and already took them off the pet.
-- This joins the pieces and rebuilds exactly the list the setup script would have baked —
-- same numbers, formatted by the same code — for the same altushkaApplyEffects. A missing
-- piece, a newer format or anything unreadable costs the EFFECTS, with a warning saying why,
-- and never the assembly.
local function altushkaReadEffectGhosts(tags)
    if #tags == 0 then
        return nil, nil, nil
    end

    local format, total
    local pieces = {}
    for _, tag in ipairs(tags) do
        local headerFormat, headerTotal = string.match(tag.Name, "^ASFxE|(%d+)|(%d+)$")
        local index, piece = string.match(tag.Name, "^ASFx|(%d%d%d)|(.*)$")
        if headerFormat then
            format, total = tonumber(headerFormat), tonumber(headerTotal)
        elseif index then
            local position = tonumber(index)
            if pieces[position] ~= nil and pieces[position] ~= piece then
                return nil, nil, "two different pieces are both numbered " .. index
            end
            pieces[position] = piece
        end
    end

    if not format then
        return nil, nil, "its header tag is missing"
    end
    -- Formats 1 to 4 are read. Format 2 added each layer's
    -- Brightness; an older one has none, and its layers get the engine's default below — so a
    -- pet exported before that still sets up exactly as it did. Format 3 carries
    -- several effects (two copies for two wings); an older one is always one effect. Format
    -- 4 adds whether each layer sticks to its part; an older one never does.
    if format > 4 then
        -- The whole recovery, not half of it (row B2's review): the app rewrites this plugin at
        -- launch only while its setting is on, and Studio loads plugins when it starts.
        return nil, nil, string.format(
            "it is in format %d, from a newer meshpup — relaunch the app with Settings' " ..
            "'Keep Roblox Studio plugin installed' on, then restart Roblox Studio",
            format)
    end
    if format < 1 then
        return nil, nil, string.format("it is in format %d, which no meshpup writes", format)
    end

    local joined = {}
    for i = 1, total do
        if pieces[i] == nil then
            return nil, nil, string.format("piece %d of %d is missing", i, total)
        end
        joined[i] = pieces[i]
    end

    -- Every string field was percent-encoded by the exporter, so a bar inside a part name
    -- arrives as %7C and this split sees only the separators.
    local fields = string.split(table.concat(joined), "|")
    local cursor = 0

    local function field()
        cursor = cursor + 1
        local value = fields[cursor]
        if value == nil then
            error("the data ends early", 0)
        end
        return value
    end
    local function text()
        return (string.gsub(field(), "%%(%x%x)", function(hex)
            return string.char(tonumber(hex, 16))
        end))
    end
    local function number(value)
        local parsed = tonumber(value)
        if parsed == nil then
            error("'" .. value .. "' is not a number", 0)
        end
        return parsed
    end
    local function numbers(value, separator, count)
        local parsed = {}
        for _, part in ipairs(string.split(value, separator)) do
            table.insert(parsed, number(part))
        end
        if #parsed ~= count then
            error(string.format("'%s' should hold %d numbers", value, count), 0)
        end
        return parsed
    end
    local function range()
        local values = numbers(field(), ",", 2)
        return NumberRange.new(values[1], values[2])
    end
    local function sequence()
        local keypoints = {}
        for _, keypoint in ipairs(string.split(field(), ";")) do
            local values = numbers(keypoint, ",", 3)
            table.insert(keypoints, NumberSequenceKeypoint.new(values[1], values[2], values[3]))
        end
        return NumberSequence.new(keypoints)
    end
    -- An enum item by NAME, strictly. Enum.X[name] would also answer a METHOD name such as
    -- GetEnumItems with a function, and the emitter assignment that then fails sits outside the
    -- pcall below, where it would cost the whole assembly rather than the effect (a Fable 5.1
    -- review found it). Walking the items accepts real members only. The name is read at the
    -- call site, like every other field, so the reads stay visible in the encoder's order.
    local function enumItem(enumType, name)
        for _, item in ipairs(enumType:GetEnumItems()) do
            if item.Name == name then
                return item
            end
        end
        error("'" .. name .. "' is not a " .. tostring(enumType), 0)
    end
    local function colours()
        local keypoints = {}
        for _, keypoint in ipairs(string.split(field(), ";")) do
            local time, hex = string.match(keypoint, "^([^,]+),(%x%x%x%x%x%x)$")
            if not time then
                error("'" .. keypoint .. "' is not a colour keypoint", 0)
            end
            table.insert(keypoints, ColorSequenceKeypoint.new(number(time), Color3.fromRGB(
                tonumber(string.sub(hex, 1, 2), 16),
                tonumber(string.sub(hex, 3, 4), 16),
                tonumber(string.sub(hex, 5, 6), 16))))
        end
        return ColorSequence.new(keypoints)
    end

    -- pcall because Roblox's own constructors are the final validators: an enum name it does
    -- not know, or a curve whose first keypoint is not at time 0, raises — and must cost the
    -- effects, not the assembly around them.
    -- Every read below is a statement of its own, in the encoder's field order, rather than
    -- two calls inside one table constructor, whose evaluation order Lua does not promise.
    local ok, effects, imageIds = pcall(function()
        -- One effect copy: its id, its name, from format 3 its tag (what its
        -- emitters' names end in — "" for the first copy of an effect), then its layers.
        local function readEffect()
            local effectId = text()
            local effectName = text()
            local effectTag = ""
            if format >= 3 then
                effectTag = text()
            end
            local effect = { id = effectId, name = effectName, tag = effectTag, layers = {} }
            for _ = 1, number(field()) do
                local layer = {}
                layer.id = text()
                layer.host = text()
                layer.textureId = text()
                layer.rate = number(field())
                layer.lifetime = range()
                layer.speed = range()
                local spread = number(field())
                layer.spread = Vector2.new(spread, spread)
                layer.shape = enumItem(Enum.ParticleEmitterShape, text())
                layer.style = enumItem(Enum.ParticleEmitterShapeStyle, text())
                layer.inOut = enumItem(Enum.ParticleEmitterShapeInOut, text())
                layer.partial = number(field())
                local acceleration = numbers(field(), ",", 3)
                layer.acceleration = Vector3.new(acceleration[1], acceleration[2], acceleration[3])
                layer.drag = number(field())
                layer.size = sequence()
                layer.transparency = sequence()
                layer.squash = sequence()
                layer.color = colours()
                layer.lightEmission = number(field())
                -- ONE assignment on purpose: a format-2 layer carries the field
                -- and an older one does not, and `and`/`or` reads it only when it is there (a 0 is
                -- still a number, so it survives the `or`).
                layer.brightness = format >= 2 and number(field()) or 1
                layer.zOffset = number(field())
                layer.rotation = range()
                layer.rotSpeed = range()
                layer.lockedToPart = false
                if format >= 4 then
                    local locked = field()
                    if locked ~= "1" and locked ~= "0" then
                        error("'" .. locked .. "' is not 1 or 0", 0)
                    end
                    layer.lockedToPart = locked == "1"
                end
                table.insert(effect.layers, layer)
            end
            return effect
        end

        -- A format-3 stream starts with how many copies follow; an older one is
        -- one effect, the pet's first and only copy.
        local copyCount = 1
        if format >= 3 then
            copyCount = number(field())
        end
        local list = {}
        for _ = 1, copyCount do
            table.insert(list, readEffect())
        end
        -- ONE texture list for every copy, as the script bakes ONE ALTUSHKA_FX_TEXTURES.
        local images = {}
        for _ = 1, number(field()) do
            local textureId = text()
            images[textureId] = "rbxassetid://" .. text()
        end
        if cursor ~= #fields then
            error("there is data after the end", 0)
        end
        return list, images
    end)

    if not ok then
        return nil, nil, tostring(effects)
    end
    return effects, imageIds, nil
end

local altushkaGhostEffects, altushkaGhostImages, altushkaGhostProblem =
    altushkaReadEffectGhosts(effectTags)
if altushkaGhostEffects then
    for textureId, imageUrl in pairs(altushkaGhostImages) do
        ALTUSHKA_FX_TEXTURES[textureId] = imageUrl
    end
    altushkaApplyEffects(model, main, altushkaGhostEffects)
elseif altushkaGhostProblem then
    -- warn, because the pet the user set up is not the pet they got, and they can act on it.
    warn(string.format(
        "meshpup: '%s' was assembled WITHOUT its effects — the effect tags inside the model " ..
        "could not be read (%s). Re-export the pet, or paste its setup script, which carries " ..
        "them in full.",
        model.Name, altushkaGhostProblem))
end

end

Selection:Set(assembledModels)

if #assembledModels > 1 then
    print(string.format("meshpup: %d pets assembled.", #assembledModels))
end
end

assembleButton.Click:Connect(function()
    local recording = ChangeHistoryService:TryBeginRecording("meshpup: Assemble Pet")
    if not recording then
        -- Official pattern (15.74): no recording means no undo and no auto-rollback
        -- on failure — refuse rather than assemble un-undoably.
        warn("meshpup: could not begin an undo recording — assembly cancelled, model untouched. Try again.")
        assembleButton:SetActive(false)
        return
    end
    local ok, problem = pcall(assembleSelection)

    if recording then
        ChangeHistoryService:FinishRecording(recording,
            ok and Enum.FinishRecordingOperation.Commit or Enum.FinishRecordingOperation.Cancel)
    end

    if not ok then
        warn(tostring(problem))
    end

    assembleButton:SetActive(false)
end)