-- 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| 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||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|||RRGGBB| -- 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|| one header -- ASFx|001| 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)