From 386532164e7a70674dadc91ff0e2ca2f103ab49e Mon Sep 17 00:00:00 2001 From: ilker2445 <103515424+ilker2445@users.noreply.github.com> Date: Sun, 14 Jun 2026 23:02:11 +0300 Subject: [PATCH 1/8] Add workshop dependency graph resolver --- lua/outfitter/cows.lua | 69 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/lua/outfitter/cows.lua b/lua/outfitter/cows.lua index 35e40f7..834178e 100644 --- a/lua/outfitter/cows.lua +++ b/lua/outfitter/cows.lua @@ -479,6 +479,75 @@ function coDecompress(path) return 'data/' .. safepath end +local MAX_DEPENDENCY_COUNT = 64 +local MAX_DEPENDENCY_DEPTH = 16 + +function coResolveWSDependencies(wsid) + wsid = tostring(wsid) + + local graph = { + root = wsid, + nodes = {}, + order = {}, + total_size = 0, + count = 0, + max_depth = 0 + } + + local function resolve(id, depth) + id = tostring(id) + + if depth > MAX_DEPENDENCY_DEPTH then + return nil, "dependency depth" + end + + graph.max_depth = math.max(graph.max_depth, depth) + + local node = graph.nodes[id] + if node then return true end + + if depth > 0 and graph.count >= MAX_DEPENDENCY_COUNT then + return nil, "dependency count" + end + + local fileinfo = co_steamworks_FileInfo(id) + if not fileinfo then return nil, id .. ": fileinfo" end + + local size = tonumber(fileinfo.size or 0) or 0 + node = { + id = id, + size = size, + title = fileinfo.title, + children = {} + } + graph.nodes[id] = node + + if depth > 0 then + graph.count = graph.count + 1 + graph.total_size = graph.total_size + size + end + + for _, child in next, fileinfo.children or {} do + child = tostring(child) + node.children[#node.children + 1] = child + + local ok, err = resolve(child, depth + 1) + if not ok then return nil, err end + end + + if depth > 0 then + graph.order[#graph.order + 1] = id + end + + return true + end + + local ok, err = resolve(wsid, 0) + if not ok then return nil, err end + + return graph +end + local function coMountWSDependency(wsid, seen) wsid = tostring(wsid) if seen[wsid] then return true end From e9e4f2807e7b6b053e003f11c4d624703a24fdc4 Mon Sep 17 00:00:00 2001 From: ilker2445 <103515424+ilker2445@users.noreply.github.com> Date: Sun, 14 Jun 2026 23:10:45 +0300 Subject: [PATCH 2/8] Transmit selected outfit dependencies --- lua/outfitter/cl.lua | 25 ++++---- lua/outfitter/cl_util.lua | 6 ++ lua/outfitter/cows.lua | 116 +++++++++++++++++++++++--------------- lua/outfitter/net.lua | 13 +++-- lua/outfitter/sh.lua | 88 +++++++++++++++++++++++++---- lua/outfitter/sv.lua | 10 ++-- lua/outfitter/ui.lua | 15 +++++ 7 files changed, 195 insertions(+), 78 deletions(-) diff --git a/lua/outfitter/cl.lua b/lua/outfitter/cl.lua index 7e1b5f9..d31ca0a 100644 --- a/lua/outfitter/cl.lua +++ b/lua/outfitter/cl.lua @@ -67,9 +67,9 @@ local Player = FindMetaTable "Player" ------- player outfit changing -------- -function Player.SetWantOutfit(pl, mdl, download_info, skin, bodygroups) +function Player.SetWantOutfit(pl, mdl, download_info, skin, bodygroups, dependency_manifest) dbg("SetWantOutfit", pl, not mdl and "unset" or ('%q'):format(tostring(mdl)), - not download_info and "-" or ('%q'):format(tostring(download_info))) + not download_info and "-" or ('%q'):format(tostring(download_info)), DependencyManifestID(dependency_manifest)) assert(pl and pl:IsValid()) pl:GetModel() @@ -79,7 +79,7 @@ function Player.SetWantOutfit(pl, mdl, download_info, skin, bodygroups) mdl = mdl or false - pl:OutfitSetInfo(mdl, download_info, skin, bodygroups) + pl:OutfitSetInfo(mdl, download_info, skin, bodygroups, dependency_manifest) local thread = pl.outfitter_co_thread @@ -157,7 +157,7 @@ function ChangeOutfitThreadWorker(pl, hash) assert(pl:OutfitCheckHash(hash)) assert(not HBAD(pl, hash)) - local mdl, download_info, skin, bodygroups = pl:OutfitInfo() + local mdl, download_info, skin, bodygroups, dependency_manifest = pl:OutfitInfo() mdl = mdl or false dbg("ChangeOutfit", "BEGIN", pl, mdl or "unset", download_info) @@ -174,9 +174,10 @@ function ChangeOutfitThreadWorker(pl, hash) if tonumber(download_info) then -- The model may have been mounted before its required items. if ShouldMountChildren() then - local ok, err = coMountWSChildren(download_info) + local ok, err, err2 = coMountWSChildren(download_info, dependency_manifest) if not ok then - dbg("ChangeOutfit", download_info, "child mount fail", err) + dbg("ChangeOutfit", download_info, "child mount fail", err, err2) + coUIDependencyFailureMsg(pl, download_info, err, err2) end end @@ -205,7 +206,7 @@ function ChangeOutfitThreadWorker(pl, hash) end ------------ TIME PASSES ONLY HERE ------------- - local ok, err = AcquireAssets(download_info, pl, mdl) + local ok, err = AcquireAssets(download_info, pl, mdl, dependency_manifest) if not ok then dbg("DoChangeOutfit", "NeedWS failed", err, "continuing...", pl, mdl, download_info) if err == 'oversize' then @@ -247,9 +248,9 @@ function ChangeOutfitThreadWorker(pl, hash) return true end -function AcquireAssets(download_info, pl, mdl) +function AcquireAssets(download_info, pl, mdl, dependency_manifest) if download_info and tonumber(download_info) then - return NeedWS(download_info, pl, mdl) + return NeedWS(download_info, pl, mdl, dependency_manifest) end if IsHTTPURL(download_info) then if AllowedHTTPURL(download_info) then @@ -268,10 +269,10 @@ end function BroadcastMyOutfit(a) assert(not a) - local mdl, download_info, s, bg = LocalPlayer():OutfitInfo() - dbg("BroadcastMyOutfit", mdl, download_info, s, bg) + local mdl, download_info, s, bg, dependency_manifest = LocalPlayer():OutfitInfo() + dbg("BroadcastMyOutfit", mdl, download_info, s, bg, DependencyManifestID(dependency_manifest)) - NetworkOutfit(mdl, download_info) + NetworkOutfit(mdl, download_info, dependency_manifest) return mdl, download_info end diff --git a/lua/outfitter/cl_util.lua b/lua/outfitter/cl_util.lua index feb6ee7..de71121 100644 --- a/lua/outfitter/cl_util.lua +++ b/lua/outfitter/cl_util.lua @@ -200,9 +200,15 @@ end do local outfitter_mount_children = CreateClientConVar("outfitter_mount_children_test", "0", true) + outfitter_dependency_maxsize = CreateClientConVar("outfitter_dependency_maxsize", "60", true) + function ShouldMountChildren() return outfitter_mount_children:GetBool() end + + function DependencyMaxSize() + return outfitter_dependency_maxsize:GetFloat() * 1000 * 1000 + end end --TODO diff --git a/lua/outfitter/cows.lua b/lua/outfitter/cows.lua index 834178e..64f0b4f 100644 --- a/lua/outfitter/cows.lua +++ b/lua/outfitter/cows.lua @@ -39,7 +39,6 @@ end local fetching = {} local res = {} -local skip_maxsizes = {} local function SYNC(cbs, ...) for k, cb in next, cbs do cb(...) @@ -167,10 +166,6 @@ end function coFetchWS(wsid, skip_maxsize) - if skip_maxsize then - skip_maxsizes[wsid] = true - end - -- fetch cache ("promise/future") (no double-fetching) local dat = fetching[wsid] @@ -265,8 +260,6 @@ function coFetchWS(wsid, skip_maxsize) maxsz = maxsz * 1000 * 1000 if maxsz > 0.1 and ((fileinfo.size or 0) - 1024 * 1024) > maxsz then - skip_maxsize = skip_maxsize or skip_maxsizes[wsid] - dbg("FetchWS", "MAXSIZE", skip_maxsize and "OVERRIDE" or "", wsid, string.NiceSize(fileinfo.size or 0)) if not skip_maxsize then @@ -548,48 +541,77 @@ function coResolveWSDependencies(wsid) return graph end -local function coMountWSDependency(wsid, seen) - wsid = tostring(wsid) - if seen[wsid] then return true end - seen[wsid] = true +function DependencyManifestFromGraph(graph) + local dependencies = {} + for _, id in next, graph.order do + dependencies[#dependencies + 1] = id + end - local fileinfo = co_steamworks_FileInfo(wsid) - if not fileinfo then return nil, "fileinfo" end + return NormalizeDependencyManifest({ + version = 1, + dependencies = dependencies + }) +end - local child_error - for _, child in next, fileinfo.children or {} do - local ok, err = coMountWSDependency(child, seen) - if not ok then - child_error = child_error or (tostring(child) .. ": " .. tostring(err)) +function coPlanWSDependencies(wsid, dependency_manifest) + local manifest, err = NormalizeDependencyManifest(dependency_manifest) + if not manifest then return nil, err or "dependency manifest" end + + local plan = { + manifest = manifest, + order = {}, + total_size = 0, + count = #manifest.dependencies + } + if plan.count == 0 then return plan end + + local graph, err = coResolveWSDependencies(wsid) + if not graph then return nil, err end + + local selected = {} + for _, id in next, manifest.dependencies do + local node = graph.nodes[id] + if not node or id == graph.root then + return nil, "invalid dependency" end + + selected[id] = true + plan.total_size = plan.total_size + node.size end - local path, err = coFetchWS(wsid) - if not path then return nil, err end + local maxsize = DependencyMaxSize() + if maxsize > 0.1 and plan.total_size > maxsize then + return nil, "dependency oversize", plan.total_size + end - local ok, err = coMountWS(path) - if not ok then return nil, err end - if child_error then return nil, child_error end + for _, id in next, graph.order do + if selected[id] then + plan.order[#plan.order + 1] = id + end + end - return true + return plan end -local function _coMountWSChildren(wsid) - -- TODO: fix filesize limiter: https://steamcommunity.com/workshop/filedetails/?id=1640675931 - - local fileinfo = co_steamworks_FileInfo(wsid) - if not fileinfo then return nil, "fileinfo" end - - local seen = {[tostring(wsid)] = true} - local child_error - for _, child in next, fileinfo.children or {} do - local ok, err = coMountWSDependency(child, seen) - if not ok then - child_error = child_error or (tostring(child) .. ": " .. tostring(err)) +local function _coMountWSChildren(key, wsid, dependency_manifest) + local plan, err, err2 = coPlanWSDependencies(wsid, dependency_manifest) + if not plan then return nil, err, err2 end + + local first_error + for _, id in next, plan.order do + local path, err = coFetchWS(id, true) + if path then + local ok + ok, err = coMountWS(path) + if not ok then + first_error = first_error or (id .. ": " .. tostring(err)) + end + else + first_error = first_error or (id .. ": " .. tostring(err)) end end - if child_error then return nil, child_error end + if first_error then return nil, "dependency failed", first_error end return true end @@ -599,12 +621,19 @@ local worker, cache = co.work_cacher_filter( end, co.work_cacher(_coMountWSChildren) ) -coMountWSChildren = co.worker(worker) +local coMountWSDependencyManifest = co.worker(worker) + +function coMountWSChildren(wsid, dependency_manifest) + if not dependency_manifest then return true end + + local key = tostring(wsid) .. "|" .. DependencyManifestID(dependency_manifest) + return coMountWSDependencyManifest(key, wsid, dependency_manifest) +end --TODO: own cache -function NeedWS(wsid, pl, mdl) +function NeedWS(wsid, pl, mdl, dependency_manifest) assert(tonumber(wsid), "NeedWS invalid wsid: " .. tostring(wsid)) - if co.make(wsid, pl, mdl) then return end + if co.make(wsid, pl, mdl, dependency_manifest) then return end -- already mounted, don't mount again if steamworks.IsSubscribed(wsid) and file.Exists(mdl, 'GAME') then return true end @@ -673,9 +702,10 @@ function NeedWS(wsid, pl, mdl) end if ShouldMountChildren() then - local children_ok, children_err = coMountWSChildren(wsid) + local children_ok, children_err, children_err2 = coMountWSChildren(wsid, dependency_manifest) if not children_ok then - dbg("NeedWS", wsid, "child mount fail", children_err) + dbg("NeedWS", wsid, "child mount fail", children_err, children_err2) + coUIDependencyFailureMsg(pl, wsid, children_err, children_err2) end end @@ -768,8 +798,6 @@ local function checkhttp(ok, ret, len, hdrs, retcode) maxsz = maxsz * 1000 * 1000 if size and maxsz >= 1 and size > math.min(maxsz, 1024 * 1024 * 1024) then - skip_maxsize = skip_maxsize or skip_maxsizes[wsid] - dbg("NeedHTTPGMA", "MAXSIZE", skip_maxsize and "OVERRIDE" or "", wsid, string.NiceSize(size)) if not skip_maxsize then diff --git a/lua/outfitter/net.lua b/lua/outfitter/net.lua index c221b1c..5eb7e2b 100644 --- a/lua/outfitter/net.lua +++ b/lua/outfitter/net.lua @@ -8,16 +8,17 @@ _M.NTag = NTag hook.Add("NetData", Tag, function(...) return NetData(...) end) -function SHNetworkOutfit(pl, mdl, download_info) +function SHNetworkOutfit(pl, mdl, download_info, dependency_manifest) --assert(not download_info or tonumber(download_info),('NetworkOutfit INVALID: mdl=%q download_info=%q'):format(tostring(mdl),tostring(download_info))) if not mdl then mdl = nil download_info = nil + dependency_manifest = nil end - local encoded, err = mdl and EncodeOutfitterPayload(mdl, download_info) - dbg("NetworkOutfit", pl, mdl, download_info, ('%q'):format(tostring(encoded)), err) + local encoded, err = mdl and EncodeOutfitterPayload(mdl, download_info, dependency_manifest) + dbg("NetworkOutfit", pl, mdl, download_info, DependencyManifestID(dependency_manifest), ('%q'):format(tostring(encoded)), err) if not encoded then encoded = nil end pl:SetNetData(NTag, encoded) @@ -89,9 +90,9 @@ function OnPlayerVisible(pl, initial_sendings) --if old == true then return end - local mdl, download_info + local mdl, download_info, dependency_manifest if new then - mdl, download_info = DecodeOutfitterPayload(new) + mdl, download_info, dependency_manifest = DecodeOutfitterPayload(new) local ret = hook.Run("CanOutfit", pl, mdl, download_info) if ret == false then return end @@ -114,7 +115,7 @@ function OnPlayerVisible(pl, initial_sendings) return end - OnChangeOutfit(pl, mdl, download_info) + OnChangeOutfit(pl, mdl, download_info, nil, nil, dependency_manifest) end hook.Add("NetworkEntityCreated", Tag, function(ent) diff --git a/lua/outfitter/sh.lua b/lua/outfitter/sh.lua index 670b1c2..4013cfd 100644 --- a/lua/outfitter/sh.lua +++ b/lua/outfitter/sh.lua @@ -26,7 +26,54 @@ function HasMDL(mdl) return file.Exists(mdl .. '.mdl', 'GAME') end -function SanityCheckNData(mdl, download_path) +local DEPENDENCY_MANIFEST_VERSION = 1 +local MAX_DEPENDENCY_COUNT = 64 + +function NormalizeDependencyManifest(manifest) + if manifest == nil then return nil end + if not istable(manifest) or tonumber(manifest.version) ~= DEPENDENCY_MANIFEST_VERSION or not istable(manifest.dependencies) then + return nil, "invalid dependency manifest" + end + + local dependencies = {} + local seen = {} + for k, id in next, manifest.dependencies do + if not isnumber(k) or k < 1 or k % 1 ~= 0 then + return nil, "invalid dependency manifest" + end + + id = tostring(id) + if not id:find("^%d+$") or tonumber(id) <= 0 or seen[id] then + return nil, "invalid dependency manifest" + end + + seen[id] = true + dependencies[#dependencies + 1] = id + if #dependencies > MAX_DEPENDENCY_COUNT then + return nil, "dependency count" + end + end + + table.sort(dependencies, function(a, b) + return tonumber(a) < tonumber(b) + end) + + return { + version = DEPENDENCY_MANIFEST_VERSION, + dependencies = dependencies + } +end + +function DependencyManifestID(manifest) + if not manifest then return "" end + + local normalized = NormalizeDependencyManifest(manifest) + if not normalized then return "invalid" end + + return tostring(normalized.version) .. ":" .. table.concat(normalized.dependencies, ",") +end + +function SanityCheckNData(mdl, download_path, dependency_manifest) if not mdl then return false end if not download_path then return false end if mdl == "" or #mdl > 2048 * 2 then return false end @@ -38,6 +85,11 @@ function SanityCheckNData(mdl, download_path) if not IsHTTPURL(download_path) then return false end end + if dependency_manifest then + if not tonumber(download_path) then return false end + if not NormalizeDependencyManifest(dependency_manifest) then return false end + end + return nil end @@ -48,11 +100,22 @@ function findpl(uid) end end --- Encodes the shared payload to be sent to everyone: {model_path,25293523 or "https://example.com/asd.gma" or false} -function EncodeOutfitterPayload(model_path, download_path) - local encoded = model_path and download_path and - util.TableToJSON({ assert(model_path:find(".mdl", 2, true) and model_path, 'invalid path: ' .. tostring(model_path)), - tostring(download_path) or false }) or nil +-- Encodes the shared payload to be sent to everyone: +-- {model_path,25293523 or "https://example.com/asd.gma" or false,dependency_manifest or nil} +function EncodeOutfitterPayload(model_path, download_path, dependency_manifest) + local normalized, err = NormalizeDependencyManifest(dependency_manifest) + if dependency_manifest and not normalized then return nil, err end + + local payload = model_path and download_path and { + assert(model_path:find(".mdl", 2, true) and model_path, 'invalid path: ' .. tostring(model_path)), + tostring(download_path) or false + } or nil + + if payload and normalized then + payload[3] = normalized + end + + local encoded = payload and util.TableToJSON(payload) or nil return encoded and #encoded < 32000 and encoded end @@ -68,6 +131,8 @@ function DecodeOutfitterPayload(encoded) if not decoded then return nil, err or 'json parsing failed' end local model_path = decoded[1] local download_path = decoded[2] + local dependency_manifest, err = NormalizeDependencyManifest(decoded[3]) + if decoded[3] and not dependency_manifest then return nil, err end if not model_path then return nil, 'empty' end model_path = tostring(model_path) if not model_path:find("%.mdl$") and not model_path:lower():find("%.mdl$") then return nil, 'not a .mdl' end @@ -77,7 +142,7 @@ function DecodeOutfitterPayload(encoded) if not tonumber(download_path) and not download_path:find "^https?://.*/" and download_path ~= false then return nil, 'invalid' end - return model_path, download_path + return model_path, download_path, dependency_manifest end -- legacy @@ -300,7 +365,7 @@ for _,fn in next,flist do f:Close() end--]] -local t = { "", "", "", "" } +local t = { "", "", "", "", "" } local function GenID(_1, _2, _3, _4, _5) if not _1 then return end @@ -308,7 +373,7 @@ local function GenID(_1, _2, _3, _4, _5) t[2] = tostring(_2) t[3] = tostring(_3) t[4] = tostring(_4) - assert(not _5) + t[5] = DependencyManifestID(_5) return table.concat(t, "|") end @@ -335,14 +400,15 @@ function Player.OutfitCheckHash(pl, nhash) end function Player.OutfitInfo(pl) - return pl.outfitter_mdl, pl.outfitter_download_path, pl.outfitter_skin, pl.outfitter_bodygroups + return pl.outfitter_mdl, pl.outfitter_download_path, pl.outfitter_skin, pl.outfitter_bodygroups, pl.outfitter_dependency_manifest end -function Player.OutfitSetInfo(pl, mdl, download_path, skin, bodygroups) +function Player.OutfitSetInfo(pl, mdl, download_path, skin, bodygroups, dependency_manifest) pl.outfitter_mdl = mdl pl.outfitter_download_path = download_path pl.outfitter_skin = skin pl.outfitter_bodygroups = bodygroups + pl.outfitter_dependency_manifest = NormalizeDependencyManifest(dependency_manifest) pl:OutfitUpdateHash() end diff --git a/lua/outfitter/sv.lua b/lua/outfitter/sv.lua index 4729e5a..020f888 100644 --- a/lua/outfitter/sv.lua +++ b/lua/outfitter/sv.lua @@ -75,26 +75,26 @@ function NetData(pl, k, val) return false end - local mdl, download_info + local mdl, download_info, dependency_manifest if val then if #val > 2048 * 2 or #val == 0 then dbg("NetData", "badval", #val, pl) return false end - mdl, download_info = DecodeOutfitterPayload(val) + mdl, download_info, dependency_manifest = DecodeOutfitterPayload(val) end local ret = hook.Run("CanOutfit", pl, mdl, download_info) if ret == false then return false end - pl:OutfitSetInfo(mdl, download_info) + pl:OutfitSetInfo(mdl, download_info, nil, nil, dependency_manifest) - dbg("NetData", pl, "outfit", mdl, download_info) + dbg("NetData", pl, "outfit", mdl, download_info, DependencyManifestID(dependency_manifest)) if not val then return true end - local ret = SanityCheckNData(mdl, download_info) + local ret = SanityCheckNData(mdl, download_info, dependency_manifest) if ret ~= nil then dbg("NetData", pl, "sanity check fail", tostring(val):sub(1, 256)) diff --git a/lua/outfitter/ui.lua b/lua/outfitter/ui.lua index 220270d..14aca39 100644 --- a/lua/outfitter/ui.lua +++ b/lua/outfitter/ui.lua @@ -747,6 +747,21 @@ function coUIOversizeMsg(pl, wsid) (" is too big %saccording to your settings (%s) so it was not mounted!"):format(szstr, maxsz)) end +local dependency_failures = {} +function coUIDependencyFailureMsg(pl, wsid, err, err2) + local key = tostring(wsid) .. "|" .. tostring(err) + if dependency_failures[key] then return end + dependency_failures[key] = true + + local detail = err2 and " (" .. (err == "dependency oversize" and string.NiceSize(err2) or tostring(err2)) .. ")" or "" + local msg = ("Dependencies for the outfit of %s were not fully mounted: %s%s"):format(tostring(pl), tostring(err), detail) + + Msg("[Outfitter] ") + print(msg) + UIMsg(msg) + notification.AddLegacy("[Outfitter] " .. msg, NOTIFY_ERROR, 4) +end + -- This is a horrible hack because of forethought was lacking when the rest of the code was made -- duplicated from two different functions, etc function coDoAutowear() From 76195ce4e5d4f269903b209cdca9983edf427f23 Mon Sep 17 00:00:00 2001 From: ilker2445 <103515424+ilker2445@users.noreply.github.com> Date: Sun, 14 Jun 2026 23:19:14 +0300 Subject: [PATCH 3/8] Add dependency selection interface --- lua/outfitter/cl.lua | 21 ++++ lua/outfitter/cl_util.lua | 21 ++++ lua/outfitter/gui.lua | 233 ++++++++++++++++++++++++++++++++++++-- lua/outfitter/ui.lua | 67 +++++++++-- 4 files changed, 324 insertions(+), 18 deletions(-) diff --git a/lua/outfitter/cl.lua b/lua/outfitter/cl.lua index d31ca0a..690d80c 100644 --- a/lua/outfitter/cl.lua +++ b/lua/outfitter/cl.lua @@ -58,6 +58,26 @@ function RefreshPlayers() end end +function RefreshDependencies() + local players = {} + for _, pl in next, player.GetAll() do + local _, _, _, _, dependency_manifest = pl:OutfitInfo() + if pl ~= LocalPlayer() and dependency_manifest and #dependency_manifest.dependencies > 0 then + players[#players + 1] = pl + end + end + + return co(function() + for _, pl in next, players do + if pl:IsValid() then + pl.outfitter_nvar = nil + OnPlayerVisible(pl) + end + co.sleep(.25) + end + end) +end + function EnableEverything() dbg("EnableEverything") RefreshPlayers() @@ -270,6 +290,7 @@ end function BroadcastMyOutfit(a) assert(not a) local mdl, download_info, s, bg, dependency_manifest = LocalPlayer():OutfitInfo() + if not ShouldMountChildren() then dependency_manifest = nil end dbg("BroadcastMyOutfit", mdl, download_info, s, bg, DependencyManifestID(dependency_manifest)) NetworkOutfit(mdl, download_info, dependency_manifest) diff --git a/lua/outfitter/cl_util.lua b/lua/outfitter/cl_util.lua index de71121..1950d24 100644 --- a/lua/outfitter/cl_util.lua +++ b/lua/outfitter/cl_util.lua @@ -202,6 +202,27 @@ do local outfitter_mount_children = CreateClientConVar("outfitter_mount_children_test", "0", true) outfitter_dependency_maxsize = CreateClientConVar("outfitter_dependency_maxsize", "60", true) + local function refresh_dependencies() + timer.Create(Tag .. "_dependency_refresh", .5, 1, function() + if RefreshDependencies then RefreshDependencies() end + end) + end + + cvars.AddChangeCallback("outfitter_mount_children_test", function(cvar, old, new) + if tonumber(old) == 0 and tonumber(new) ~= 0 then + refresh_dependencies() + end + end) + + cvars.AddChangeCallback("outfitter_dependency_maxsize", function(cvar, old, new) + old = tonumber(old) or 0 + new = tonumber(new) or 0 + + if outfitter_mount_children:GetBool() and (new == 0 or old > 0 and new > old) then + refresh_dependencies() + end + end) + function ShouldMountChildren() return outfitter_mount_children:GetBool() end diff --git a/lua/outfitter/gui.lua b/lua/outfitter/gui.lua index 409c6c0..93953ca 100644 --- a/lua/outfitter/gui.lua +++ b/lua/outfitter/gui.lua @@ -66,7 +66,7 @@ function PANEL:WSChoose() self:Hide() if self.chosen_id then surface.PlaySound "npc/vort/claw_swing1.wav" - UIChoseWorkshop(self.chosen_id, self.returntoui) + UIChoseWorkshop(self.chosen_id, self.returntoui, true) end end @@ -160,6 +160,178 @@ function GUIWantChangeModel(str, returntoui) return m_vModelDlg end +function GUIReviewDependencies(graph, dependency_manifest, cb) + local frame = vgui.Create('DFrame', nil, 'dependency selector') + frame:SetDeleteOnClose(true) + frame:SetTitle("Outfitter dependencies") + frame:SetIcon('icon16/bricks.png') + frame:SetSize(math.min(ScrW() - 64, 720), math.min(ScrH() - 64, 560)) + frame:Center() + frame:MakePopup() + + local selected = {} + local normalized = NormalizeDependencyManifest(dependency_manifest) + if normalized then + for _, id in next, normalized.dependencies do + selected[id] = true + end + else + for _, id in next, graph.order do + selected[id] = true + end + end + + local finished + local function finish(manifest) + if finished then return end + finished = true + cb(manifest) + frame:Remove() + end + + frame.OnClose = function() + finish(false) + end + + local info = frame:Add("DLabel") + info:Dock(TOP) + info:DockMargin(8, 8, 8, 4) + info:SetWrap(true) + info:SetAutoStretchVertical(true) + info:SetText("This workshop outfit declares the following dependencies. Select the ones that should be mounted and sent with your outfit.") + + local status = frame:Add("DLabel") + status:Dock(BOTTOM) + status:DockMargin(8, 4, 8, 4) + status:SetTall(22) + + local buttons = frame:Add("EditablePanel") + buttons:Dock(BOTTOM) + buttons:DockMargin(8, 4, 8, 8) + buttons:SetTall(28) + + local cancel = buttons:Add("DButton") + cancel:Dock(LEFT) + cancel:SetWide(80) + cancel:SetText("#gameui_cancel") + cancel:SetImage("icon16/cancel.png") + cancel.DoClick = function() + finish(false) + end + + local none = buttons:Add("DButton") + none:Dock(RIGHT) + none:SetWide(190) + none:SetText("Continue without dependencies") + none:SetImage("icon16/delete.png") + none.DoClick = function() + finish(NormalizeDependencyManifest({ version = 1, dependencies = {} })) + end + + local accept = buttons:Add("DButton") + accept:Dock(RIGHT) + accept:DockMargin(0, 0, 4, 0) + accept:SetWide(190) + accept:SetImage("icon16/accept.png") + + local scroll = frame:Add("DScrollPanel") + scroll:Dock(FILL) + scroll:DockMargin(8, 4, 8, 4) + + local list = scroll:Add("DListLayout") + list:Dock(TOP) + + local function selected_manifest() + local dependencies = {} + for _, id in next, graph.order do + if selected[id] then + dependencies[#dependencies + 1] = id + end + end + + return NormalizeDependencyManifest({ + version = 1, + dependencies = dependencies + }) + end + + local function selected_size() + local size = 0 + local count = 0 + for id in next, selected do + if selected[id] then + local node = graph.nodes[id] + size = size + (node and node.size or 0) + count = count + 1 + end + end + return size, count + end + + local function refresh() + local size, count = selected_size() + local maxsize = DependencyMaxSize() + local oversize = maxsize > 0.1 and size > maxsize + + status:SetText(("%d selected, %s total, %s limit"):format(count, string.NiceSize(size), + maxsize > 0.1 and string.NiceSize(maxsize) or "no")) + status:SetTextColor(oversize and Color(220, 70, 70) or Color(70, 180, 70)) + accept:SetText(oversize and "Increase limit and accept" or "Use selected dependencies") + end + + local seen = {} + local function add_dependency(id, depth) + if seen[id] then return end + seen[id] = true + + local node = graph.nodes[id] + if not node or id == graph.root then return end + + local row = list:Add("EditablePanel") + row:SetTall(28) + + local open = row:Add("DButton") + open:Dock(RIGHT) + open:SetWide(34) + open:SetText("") + open:SetImage("icon16/world.png") + open:SetTooltip("Open workshop page") + open.DoClick = function() + gui.OpenURL("https://steamcommunity.com/sharedfiles/filedetails/?id=" .. id) + end + + local check = row:Add("DCheckBoxLabel") + check:Dock(FILL) + check:DockMargin(math.max(0, depth - 1) * 16, 2, 4, 2) + check:SetText(("%s [%s] (%s)"):format(node.title or "Unknown workshop item", id, string.NiceSize(node.size))) + check:SetTooltip("Workshop " .. id) + check:SetChecked(selected[id] and true or false) + check.OnChange = function(_, value) + selected[id] = value and true or nil + refresh() + end + + for _, child in next, node.children do + add_dependency(child, depth + 1) + end + end + + for _, child in next, graph.nodes[graph.root].children do + add_dependency(child, 1) + end + + accept.DoClick = function() + local size = selected_size() + local maxsize = DependencyMaxSize() + if maxsize > 0.1 and size > maxsize then + outfitter_dependency_maxsize:SetInt(math.ceil(size / 1000 / 1000)) + end + finish(selected_manifest()) + end + + refresh() +end + -- GUIOpen @@ -225,7 +397,7 @@ function PANEL:Init() dbg("GUI", "UrlToWorkshopID", url, wsid) if wsid then surface.PlaySound "npc/vort/claw_swing1.wav" - UIChoseWorkshop(wsid, true) + UIChoseWorkshop(wsid, true, true) self:GetParent():Hide() else if IsHTTPURL(url) then @@ -256,6 +428,30 @@ function PANEL:Init() l:SetFont "BudgetLabel" l:SetTextColor(Color(255, 255, 255, 255)) + local dependencies = functions:Add("DButton", 'dependencies') + self.btn_dependencies = dependencies + dependencies:Dock(TOP) + dependencies:DockMargin(0, 1, 1, 4) + dependencies:SetText("Dependencies...") + dependencies:SetImage("icon16/bricks.png") + dependencies:SetTooltip("Review the dependencies sent with this outfit") + dependencies:SetVisible(false) + dependencies.DoClick = function() + local wsid = UIGetWSID() + if not wsid then return end + + co(function() + local dependency_manifest, err = coUIReviewDependencies(wsid, UIGetDependencyManifest()) + if dependency_manifest == false then return end + if not dependency_manifest then + return UIError("Dependency review failed: " .. tostring(err)) + end + + UIApplyDependencyManifest(dependency_manifest) + GUIRefresh() + end) + end + local mdllist = functions:Add("DListView", 'modelname') @@ -493,6 +689,27 @@ function PANEL:Init() slider:SetDecimals(0) slider:SetConVar(Tag .. '_maxsize') local sld_dl = slider + + local check = AddS("DCheckBoxLabel") + check:SetConVar(Tag .. "_mount_children_test") + check:SetText("Mount workshop dependencies") + check:SizeToContents() + check:SetTooltip [[Mounts selected workshop dependencies along with outfits]] + check:DockMargin(1, 12, 1, 1) + + local slider = AddS("DNumSlider") + slider:SetText("Maximum dependency size (in MB)") + slider:SizeToContents() + slider:DockPadding(0, 16, 0, 0) + slider.Label:Dock(TOP) + slider.Label:DockMargin(0, -16, 0, 0) + slider:SetTooltip [[Maximum combined size of the dependencies selected for an outfit. Set to 0 for no limit.]] + slider:DockMargin(1, 4, 1, 1) + slider:SetMin(0) + slider:SetMax(1024) + slider:SetDecimals(0) + slider:SetConVar(Tag .. '_dependency_maxsize') + --TODO --local check = functions:Add( "DCheckBoxLabel" ) -- check:SetConVar(Tag.."_ask") @@ -585,13 +802,6 @@ function PANEL:Init() check:DockMargin(1, 4, 1, 1) local d_2 = check - local check = AddS("DCheckBoxLabel") - check:SetConVar(Tag .. "_mount_children_test") - check:SetText("Mount children dependencies") - check:SizeToContents() - check:SetTooltip [[Mounts the required workshop addon children/dependencies along with the outfit]] - check:DockMargin(1, 4, 1, 1) - hr() local check = AddS("DCheckBoxLabel") check:SetConVar(Tag .. "_animfix_oldmethod") @@ -833,7 +1043,7 @@ function PANEL:WantOutfitMDL(wsid, mdl, title) wanting = true self:GetParent():Hide() dbg("WantOutfitMDL", wanting and "ALREADY WANTING" or "", wsid, mdl, title) - local ok, err = xpcall(UIChoseWorkshop, debug.traceback, wsid) + local ok, err = xpcall(UIChoseWorkshop, debug.traceback, wsid, false, true) if not ok then ErrorNoHalt(err .. '\n') wanting = false @@ -850,7 +1060,7 @@ function PANEL:WSChoose() self:Hide() if self.chosen_id then surface.PlaySound "npc/vort/claw_swing1.wav" - UIChoseWorkshop(self.chosen_id) + UIChoseWorkshop(self.chosen_id, false, true) end end @@ -975,6 +1185,7 @@ function PANEL:DoRefresh(trychoose_mdl) self.lbl_chosen:SetText("Please choose a workshop addon") local wsid = UIGetWSID() + self.btn_dependencies:SetVisible(tonumber(wsid) ~= nil and ShouldMountChildren()) co(function() self.lbl_chosen:SetText("-") diff --git a/lua/outfitter/ui.lua b/lua/outfitter/ui.lua index 14aca39..d82259d 100644 --- a/lua/outfitter/ui.lua +++ b/lua/outfitter/ui.lua @@ -355,6 +355,7 @@ local tried_mounting local mount_path local chosen_mdl local mdllist_extra +local chosen_dependency_manifest function UIGetMDLList() return mdllist end @@ -383,6 +384,35 @@ function UIGetDownloadInfoX() return chosen_download_info end +function UIGetDependencyManifest() + return chosen_dependency_manifest +end + +function UISetDependencyManifest(dependency_manifest) + chosen_dependency_manifest = NormalizeDependencyManifest(dependency_manifest) +end + +function UIApplyDependencyManifest(dependency_manifest) + UISetDependencyManifest(dependency_manifest) + + local pl = LocalPlayer() + local mdl, download_info, skin, bodygroups = pl:OutfitInfo() + if mdl and download_info == chosen_download_info then + OnChangeOutfit(pl, mdl, download_info, skin, bodygroups, chosen_dependency_manifest) + end +end + +function coUIReviewDependencies(wsid, dependency_manifest) + local graph, err = coResolveWSDependencies(wsid) + if not graph then return nil, err end + if graph.count == 0 then return DependencyManifestFromGraph(graph) end + if not GUIReviewDependencies then return nil, "no dependency review gui" end + + local cb = co.newcb() + GUIReviewDependencies(graph, dependency_manifest, cb) + return co.waitcb(cb) +end + function UICancelAll() UIMsg "Unsetting everything" @@ -392,6 +422,7 @@ function UICancelAll() mount_path = nil tried_mounting = nil chosen_mdl = nil + chosen_dependency_manifest = nil RemoveOutfit() EnforceHands() @@ -457,7 +488,7 @@ function UIChangeModelToID(n, opengui) relay_opengui = opengui -- returns instantly, but should be instant anyway - OnChangeOutfit(LocalPlayer(), mdl.Name, chosen_download_info) + OnChangeOutfit(LocalPlayer(), mdl.Name, chosen_download_info, nil, nil, chosen_dependency_manifest) dbg("EnforceHands?", ShouldHands(), n, mdllist[2] == nil, handslist, handslist and handslist[1]) if n == 1 and nil == mdllist[2] and handslist and next(handslist) ~= nil and ShouldHands() then local _, entry = next(handslist) @@ -491,16 +522,17 @@ hook.Add("OutfitApply", Tag, function(pl, mdl) end end) -function UIChoseWorkshop(wsid, opengui) +function UIChoseWorkshop(wsid, opengui, review_dependencies) assert(tonumber(wsid)) - if co.make(wsid, opengui) then return end + if co.make(wsid, opengui, review_dependencies) then return end mdllist = nil chosen_download_info = nil mount_path = nil tried_mounting = nil chosen_mdl = nil + chosen_dependency_manifest = nil SetUIFetching(wsid, true) co.sleep(.5) @@ -573,6 +605,18 @@ function UIChoseWorkshop(wsid, opengui) end end + if review_dependencies and ShouldMountChildren() then + local dependency_manifest, err = coUIReviewDependencies(wsid) + if dependency_manifest == false then + if opengui then GUIOpen() end + return + elseif dependency_manifest then + chosen_dependency_manifest = dependency_manifest + elseif err then + UIError("Dependency review failed: " .. tostring(err)) + end + end + co.sleep(.2) if mdls[2] then @@ -607,6 +651,7 @@ function UIChoseHTTPGMA(download_info, opengui) mount_path = nil tried_mounting = nil chosen_mdl = nil + chosen_dependency_manifest = nil local id = URLFilename(download_info) or "httpgma:" .. util.CRC(download_info) @@ -713,10 +758,17 @@ end function SetAutowear() local pl = LocalPlayer() - local mdl, wsid, skin, bodygroup = pl:OutfitInfo() + local mdl, wsid, skin, bodygroup, dependency_manifest = pl:OutfitInfo() - local t = { mdl = mdl, wsid = wsid, skin = skin, bodygroup = bodygroup, setbodygroupdata = pl:GetBodyGroupData(), hands = - pl.outfitter_hands } + local t = { + mdl = mdl, + wsid = wsid, + skin = skin, + bodygroup = bodygroup, + setbodygroupdata = pl:GetBodyGroupData(), + hands = pl.outfitter_hands, + dependency_manifest = dependency_manifest + } if mdl then @@ -774,6 +826,7 @@ function coDoAutowear() local mdl, wsid, skin, bodygroup, setbodygroupdata = t.mdl, t.wsid, t.skin, t.bodygroup, t.setbodygroupdata local hands = t.hands + local dependency_manifest = NormalizeDependencyManifest(t.dependency_manifest) if not mdl then return end @@ -864,7 +917,7 @@ function coDoAutowear() UISetSilentApplyModel(mdl) -- returns instantly, but should be instant anyway - OnChangeOutfit(LocalPlayer(), mdl, chosen_download_info) + OnChangeOutfit(LocalPlayer(), mdl, chosen_download_info, nil, nil, dependency_manifest) -- cannot enforce hands without crashing at the moment dbg("coDoAutowear", "EnforceHands", ShouldHands(), next(handslist or {})) From e8a57a0bb54474ab18f1dc70567fbd38427c3a78 Mon Sep 17 00:00:00 2001 From: ilker2445 <103515424+ilker2445@users.noreply.github.com> Date: Sun, 14 Jun 2026 23:27:31 +0300 Subject: [PATCH 4/8] Harden dependency manifest handling --- lua/outfitter/cl_util.lua | 2 +- lua/outfitter/cows.lua | 16 ++++++++++------ lua/outfitter/gui.lua | 6 ++++-- lua/outfitter/sh.lua | 7 +++++-- lua/outfitter/ui.lua | 2 +- 5 files changed, 21 insertions(+), 12 deletions(-) diff --git a/lua/outfitter/cl_util.lua b/lua/outfitter/cl_util.lua index 1950d24..df7c74f 100644 --- a/lua/outfitter/cl_util.lua +++ b/lua/outfitter/cl_util.lua @@ -218,7 +218,7 @@ do old = tonumber(old) or 0 new = tonumber(new) or 0 - if outfitter_mount_children:GetBool() and (new == 0 or old > 0 and new > old) then + if outfitter_mount_children:GetBool() and old > 0 and (new <= 0 or new > old) then refresh_dependencies() end end) diff --git a/lua/outfitter/cows.lua b/lua/outfitter/cows.lua index 64f0b4f..6007872 100644 --- a/lua/outfitter/cows.lua +++ b/lua/outfitter/cows.lua @@ -490,15 +490,15 @@ function coResolveWSDependencies(wsid) local function resolve(id, depth) id = tostring(id) + local node = graph.nodes[id] + if node then return true end + if depth > MAX_DEPENDENCY_DEPTH then return nil, "dependency depth" end graph.max_depth = math.max(graph.max_depth, depth) - local node = graph.nodes[id] - if node then return true end - if depth > 0 and graph.count >= MAX_DEPENDENCY_COUNT then return nil, "dependency count" end @@ -593,7 +593,7 @@ function coPlanWSDependencies(wsid, dependency_manifest) return plan end -local function _coMountWSChildren(key, wsid, dependency_manifest) +local function _coMountWSChildren(_, wsid, dependency_manifest) local plan, err, err2 = coPlanWSDependencies(wsid, dependency_manifest) if not plan then return nil, err, err2 end @@ -601,8 +601,12 @@ local function _coMountWSChildren(key, wsid, dependency_manifest) for _, id in next, plan.order do local path, err = coFetchWS(id, true) if path then - local ok - ok, err = coMountWS(path) + local ok, blacklist_err = GMABlacklist(path, id) + if ok then + ok, err = coMountWS(path) + else + err = "blocked: " .. tostring(blacklist_err) + end if not ok then first_error = first_error or (id .. ": " .. tostring(err)) end diff --git a/lua/outfitter/gui.lua b/lua/outfitter/gui.lua index 93953ca..6ccea81 100644 --- a/lua/outfitter/gui.lua +++ b/lua/outfitter/gui.lua @@ -173,7 +173,9 @@ function GUIReviewDependencies(graph, dependency_manifest, cb) local normalized = NormalizeDependencyManifest(dependency_manifest) if normalized then for _, id in next, normalized.dependencies do - selected[id] = true + if graph.nodes[id] and id ~= graph.root then + selected[id] = true + end end else for _, id in next, graph.order do @@ -198,7 +200,7 @@ function GUIReviewDependencies(graph, dependency_manifest, cb) info:DockMargin(8, 8, 8, 4) info:SetWrap(true) info:SetAutoStretchVertical(true) - info:SetText("This workshop outfit declares the following dependencies. Select the ones that should be mounted and sent with your outfit.") + info:SetText("This workshop outfit declares the following dependencies. Select the ones that should be mounted and sent with your outfit. Already-mounted items remain mounted until Garry's Mod restarts.") local status = frame:Add("DLabel") status:Dock(BOTTOM) diff --git a/lua/outfitter/sh.lua b/lua/outfitter/sh.lua index 4013cfd..ddb201b 100644 --- a/lua/outfitter/sh.lua +++ b/lua/outfitter/sh.lua @@ -28,6 +28,7 @@ end local DEPENDENCY_MANIFEST_VERSION = 1 local MAX_DEPENDENCY_COUNT = 64 +local MAX_WORKSHOP_ID = "18446744073709551615" function NormalizeDependencyManifest(manifest) if manifest == nil then return nil end @@ -43,7 +44,8 @@ function NormalizeDependencyManifest(manifest) end id = tostring(id) - if not id:find("^%d+$") or tonumber(id) <= 0 or seen[id] then + local id_too_large = #id > #MAX_WORKSHOP_ID or #id == #MAX_WORKSHOP_ID and id > MAX_WORKSHOP_ID + if not id:find("^[1-9]%d*$") or id_too_large or seen[id] then return nil, "invalid dependency manifest" end @@ -55,7 +57,8 @@ function NormalizeDependencyManifest(manifest) end table.sort(dependencies, function(a, b) - return tonumber(a) < tonumber(b) + if #a ~= #b then return #a < #b end + return a < b end) return { diff --git a/lua/outfitter/ui.lua b/lua/outfitter/ui.lua index d82259d..1682d7d 100644 --- a/lua/outfitter/ui.lua +++ b/lua/outfitter/ui.lua @@ -608,7 +608,7 @@ function UIChoseWorkshop(wsid, opengui, review_dependencies) if review_dependencies and ShouldMountChildren() then local dependency_manifest, err = coUIReviewDependencies(wsid) if dependency_manifest == false then - if opengui then GUIOpen() end + GUIOpen() return elseif dependency_manifest then chosen_dependency_manifest = dependency_manifest From b57b7bbdeb42c378e26e2f2b1cadfea9749acfca Mon Sep 17 00:00:00 2001 From: ilker2445 <103515424+ilker2445@users.noreply.github.com> Date: Sun, 14 Jun 2026 23:30:53 +0300 Subject: [PATCH 5/8] Preserve dependency selection state --- lua/outfitter/cl.lua | 4 ++-- lua/outfitter/ui.lua | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/lua/outfitter/cl.lua b/lua/outfitter/cl.lua index 690d80c..ec120d3 100644 --- a/lua/outfitter/cl.lua +++ b/lua/outfitter/cl.lua @@ -210,7 +210,7 @@ function ChangeOutfitThreadWorker(pl, hash) if HBAD(pl, hash) then return false, "outdated" end end - local ret = hook.Run("CanOutfit", pl, pl:OutfitInfo()) + local ret = hook.Run("CanOutfit", pl, mdl, download_info, skin, bodygroups) if ret == false then return false, "canoutfit" end @@ -252,7 +252,7 @@ function ChangeOutfitThreadWorker(pl, hash) end -- 5. Check CanOutfit - local ret = hook.Run("CanOutfit", pl, pl:OutfitInfo()) + local ret = hook.Run("CanOutfit", pl, mdl, download_info, skin, bodygroups) if ret == false then return false, "canoutfit" end diff --git a/lua/outfitter/ui.lua b/lua/outfitter/ui.lua index 1682d7d..05a2428 100644 --- a/lua/outfitter/ui.lua +++ b/lua/outfitter/ui.lua @@ -527,6 +527,20 @@ function UIChoseWorkshop(wsid, opengui, review_dependencies) if co.make(wsid, opengui, review_dependencies) then return end + local previous_dependency_manifest + if tostring(chosen_download_info) == tostring(wsid) then + previous_dependency_manifest = chosen_dependency_manifest + end + if not previous_dependency_manifest then + local pl = LocalPlayer() + if pl:IsValid() then + local _, current_wsid, _, _, current_dependency_manifest = pl:OutfitInfo() + if tostring(current_wsid) == tostring(wsid) then + previous_dependency_manifest = current_dependency_manifest + end + end + end + mdllist = nil chosen_download_info = nil mount_path = nil @@ -606,7 +620,7 @@ function UIChoseWorkshop(wsid, opengui, review_dependencies) end if review_dependencies and ShouldMountChildren() then - local dependency_manifest, err = coUIReviewDependencies(wsid) + local dependency_manifest, err = coUIReviewDependencies(wsid, previous_dependency_manifest) if dependency_manifest == false then GUIOpen() return From a2037fe906cb02806f7fc7d69f0942c1f655f428 Mon Sep 17 00:00:00 2001 From: ilker2445 <103515424+ilker2445@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:11:38 +0300 Subject: [PATCH 6/8] Remove Workshop ID upper bound --- lua/outfitter/sh.lua | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lua/outfitter/sh.lua b/lua/outfitter/sh.lua index ddb201b..2b18c82 100644 --- a/lua/outfitter/sh.lua +++ b/lua/outfitter/sh.lua @@ -28,7 +28,6 @@ end local DEPENDENCY_MANIFEST_VERSION = 1 local MAX_DEPENDENCY_COUNT = 64 -local MAX_WORKSHOP_ID = "18446744073709551615" function NormalizeDependencyManifest(manifest) if manifest == nil then return nil end @@ -44,8 +43,7 @@ function NormalizeDependencyManifest(manifest) end id = tostring(id) - local id_too_large = #id > #MAX_WORKSHOP_ID or #id == #MAX_WORKSHOP_ID and id > MAX_WORKSHOP_ID - if not id:find("^[1-9]%d*$") or id_too_large or seen[id] then + if not id:find("^[1-9]%d*$") or seen[id] then return nil, "invalid dependency manifest" end From 75759d49a78519229f69c0bc41ed07a6bb0dffe1 Mon Sep 17 00:00:00 2001 From: ilker2445 <103515424+ilker2445@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:39:05 +0300 Subject: [PATCH 7/8] Remove dependency ID validation --- lua/outfitter/sh.lua | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lua/outfitter/sh.lua b/lua/outfitter/sh.lua index 2b18c82..c1abc05 100644 --- a/lua/outfitter/sh.lua +++ b/lua/outfitter/sh.lua @@ -36,18 +36,12 @@ function NormalizeDependencyManifest(manifest) end local dependencies = {} - local seen = {} for k, id in next, manifest.dependencies do if not isnumber(k) or k < 1 or k % 1 ~= 0 then return nil, "invalid dependency manifest" end id = tostring(id) - if not id:find("^[1-9]%d*$") or seen[id] then - return nil, "invalid dependency manifest" - end - - seen[id] = true dependencies[#dependencies + 1] = id if #dependencies > MAX_DEPENDENCY_COUNT then return nil, "dependency count" From cb3f380494da2dd95d21f5a88fbb72b7272d547b Mon Sep 17 00:00:00 2001 From: ilker2445 <103515424+ilker2445@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:41:07 +0300 Subject: [PATCH 8/8] Deduplicate dependency manifests --- lua/outfitter/sh.lua | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lua/outfitter/sh.lua b/lua/outfitter/sh.lua index c1abc05..700f20e 100644 --- a/lua/outfitter/sh.lua +++ b/lua/outfitter/sh.lua @@ -36,15 +36,19 @@ function NormalizeDependencyManifest(manifest) end local dependencies = {} + local seen = {} for k, id in next, manifest.dependencies do if not isnumber(k) or k < 1 or k % 1 ~= 0 then return nil, "invalid dependency manifest" end id = tostring(id) - dependencies[#dependencies + 1] = id - if #dependencies > MAX_DEPENDENCY_COUNT then - return nil, "dependency count" + if not seen[id] then + seen[id] = true + dependencies[#dependencies + 1] = id + if #dependencies > MAX_DEPENDENCY_COUNT then + return nil, "dependency count" + end end end