///|
pub(all) struct VMTOrigin {
path : Array[Int32]
source : String
operation : String
} derive(Eq, Debug)
///|
pub(all) struct VMTMaterial {
shader : String
parameters : Array[Entry]
origins : Array[VMTOrigin]
includes : Array[String]
} derive(Eq, Debug)
///|
pub(all) struct VMTTexture {
path : Array[String]
parameter : String
value : String
target : String?
status : String
} derive(Eq, Debug)
///|
pub extend VMTOrigin with Eq::{equal, not_equal}
///|
pub extend VMTOrigin with @debug.Debug::{to_repr}
///|
pub extend VMTMaterial with Eq::{equal, not_equal}
///|
pub extend VMTMaterial with @debug.Debug::{to_repr}
///|
pub extend VMTTexture with Eq::{equal, not_equal}
///|
pub extend VMTTexture with @debug.Debug::{to_repr}
///|
priv struct VMTNode {
key : String
value : String?
children : Array[VMTNode]
source : String
operation : String
}
///|
fn vmt_nodes(
entries : Array[Entry],
source : String,
operation : String,
) -> Array[VMTNode] {
entries.map(e => {
match e.value {
Text(text) =>
{ key: e.key, value: Some(text), children: [], source, operation, }
Object(children) =>
{
key: e.key,
value: None,
children: vmt_nodes(children, source, operation),
source,
operation,
}
}
})
}
///|
fn vmt_entries(
nodes : Array[VMTNode],
path : Array[Int32],
origins : Array[VMTOrigin],
) -> Array[Entry] {
nodes.mapi((i, node) => {
let path = path + [i]
origins.push({ path, source: node.source, operation: node.operation, })
{
key: node.key,
value: match node.value {
Some(s) => Text(s)
None => Object(vmt_entries(node.children, path, origins))
},
}
})
}
///|
fn vmt_read(
text : String,
options : ParseOptions,
) -> (String, Array[Entry]) raise {
let doc = parse(text, options~)
guard doc.entries.length() == 1 && doc.entries[0].value is Object(parameters) else {
raise InvalidReference("VMT requires one shader object")
}
(doc.entries[0].key, parameters)
}
///|
pub fn parse_vmt(
text : String,
source~ : String = "material.vmt",
options~ : ParseOptions = ParseOptions::default(),
) -> VMTMaterial raise {
let (shader, entries) = vmt_read(text, options)
let origins : Array[VMTOrigin] = []
let parameters = vmt_entries(
vmt_nodes(entries, source, "source"),
[],
origins,
)
{ shader, parameters, origins, includes: [], }
}
///|
fn vmt_patch(
base : Array[VMTNode],
patch : Array[VMTNode],
replace : Bool,
depth : Int32,
) -> Array[VMTNode] raise {
if depth > 128 {
raise InvalidReference("VMT patch depth exceeded")
}
let output = base.copy()
for node in patch {
let key = node.key.to_lower()
let mut found : Int32? = None
for i in 0.. ignore
}
} else if found is Some(i) {
output[i] = { ..node, key: output[i].key, }
} else if !replace {
output.push(node)
}
} else if key == "proxies" {
if found is Some(i) {
if output[i].value is Some(_) {
raise InvalidReference("VMT Proxies must be an object")
}
output[i] = {
..output[i],
children: output[i].children + node.children,
}
} else {
output.push(node)
}
} else if found is Some(i) {
if output[i].value is Some(_) {
raise InvalidReference("VMT patch changes parameter shape")
}
output[i] = {
..output[i],
children: vmt_patch(
output[i].children,
node.children,
replace,
depth + 1,
),
}
} else if !replace {
output.push(node)
}
}
output
}
///|
fn vmt_expand(
source : String,
loader : (String) -> String raise,
options : ParseOptions,
stack : Array[String],
includes : Array[String],
chars : Ref[Int32],
max_files : Int32,
) -> (String, Array[VMTNode]) raise {
if stack.length() >= max_files || stack.contains(source.to_lower()) {
raise InvalidReference("VMT Patch include cycle or file budget exceeded")
}
let text = loader(source)
if text.char_length() > options.max_input_chars - chars.val {
raise InvalidReference("VMT aggregate input budget exceeded")
}
chars.val += text.char_length()
let (shader, entries) = vmt_read(text, options)
if shader.to_lower() != "patch" {
return (shader, vmt_nodes(entries, source, "source"))
}
let include_entries = entries.filter(e => e.key.to_lower() == "include")
guard include_entries.length() == 1 &&
include_entries[0].value is Text(target) else {
raise InvalidReference("VMT Patch requires one include")
}
let target = game_virtual_path(target)
if !target.to_lower().has_prefix("materials/") ||
!target.to_lower().has_suffix(".vmt") {
raise InvalidReference("VMT Patch include requires a materials/*.vmt path")
}
includes.push(target)
let (shader, base) = vmt_expand(
target,
loader,
options,
stack + [source.to_lower()],
includes,
chars,
max_files,
)
let mut result = base
for entry in entries {
let command = entry.key.to_lower()
if command == "include" {
continue
}
if command != "insert" && command != "replace" {
raise InvalidReference("Unsupported VMT Patch command")
}
guard entry.value is Object(parameters) else {
raise InvalidReference("VMT Patch command requires an object")
}
result = vmt_patch(
result,
vmt_nodes(parameters, source, command),
command == "replace",
0,
)
}
(shader, result)
}
///|
/// Resolve Patch include/insert/replace through an explicit game-relative loader.
/// Origin indices address the expanded parameter tree, including repeated proxies.
pub fn load_vmt(
source : String,
loader : (String) -> String raise,
options~ : ParseOptions = ParseOptions::default(),
max_files~ : Int32 = 64,
) -> VMTMaterial raise {
if max_files < 1 || max_files > 128 {
raise InvalidReference("Invalid VMT file budget")
}
let includes : Array[String] = []
let (shader, nodes) = vmt_expand(
game_virtual_path(source),
loader,
options,
[],
includes,
{ val: 0, },
max_files,
)
let origins : Array[VMTOrigin] = []
let parameters = vmt_entries(nodes, [], origins)
stringify({ entries: [{ key: shader, value: Object(parameters), }], })
|> ignore
{ shader, parameters, origins, includes, }
}
///|
pub fn VMTMaterial::stringify(self : VMTMaterial) -> String raise {
stringify({
entries: [{ key: self.shader, value: Object(self.parameters), }],
})
}
///|
fn vmt_texture_keys(extra : Array[String]) -> Array[String] {
[
"$basetexture", "$basetexture2", "$bumpmap", "$bumpmap2", "$normalmap", "$detail",
"$detail2", "$envmap", "$envmapmask", "$phongexponenttexture", "$selfillummask",
"$blendmodulatetexture", "$lightwarptexture", "$flowmap", "$flow_noise_texture",
"$dudvmap", "$refracttinttexture",
] +
extra.map(s => s.to_lower())
}
///|
fn vmt_textures(
entries : Array[Entry],
path : Array[String],
keys : Array[String],
output : Array[VMTTexture],
) -> Unit {
for entry in entries {
let child = path + [entry.key]
match entry.value {
Object(children) =>
if entry.key.to_lower() != "proxies" {
vmt_textures(children, child, keys, output)
}
Text(value) =>
if keys.contains(entry.key.to_lower()) {
let normalized = value.replace_all(old="\\", new="/")
let lower = normalized.to_lower()
let builtin = lower == "env_cubemap" || lower.has_prefix("_rt_")
let dynamic = value.is_empty() ||
value.contains("$") ||
value.contains("%") ||
!resource_relative(normalized)
let target = if builtin || dynamic {
None
} else {
Some(
(if lower.has_prefix("materials/") {
normalized
} else {
"materials/" + normalized
}) +
(if lower.has_suffix(".vtf") { "" } else { ".vtf" }),
)
}
output.push({
path: child,
parameter: entry.key,
value,
target,
status: if builtin {
"builtin"
} else if dynamic {
"dynamic"
} else {
"file"
},
})
}
}
}
}
///|
pub fn VMTMaterial::textures(
self : VMTMaterial,
extra_parameters~ : Array[String] = [],
) -> Array[VMTTexture] raise {
self.stringify() |> ignore
let output : Array[VMTTexture] = []
vmt_textures(self.parameters, [], vmt_texture_keys(extra_parameters), output)
output
}
///|
/// Return a new material with one top-level parameter. Duplicate matches are
/// rejected so an edit cannot silently target a different effective parameter.
pub fn VMTMaterial::with_parameter(
self : VMTMaterial,
key : String,
value : String,
) -> VMTMaterial raise {
self.stringify() |> ignore
let indices : Array[Int32] = []
for i in 0.. 1 {
raise InvalidEdit("Ambiguous duplicate VMT parameter")
}
let entries = self.parameters.copy()
if indices.is_empty() {
entries.push({ key, value: Text(value), })
} else {
entries[indices[0]] = { key: entries[indices[0]].key, value: Text(value), }
}
let edited = if indices.is_empty() {
entries.length() - 1
} else {
indices[0]
}
let origins = self.origins.filter(origin => {
origin.path.is_empty() || origin.path[0] != edited
})
origins.push({ path: [edited], source: "edited.vmt", operation: "edit", })
let result = { ..self, parameters: entries, origins, }
result.stringify() |> ignore
result
}