diff --git a/lua/outfitter/cl.lua b/lua/outfitter/cl.lua index 7e1b5f9..1208c45 100644 --- a/lua/outfitter/cl.lua +++ b/lua/outfitter/cl.lua @@ -42,7 +42,7 @@ end function DisableEverything() dbg("DisableEverything") - for _, pl in next, player.GetAll() do + for _, pl in player.Iterator() do if pl.outfitter_nvar then pl.outfitter_nvar = nil @@ -53,11 +53,38 @@ function DisableEverything() end function RefreshPlayers() - for _, pl in next, player.GetAll() do + for _, pl in player.Iterator() do OnPlayerVisible(pl) end end +function RefreshDependencies() + local players = {} + for _, pl in player.Iterator() do + local dependency_manifest = pl:OutfitDependencyManifest() + if 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 + if pl == LocalPlayer() then + local mdl, download_info, skin, bodygroups = pl:OutfitInfo() + if mdl then + OnChangeOutfit(pl, mdl, download_info, skin, bodygroups, pl:OutfitDependencyManifest()) + end + else + pl.outfitter_nvar = nil + OnPlayerVisible(pl) + end + end + co.sleep(.25) + end + end) +end + function EnableEverything() dbg("EnableEverything") RefreshPlayers() @@ -67,9 +94,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 +106,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 @@ -158,6 +185,7 @@ function ChangeOutfitThreadWorker(pl, hash) assert(not HBAD(pl, hash)) local mdl, download_info, skin, bodygroups = pl:OutfitInfo() + local dependency_manifest = pl:OutfitDependencyManifest() mdl = mdl or false dbg("ChangeOutfit", "BEGIN", pl, mdl or "unset", download_info) @@ -174,9 +202,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 +234,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 +276,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 @@ -269,9 +298,11 @@ end function BroadcastMyOutfit(a) assert(not a) local mdl, download_info, s, bg = LocalPlayer():OutfitInfo() - dbg("BroadcastMyOutfit", mdl, download_info, s, bg) + local dependency_manifest = LocalPlayer():OutfitDependencyManifest() + if not ShouldMountChildren() then dependency_manifest = nil end + 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..8f1917a 100644 --- a/lua/outfitter/cl_util.lua +++ b/lua/outfitter/cl_util.lua @@ -198,10 +198,27 @@ do end end +local function refresh_dependencies() + timer.Create(Tag .. "_dependency_refresh", .5, 1, function() + if RefreshDependencies then RefreshDependencies() end + end) +end + do - local outfitter_mount_children = CreateClientConVar("outfitter_mount_children_test", "0", true) + local outfitter_allow_dependencies = CreateClientConVar("outfitter_allow_dependencies", "1", true) + + cvars.AddChangeCallback("outfitter_allow_dependencies", function(cvar, old, new) + if tonumber(old) == 0 and tonumber(new) ~= 0 then + refresh_dependencies() + end + end) + function ShouldMountChildren() - return outfitter_mount_children:GetBool() + return outfitter_allow_dependencies:GetBool() + end + + function OutfitMaxSize() + return outfitter_maxsize:GetFloat() * 1000 * 1000 end end @@ -371,6 +388,14 @@ end --TODO outfitter_maxsize = CreateClientConVar("outfitter_maxsize", "60", true) +cvars.AddChangeCallback("outfitter_maxsize", function(cvar, old, new) + old = tonumber(old) or 0 + new = tonumber(new) or 0 + + if ShouldMountChildren and ShouldMountChildren() and old > 0 and (new <= 0 or new > old) then + refresh_dependencies() + end +end) -- Model enforcing diff --git a/lua/outfitter/cows.lua b/lua/outfitter/cows.lua index 35e40f7..9576ae5 100644 --- a/lua/outfitter/cows.lua +++ b/lua/outfitter/cows.lua @@ -48,6 +48,11 @@ local function SYNC(cbs, ...) return ... end +local function SYNCWS(wsid, cbs, ...) + skip_maxsizes[wsid] = nil + return SYNC(cbs, ...) +end + local function steamworks_Download_work(fileid) local instant local path, fd @@ -167,10 +172,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] @@ -180,6 +181,9 @@ function coFetchWS(wsid, skip_maxsize) return res[wsid] or true elseif istable(dat) then -- become a waiter + if skip_maxsize then + skip_maxsizes[wsid] = true + end local cb = co.newcb() dat[#dat + 1] = cb return co.waitcb() @@ -199,6 +203,9 @@ function coFetchWS(wsid, skip_maxsize) dat = {} fetching[wsid] = dat + if skip_maxsize then + skip_maxsizes[wsid] = true + end local fileinfo = co_steamworks_FileInfo(wsid) @@ -224,7 +231,7 @@ function coFetchWS(wsid, skip_maxsize) if fileinfo.banned then dbge(wsid, "BANNED!?") - return SYNC(dat, cantmount(wsid, "banned")) + return SYNCWS(wsid, dat, cantmount(wsid, "banned")) end if next(fileinfo.children or {}) then @@ -235,7 +242,7 @@ function coFetchWS(wsid, skip_maxsize) if created < 60 * 60 * 24 * 7 then dbg(wsid, "WARNING: ONE WEEK OLD ADDON. NOT ENOUGH TIME FOR WORKSHOP MODERATORS.") if IsParanoidMode(1) then - return SYNC(dat, cantmount(wsid, "new_addon")) + return SYNCWS(wsid, dat, cantmount(wsid, "new_addon")) end end if disabled then @@ -247,18 +254,18 @@ function coFetchWS(wsid, skip_maxsize) end if not fileinfo or not fileinfo.title then - return SYNC(dat, cantmount(wsid, "fileinfo")) + return SYNCWS(wsid, dat, cantmount(wsid, "fileinfo")) end if IsTitleBlocked(fileinfo.title) then - return SYNC(dat, cantmount(wsid, "blocked title")) + return SYNCWS(wsid, dat, cantmount(wsid, "blocked title")) end if fileinfo.error and fileinfo.error ~= "" then - return SYNC(dat, cantmount(wsid, "fileinfo: " .. tostring(fileinfo.error))) + return SYNCWS(wsid, dat, cantmount(wsid, "fileinfo: " .. tostring(fileinfo.error))) end if tonumber(fileinfo.size or 0) == 0 or tonumber(fileinfo.size or 0) == 0 then - return SYNC(dat, cantmount(wsid, "undownloadable")) + return SYNCWS(wsid, dat, cantmount(wsid, "undownloadable")) end local maxsz = outfitter_maxsize:GetFloat() @@ -270,7 +277,7 @@ function coFetchWS(wsid, skip_maxsize) dbg("FetchWS", "MAXSIZE", skip_maxsize and "OVERRIDE" or "", wsid, string.NiceSize(fileinfo.size or 0)) if not skip_maxsize then - return SYNC(dat, cantmount(wsid, "oversize")) + return SYNCWS(wsid, dat, cantmount(wsid, "oversize")) end end @@ -285,11 +292,11 @@ function coFetchWS(wsid, skip_maxsize) assert(path ~= true) if not path then - return SYNC(dat, cantmount(wsid, "download")) + return SYNCWS(wsid, dat, cantmount(wsid, "download")) end if not IsUGCFilePath(path) and file.Size(path, 'MOD') <= 512 then - return SYNC(dat, cantmount(wsid, "file")) + return SYNCWS(wsid, dat, cantmount(wsid, "file")) end -- Decompress manually @@ -297,12 +304,12 @@ function coFetchWS(wsid, skip_maxsize) local err path, err = coDecompress(path) if not path then - return SYNC(dat, cantmount(wsid, 'decompress')) + return SYNCWS(wsid, dat, cantmount(wsid, 'decompress')) end if not IsUGCFilePath(path) and not file.Exists(path, 'MOD') then dbg(path, "IsUGCFilePath", IsUGCFilePath(path), "file.Exists", file.Exists(path, 'MOD')) - return SYNC(dat, cantmount(wsid, "file")) + return SYNCWS(wsid, dat, cantmount(wsid, "file")) end end @@ -332,7 +339,7 @@ function coFetchWS(wsid, skip_maxsize) dbg("coFetchWS", "gma.rebuild_nolua", "No rebuild necessary or possible") else dbge("coFetchWS", "gma.rebuild_nolua", wsid, err, err2) - return SYNC(dat, cantmount(wsid, "gmarebuild")) + return SYNCWS(wsid, dat, cantmount(wsid, "gmarebuild")) end end @@ -342,7 +349,7 @@ function coFetchWS(wsid, skip_maxsize) fetching[wsid] = true res[wsid] = result - return SYNC(dat, result) + return SYNCWS(wsid, dat, result) end function FetchWS(wsid, cb) @@ -479,48 +486,151 @@ function coDecompress(path) return 'data/' .. safepath end -local function coMountWSDependency(wsid, seen) +local function DependencyFileInfoError(fileinfo) + if not fileinfo or not fileinfo.title then return "fileinfo" end + if fileinfo.error and fileinfo.error ~= "" then return "fileinfo: " .. tostring(fileinfo.error) end + if tonumber(fileinfo.size or 0) == 0 then return "undownloadable" end +end + +local MAX_DEPENDENCY_DEPTH = 16 + +function coResolveWSDependencies(wsid) wsid = tostring(wsid) - if seen[wsid] then return true end - seen[wsid] = true - local fileinfo = co_steamworks_FileInfo(wsid) - if not fileinfo then return nil, "fileinfo" end + local graph = { + root = wsid, + nodes = {}, + order = {}, + count = 0 + } - 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 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 limit (" .. MAX_DEPENDENCY_DEPTH .. ")" end - end - local path, err = coFetchWS(wsid) - if not path then return nil, err end + if depth > 0 and graph.count >= MAX_DEPENDENCY_COUNT then + return nil, "dependency count limit (" .. MAX_DEPENDENCY_COUNT .. ")" + end - local ok, err = coMountWS(path) + local fileinfo = co_steamworks_FileInfo(id) + local fileinfo_error = DependencyFileInfoError(fileinfo) + node = { + size = tonumber(fileinfo and fileinfo.size or 0) or 0, + title = fileinfo and fileinfo.title, + children = {}, + error = fileinfo_error + } + graph.nodes[id] = node + + if depth > 0 then + graph.count = graph.count + 1 + end + + if fileinfo_error then + if depth == 0 then return nil, id .. ": " .. fileinfo_error end + end + + for _, child in next, fileinfo and 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 - if child_error then return nil, child_error end - return true + return graph 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 +function coPlanWSDependencies(wsid, dependency_manifest) + local manifest, err = NormalizeDependencyManifest(dependency_manifest) + if not manifest then return nil, err or "dependency manifest" end + + local plan = { + order = {}, + dependency_size = 0, + total_size = 0, + failures = {} + } + if #manifest.dependencies == 0 then return plan end + + local graph, err = coResolveWSDependencies(wsid) + if not graph then return nil, err end + local root = graph.nodes[graph.root] + plan.total_size = root and root.size or 0 + + local selected = {} + for _, id in next, manifest.dependencies do + local node = graph.nodes[id] + if id == graph.root then + return nil, "invalid dependency" + elseif not node then + plan.failures[#plan.failures + 1] = id .. ": unavailable" + elseif node.error then + plan.failures[#plan.failures + 1] = id .. ": " .. node.error + else + selected[id] = true + plan.dependency_size = plan.dependency_size + node.size + plan.total_size = plan.total_size + node.size + end + 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 maxsize = OutfitMaxSize() + if maxsize > 0.1 and plan.total_size > maxsize then + return nil, "oversize", plan.total_size + end + + for _, id in next, graph.order do + if selected[id] then + plan.order[#plan.order + 1] = id end end - if child_error then return nil, child_error end + return plan +end + +local function _coMountWSChildren(_, wsid, dependency_manifest) + local plan, err, err2 = coPlanWSDependencies(wsid, dependency_manifest) + if not plan then return nil, err, err2 end + + local failures = {} + for _, failure in next, plan.failures do + failures[#failures + 1] = failure + end + + for _, id in next, plan.order do + local path, err = coFetchWS(id, true) + if path then + 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 + failures[#failures + 1] = id .. ": " .. tostring(err) + end + else + failures[#failures + 1] = id .. ": " .. tostring(err) + end + end + + if failures[1] then return nil, "dependency failed", table.concat(failures, "; ") end return true end @@ -530,12 +640,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 @@ -604,9 +721,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 @@ -699,8 +817,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/gui.lua b/lua/outfitter/gui.lua index 409c6c0..2d4e6e7 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,211 @@ function GUIWantChangeModel(str, returntoui) return m_vModelDlg end +function GUIReviewDependencies(graph, dependency_manifest, cb) + local text_color = Color(20, 20, 20) + local unavailable_color = Color(125, 35, 35) + local status_ok_color = Color(20, 95, 35) + local status_error_color = Color(135, 25, 25) + + local function paint_frame(_, w, h) + surface.SetDrawColor(238, 238, 238, 255) + surface.DrawRect(0, 0, w, h) + surface.SetDrawColor(210, 210, 210, 255) + surface.DrawRect(0, 0, w, 24) + surface.SetDrawColor(70, 70, 70, 255) + surface.DrawOutlinedRect(0, 0, w, h) + end + + local function set_check_text_color(check, color) + check:SetTextColor(color) + if check.Label then check.Label:SetTextColor(color) end + end + + 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.Paint = paint_frame + frame:Center() + frame:MakePopup() + + local selected = {} + local normalized = NormalizeDependencyManifest(dependency_manifest) + if normalized then + for _, id in next, normalized.dependencies do + if graph.nodes[id] and not graph.nodes[id].error and id ~= graph.root then + selected[id] = true + end + end + else + for _, id in next, graph.order do + if not graph.nodes[id].error then + selected[id] = true + end + 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:SetTextColor(text_color) + info:SetText("This workshop outfit declares the following dependencies. Select the ones that should be mounted and sent with your outfit. Unavailable items cannot be selected. Already-mounted items remain mounted until Garry's Mod restarts.") + + 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(MakeDependencyManifest()) + 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 MakeDependencyManifest(dependencies) + end + + local function selected_size() + local root = graph.nodes[graph.root] + local size = root and root.size or 0 + local dependency_size = 0 + local count = 0 + for id in next, selected do + if selected[id] then + local node = graph.nodes[id] + local node_size = node and node.size or 0 + dependency_size = dependency_size + node_size + size = size + node_size + count = count + 1 + end + end + return size, dependency_size, count + end + + local function refresh() + local size, dependency_size, count = selected_size() + local maxsize = OutfitMaxSize() + local oversize = maxsize > 0.1 and size > maxsize + + status:SetText(("%d selected, %s dependencies, %s with outfit, %s limit"):format(count, + string.NiceSize(dependency_size), string.NiceSize(size), + maxsize > 0.1 and string.NiceSize(maxsize) or "no")) + status:SetTextColor(oversize and status_error_color or status_ok_color) + 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) + local text = ("%s [%s] (%s)"):format(node.title or "Unknown workshop item", id, string.NiceSize(node.size)) + if node.error then + text = text .. " - unavailable: " .. node.error + end + check:SetText(text) + check:SetTooltip(node.error and ("Unavailable: " .. node.error) or ("Workshop " .. id)) + check:SetChecked(selected[id] and true or false) + check:SetEnabled(not node.error) + set_check_text_color(check, node.error and unavailable_color or text_color) + 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 = OutfitMaxSize() + if maxsize > 0.1 and size > maxsize then + outfitter_maxsize:SetInt(math.ceil(size / 1000 / 1000)) + end + finish(selected_manifest()) + end + + refresh() +end + -- GUIOpen @@ -225,7 +430,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 +461,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') @@ -485,7 +714,7 @@ function PANEL:Init() slider.Label:Dock(TOP) slider.Label:DockMargin(0, -16, 0, 0) - slider:SetTooltip [[This is how big an outfit you can receive without it being blocked]] + slider:SetTooltip [[This is how big an outfit and its selected dependencies can be before being blocked]] slider:DockMargin(1, 4, 1, 1) slider:SetMin(0) @@ -493,6 +722,14 @@ function PANEL:Init() slider:SetDecimals(0) slider:SetConVar(Tag .. '_maxsize') local sld_dl = slider + + local check = AddS("DCheckBoxLabel") + check:SetConVar(Tag .. "_allow_dependencies") + check:SetText("Allow workshop dependencies") + check:SizeToContents() + check:SetTooltip [[Mounts selected workshop dependencies along with outfits]] + check:DockMargin(1, 12, 1, 1) + --TODO --local check = functions:Add( "DCheckBoxLabel" ) -- check:SetConVar(Tag.."_ask") @@ -585,13 +822,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 +1063,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 +1080,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 @@ -964,6 +1194,41 @@ function GUICheckTransmit() local cansend = UIGetChosenMDL() and UIGetDownloadInfoX() and UIGetMDLList() self.btnSendOutfit:SetEnabled2(cansend) self.btn_bg:Refresh() + self:RefreshDependencyButton() +end + +function PANEL:RefreshDependencyButton() + local button = self.btn_dependencies + if not button then return end + + local wsid = UIGetWSID() + local visible = tonumber(wsid) ~= nil and ShouldMountChildren() + self.dependency_button_request = (self.dependency_button_request or 0) + 1 + + button:SetVisible(visible) + button:SetEnabled(false) + + if not visible then return end + + if not UIGetChosenMDL() then + button:SetTooltip("Choose a model before reviewing dependencies") + return + end + + button:SetTooltip("Checking workshop dependencies...") + + local request = self.dependency_button_request + co(function() + local graph, err = coResolveWSDependencies(wsid) + if not self:IsValid() or not button:IsValid() then return end + if request ~= self.dependency_button_request or wsid ~= UIGetWSID() then return end + + local has_dependencies = graph and graph.count > 0 + button:SetEnabled(has_dependencies) + button:SetTooltip(has_dependencies and "Review the dependencies sent with this outfit" or + (graph and "This workshop outfit does not declare dependencies" or + ("Could not read dependencies: " .. tostring(err)))) + end) end function PANEL:DoRefresh(trychoose_mdl) 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..c94b442 100644 --- a/lua/outfitter/sh.lua +++ b/lua/outfitter/sh.lua @@ -26,7 +26,67 @@ function HasMDL(mdl) return file.Exists(mdl .. '.mdl', 'GAME') end -function SanityCheckNData(mdl, download_path) +local DEPENDENCY_MANIFEST_VERSION = 1 +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 seen[id] then + seen[id] = true + dependencies[#dependencies + 1] = id + if #dependencies > MAX_DEPENDENCY_COUNT then + return nil, "dependency count" + end + end + end + + table.sort(dependencies, function(a, b) + if #a ~= #b then return #a < #b end + return a < b + end) + + return { + version = DEPENDENCY_MANIFEST_VERSION, + dependencies = dependencies + } +end + +function MakeDependencyManifest(dependencies) + return NormalizeDependencyManifest({ + version = DEPENDENCY_MANIFEST_VERSION, + dependencies = dependencies or {} + }) +end + +function DependencyManifestID(manifest) + if not manifest then return "" end + + local normalized = NormalizeDependencyManifest(manifest) + if not normalized then return "invalid" end + + local parts = { tostring(normalized.version), ":" } + for _, id in next, normalized.dependencies do + parts[#parts + 1] = tostring(#id) + parts[#parts + 1] = ":" + parts[#parts + 1] = id + end + + return table.concat(parts) +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 +98,10 @@ 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 + end + return nil end @@ -48,11 +112,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 +143,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 +154,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 +377,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,19 +385,24 @@ 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 local Player = FindMetaTable "Player" +function Player.OutfitDependencyManifest(pl) + return pl.outfitter_dependency_manifest +end + function Player.OutfitHash(pl) return pl.outfitter_latest end function Player.OutfitUpdateHash(pl) - local hash = GenID(pl:OutfitInfo()) + local mdl, download_path, skin, bodygroups = pl:OutfitInfo() + local hash = GenID(mdl, download_path, skin, bodygroups, pl:OutfitDependencyManifest()) pl.outfitter_latest = hash return hash @@ -338,11 +420,12 @@ function Player.OutfitInfo(pl) return pl.outfitter_mdl, pl.outfitter_download_path, pl.outfitter_skin, pl.outfitter_bodygroups 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..7fbe1cc 100644 --- a/lua/outfitter/ui.lua +++ b/lua/outfitter/ui.lua @@ -178,7 +178,7 @@ local function Command(com, v1) end end if n then - UIChoseWorkshop(n) + UIChoseWorkshop(n, false, true) elseif v1 == "fixanims" then FixLocalPlayerAnimations(true) elseif v1 == "fullupdate" then @@ -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 MakeDependencyManifest() 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,32 @@ 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 + + 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 = pl:OutfitInfo() + local current_dependency_manifest = pl:OutfitDependencyManifest() + 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 tried_mounting = nil chosen_mdl = nil + chosen_dependency_manifest = nil SetUIFetching(wsid, true) co.sleep(.5) @@ -573,6 +620,18 @@ function UIChoseWorkshop(wsid, opengui) end end + if review_dependencies and ShouldMountChildren() then + local dependency_manifest, err = coUIReviewDependencies(wsid, previous_dependency_manifest) + if dependency_manifest == false then + GUIOpen() + 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 +666,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) @@ -714,9 +774,17 @@ function SetAutowear() local pl = LocalPlayer() local mdl, wsid, skin, bodygroup = pl:OutfitInfo() + local dependency_manifest = pl:OutfitDependencyManifest() - 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 @@ -747,6 +815,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 = table.concat({ tostring(pl), tostring(wsid), tostring(err), tostring(err2) }, "|") + if dependency_failures[key] then return end + dependency_failures[key] = true + + local detail = err2 and " (" .. (err == "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() @@ -759,6 +842,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 @@ -849,7 +933,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 {}))