///|
pub(all) struct AssetDependency {
source : String
reference : String
target : String?
kind : String
property : Array[String]
status : String
} derive(Eq, Debug)
///|
pub extend AssetDependency with Eq::{equal, not_equal}
///|
pub extend AssetDependency with @debug.Debug::{to_repr}
///|
struct DependencyGraph {
roots : Array[String]
nodes : Map[String, MountedFile]
edges : Array[AssetDependency]
}
///|
pub fn DependencyGraph::edges(self : DependencyGraph) -> Array[AssetDependency] {
self.edges.copy()
}
///|
pub fn DependencyGraph::files(self : DependencyGraph) -> Array[MountedFile] {
let files = self.nodes.values().collect()
files.sort_by((a, b) => a.path.lexical_compare(b.path))
files
}
///|
fn asset_reference(
source : String,
reference : String,
kind : String,
property : Array[String],
) -> AssetDependency {
let reference = reference.replace_all(old="\\", new="/")
let valid = resource_relative(reference)
{
source,
reference,
target: if valid {
Some(reference)
} else {
None
},
kind,
property,
status: if valid {
"pending"
} else {
"dynamic"
},
}
}
///|
fn asset_kv3(
value : KV3Value,
source : String,
path : Array[String],
edges : Array[AssetDependency],
) -> Unit {
match value {
KV3Flag(flag, inner) => {
if flag == "resource" || flag == "resource_name" {
match inner {
KV3String(resource) => {
let target = resource +
(if resource.to_lower().has_suffix("_c") { "" } else { "_c" })
edges.push(asset_reference(source, target, "kv3-" + flag, path))
}
_ =>
edges.push({
source,
reference: flag,
target: None,
kind: "kv3-" + flag,
property: path,
status: "dynamic",
})
}
}
asset_kv3(inner, source, path, edges)
}
KV3Object(entries) =>
for entry in entries {
asset_kv3(entry.value, source, path + [entry.key], edges)
}
KV3Array(values) =>
for i in 0.. ()
}
}
///|
fn asset_scan(
file : MountedFile,
filesystem : GameFileSystem,
) -> Array[AssetDependency] raise {
let edges : Array[AssetDependency] = []
let path = file.path
let lower = path.to_lower()
if lower.has_suffix(".vmt") {
let material = parse_vmt(@utf8.decode(file.bytes[:]), source=path)
if material.shader.to_lower() == "patch" {
guard game_child(material.parameters, "include") is Some(Text(target)) else {
raise InvalidReference("VMT Patch include was not found")
}
edges.push(asset_reference(path, target, "vmt-include", ["include"]))
// Resolve only when the include is present, so missing includes are
// represented as graph diagnostics instead of losing the whole graph.
if resource_relative(target) && !filesystem.locate(target).is_empty() {
let expanded = load_vmt(path, p => {
@utf8.decode(filesystem.read(p).bytes[:])
})
for texture in expanded.textures() {
edges.push({
source: path,
reference: texture.value,
target: texture.target,
kind: "vmt-texture",
property: texture.path,
status: if texture.status == "file" {
"pending"
} else {
texture.status
},
})
}
}
} else {
for texture in material.textures() {
edges.push({
source: path,
reference: texture.value,
target: texture.target,
kind: "vmt-texture",
property: texture.path,
status: if texture.status == "file" {
"pending"
} else {
texture.status
},
})
}
}
} else if lower.has_suffix(".kv3") {
let binary = file.bytes.length() >= 4 &&
(
(
file.bytes[0] >= b'\x01' &&
file.bytes[0] <= b'\x05' &&
file.bytes[1] == b'3' &&
file.bytes[2] == b'V' &&
file.bytes[3] == b'K'
) ||
file.bytes[:4].to_owned() == b"VKV\x03"
)
let doc = if binary {
decode_kv3_binary(file.bytes).document()
} else {
parse_kv3(@utf8.decode(file.bytes[:]))
}
asset_kv3(doc.root, path, [], edges)
} else if lower.has_suffix("_c") {
let container = read_resource_container(file.bytes)
for external in container.external_references() {
let target = external.name +
(if external.name.to_lower().has_suffix("_c") { "" } else { "_c" })
edges.push(
asset_reference(path, target, "rerl", [external.id.to_string()]),
)
}
let mut inspected = false
for block in container.blocks {
if block.kind == "DATA" && block.bytes.length() >= 4 {
let magic = valve_bytes(block.bytes).uint(4)
if magic == 0x03564b56UL ||
(magic >= 0x4b563301UL && magic <= 0x4b563305UL) {
asset_kv3(
decode_kv3_binary(block.bytes).document().root,
path,
[],
edges,
)
inspected = true
}
}
}
if !inspected &&
![".vtex_c", ".vsnd_c"].any(suffix => lower.has_suffix(suffix)) {
edges.push({
source: path,
reference: path,
target: None,
kind: "unscanned-resource-data",
property: [],
status: "opaque",
})
}
} else if [".vdf", ".txt", ".res"].any(suffix => lower.has_suffix(suffix)) {
let (document, _) = parse_internal(
@utf8.decode(file.bytes[:]),
ParseOptions::default(),
true,
)
for entry in document.entries {
if entry.key == "#include" || entry.key == "#base" {
guard entry.value is Text(target) else {
raise InvalidReference("File directive requires text")
}
edges.push(
asset_reference(
path,
child_reference(path, target),
"kv1-" + entry.key,
[entry.key],
),
)
}
}
} else if ![".vtf", ".png", ".jpg", ".jpeg", ".wav", ".mp3", ".ogg", ".webm"].any(suffix => {
lower.has_suffix(suffix)
},
) {
edges.push({
source: path,
reference: path,
target: None,
kind: "unscanned-format",
property: [],
status: "opaque",
})
}
edges
}
///|
/// Build a bounded transitive graph from an explicit mounted filesystem.
/// Missing, dynamic and opaque references stay visible; base-game files retain
/// their provenance and can be excluded from export without marking them missing.
pub fn build_dependency_graph(
roots : Array[String],
filesystem : GameFileSystem,
max_files~ : Int32 = 2048,
max_bytes~ : Int32 = 67108864,
) -> DependencyGraph raise {
if roots.is_empty() ||
roots.length() > max_files ||
max_files < 1 ||
max_files > 10000 ||
max_bytes < 1 ||
max_bytes > 268435456 {
raise InvalidReference("Invalid dependency graph budgets")
}
let roots = roots.map(p => game_virtual_path(p))
let queue = roots.copy()
let nodes : Map[String, MountedFile] = Map([])
let seen : Map[String, Bool] = Map([])
let edges : Array[AssetDependency] = []
let mut cursor = 0
let mut total = 0
while cursor < queue.length() {
let path = queue[cursor]
cursor += 1
if seen.contains(path.to_lower()) {
continue
}
seen[path.to_lower()] = true
if seen.length() > max_files {
raise InvalidReference("Dependency file budget exceeded")
}
if filesystem.locate(path).is_empty() {
continue
}
let file = filesystem.read(path)
if file.bytes.length() > max_bytes - total {
raise InvalidReference("Dependency aggregate bytes exceeded")
}
total += file.bytes.length()
nodes[path.to_lower()] = file
let scanned = asset_scan(file, filesystem)
if scanned.length() > 100000 - edges.length() {
raise InvalidReference("Dependency edge budget exceeded")
}
for edge in scanned {
let edge = if edge.target is Some(target) {
let found = filesystem.locate(target)
if found.is_empty() {
{ ..edge, status: "missing", }
} else {
queue.push(target)
{
..edge,
status: if found[0].base_game {
"base-game"
} else {
"resolved"
},
}
}
} else {
edge
}
edges.push(edge)
}
}
for root in roots {
if !nodes.contains(root.to_lower()) {
edges.push({
source: "",
reference: root,
target: Some(root),
kind: "root",
property: [],
status: "missing",
})
}
}
{ roots: roots.copy(), nodes, edges, }
}
///|
pub fn DependencyGraph::dependents(
self : DependencyGraph,
target : String,
transitive~ : Bool = true,
) -> Array[String] {
let queue = [target.to_lower()]
let seen : Map[String, Bool] = Map([])
seen[target.to_lower()] = true
let output : Array[String] = []
let mut i = 0
while i < queue.length() {
let current = queue[i]
i += 1
for edge in self.edges {
if edge.target.map(p => p.to_lower()) == Some(current) &&
!seen.contains(edge.source.to_lower()) &&
!edge.source.is_empty() {
seen[edge.source.to_lower()] = true
output.push(edge.source)
if transitive {
queue.push(edge.source.to_lower())
}
}
}
}
output.sort_by((a, b) => a.lexical_compare(b))
output
}
///|
pub fn DependencyGraph::export_files(
self : DependencyGraph,
include_base~ : Bool = false,
strict~ : Bool = true,
) -> Map[String, Bytes] raise {
if strict &&
self.edges.any(e => ["missing", "dynamic", "opaque"].contains(e.status)) {
raise InvalidReference(
"Dependency closure has unresolved or unscanned references",
)
}
let output : Map[String, Bytes] = Map([])
for file in self.nodes.values() {
if include_base || !file.base_game {
output[file.path] = file.bytes
}
}
if output.is_empty() && !self.roots.is_empty() {
raise InvalidReference("Dependency export is empty")
}
output
}
///|
pub fn DependencyGraph::export_vpk(
self : DependencyGraph,
include_base~ : Bool = false,
strict~ : Bool = true,
options~ : BinaryOptions = BinaryOptions::default(),
) -> Bytes raise {
create_vpk(self.export_files(include_base~, strict~), options~)
}
///|
pub fn DependencyGraph::export_zip(
self : DependencyGraph,
include_base~ : Bool = false,
strict~ : Bool = true,
) -> Bytes raise {
export_files_zip(self.export_files(include_base~, strict~))
}