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.
- One buttonAssemble Pet. It does nothing until you press it.
- No internetNo web requests, no account, no tracking.
- Only your petIt changes the model you assemble and nothing else in your place.
- UndoableOne Ctrl/⌘-Z puts the import back exactly as it was.
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
- 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.
- 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.
- 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.
- It builds the root. An invisible part called
Maingoes 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. - 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.
- 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).
- 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 Main | What it means |
|---|---|
AltushkaPet | true — marks the model as an assembled meshpup pet. |
AltushkaBaseOffset | Studs from Main's centre down to the pet's feet. Pivot to ground + this and the feet touch the ground. |
AltushkaPartCount | How many parts are welded to Main. |
AltushkaVersion | The app version that made the plugin. |
AltushkaFXId | The pet's effects, comma-separated. Only on pets with effects. |
AltushkaFXEmitterCount | How 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
-- 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)