Run a node declaration without compiling it
`ops/*.yaml` plus `build.rs` has been the class-1 plugin format since the declarative nodes landed — it was simply resolved at build time. Nothing about a declaration requires the compiler: everything it produces is data plus a WGSL string, and the composer already assembles WGSL at run time from whatever operations are active. So this is not a new mechanism. It is the existing one, loaded later (FR-PLG-2). `DeclaredOp` implements `Operation` from an owned `Declaration` — one interpreter over many declarations, where `build.rs` emits generated code per node. The generated path stays, as FR-PLG-2 says it should: a generated `match` is faster than an interpreted one, the built-ins' declared `tests:` have to run under `cargo test`, and generated source is inspectable in a way an interpreter's state is not. **The reader is now one file, read by both.** `src/declared/decl.rs` and `src/declared/expr.rs` are `#[path]`-included by `build.rs` as well as being modules of the crate, and they produce a neutral `Declaration` that names no Rust type. The build script's job is reduced to *rendering* that declaration as Rust; `DeclaredOp` converts the same declaration into descriptors and `Expr::eval` walks the same tree the renderer writes out. There is one grammar, one set of validations and one set of error messages, so "a plugin is the same kind of thing as a built-in" is structural rather than aspirational. What remains genuinely written twice is the pair of backends — an arithmetic node rendered as Rust here and evaluated there — and that is what the parity test stands between. `tests/declared_parity.rs` parses every built-in declaration at run time and asserts the composed WGSL is byte-for-byte what the generated implementation produces, with the uniform block bit-for-bit identical, at both ends of every parameter's range and at four interior points; then again over the whole develop chain with the declared nodes swapped in, which is what covers uniform slot ordering and helper de-duplication between operations. A third test asserts the declared and hand-written nodes partition `ops/` between them, so coverage cannot shrink silently. Bit-for-bit rather than within a tolerance, because a tolerance is where a real divergence hides. The one thing that had to be got right for that to hold is number literals: `expr::as_f32` rounds a decimal exactly once, through the same shortest-round-trip text the compiler is handed, rather than rounding an `f64` a second time. Not in scope, and deliberately untagged: load-time WGSL validation (FR-PLG-11), id namespacing, a plugin directory read at startup, and pass nodes (FR-PLG-2a). Those are separate work, and tagging them from here would be the overstatement the spec's own §7 warns about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,487 @@
|
||||
//! TRACES: FR-PLG-2 | FR-PLG-2d
|
||||
//! Running a node declaration without compiling it.
|
||||
//!
|
||||
//! # The format already existed
|
||||
//!
|
||||
//! `ops/*.yaml` plus `build.rs` has been the class-1 plugin format since the
|
||||
//! declarative nodes landed — it was simply resolved at build time:
|
||||
//!
|
||||
//! ```text
|
||||
//! ops/exposure.yaml ──build.rs──▶ generated impl Operation ──▶ fused shader
|
||||
//! ```
|
||||
//!
|
||||
//! Nothing about that requires the declaration to be present when the compiler
|
||||
//! runs. Everything a declaration produces is *data plus a WGSL string*, and
|
||||
//! the composer already assembles WGSL at run time from whichever operations
|
||||
//! are active. So this module is not a new mechanism; it is the existing one,
|
||||
//! loaded later.
|
||||
//!
|
||||
//! [`DeclaredOp`] is **one interpreter over many declarations**, where
|
||||
//! `build.rs` emits generated code per node. It implements [`Operation`] from
|
||||
//! an owned [`Declaration`], which is only possible because descriptors became
|
||||
//! owned — see [`crate::descriptor::OpDescriptor`] for why a `&'static`
|
||||
//! descriptor made a run-time node impossible.
|
||||
//!
|
||||
//! # Both paths stay
|
||||
//!
|
||||
//! The generated path is not removed and should not be. FR-PLG-2 says so, and
|
||||
//! the reasons are good ones: a generated `match` is faster than an
|
||||
//! interpreted one, the built-ins' declared tests have to run under `cargo
|
||||
//! test`, and generated source is *inspectable* in a way an interpreter's
|
||||
//! internal state is not.
|
||||
//!
|
||||
//! What matters is that the two are **indistinguishable downstream**, and that
|
||||
//! is a test rather than an intention: `tests/declared_parity.rs` parses every
|
||||
//! built-in `ops/*.yaml` at run time and asserts the composed WGSL is
|
||||
//! byte-for-byte identical to what the generated implementation produces, for
|
||||
//! the same parameter values. If the two ever disagree, a plugin is not the
|
||||
//! same kind of thing as a built-in and the premise of the whole plugin plan
|
||||
//! has failed quietly.
|
||||
//!
|
||||
//! # What this is not, yet
|
||||
//!
|
||||
//! Not load-time WGSL validation (FR-PLG-11), not id namespacing (FR-PLG-2's
|
||||
//! `author.name`), and not a plugin directory read at startup. Those are
|
||||
//! separate work and are deliberately absent — a declaration reaching
|
||||
//! [`DeclaredOp`] here is one that ships in this repository, so its WGSL has
|
||||
//! already been compiled by the build and its id has already been checked for
|
||||
//! collisions.
|
||||
|
||||
pub mod decl;
|
||||
pub mod expr;
|
||||
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
pub use decl::{Declaration, Node, SharedHelpers};
|
||||
|
||||
use crate::descriptor::{
|
||||
intern, Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation,
|
||||
Scale, Unit, WidgetDemand, WidgetKind,
|
||||
};
|
||||
use crate::operation::{Helper, Operation, Uniform};
|
||||
use expr::{as_f32, Expr};
|
||||
|
||||
/// The shared WGSL helper library, as the built-in nodes see it.
|
||||
///
|
||||
/// The very same `_helpers.yaml` `build.rs` reads, embedded rather than read
|
||||
/// from disk: there is no file path an installed application could look it up
|
||||
/// at, and embedding is what makes it impossible for the compiled helpers and
|
||||
/// the interpreted ones to be two different files.
|
||||
///
|
||||
/// Panics if the bundled file does not parse, which is a build-time fact about
|
||||
/// this repository rather than anything a user can cause — `build.rs` reads the
|
||||
/// same text on the same build and fails first.
|
||||
pub fn builtin_helpers() -> &'static SharedHelpers {
|
||||
static LIBRARY: LazyLock<SharedHelpers> = LazyLock::new(|| {
|
||||
decl::read_helpers(include_str!("../../ops/_helpers.yaml"))
|
||||
.unwrap_or_else(|e| panic!("the bundled {}: {e}", decl::HELPERS_FILE))
|
||||
});
|
||||
&LIBRARY
|
||||
}
|
||||
|
||||
/// TRACES: FR-PLG-2
|
||||
/// An operation built from a declaration at run time.
|
||||
///
|
||||
/// Holds everything the trait has to answer with, resolved once when the
|
||||
/// declaration is read: the descriptor, the uniform expressions, the helper
|
||||
/// list and the fragment text. Nothing is re-parsed per call, so the per-frame
|
||||
/// cost of an interpreted node is the arithmetic in [`Expr::eval`] and nothing
|
||||
/// else — the same arithmetic the generated node does, simply walked rather
|
||||
/// than inlined.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeclaredOp {
|
||||
descriptor: Arc<OpDescriptor>,
|
||||
/// Parameter ids in declaration order, parallel to `defaults` and
|
||||
/// `values`.
|
||||
///
|
||||
/// Three parallel `Vec`s rather than one of triples because the hot read
|
||||
/// is `values` alone, and because `set_param` writes exactly one of them.
|
||||
/// They are built together and never resized.
|
||||
params: Vec<ParamId>,
|
||||
defaults: Vec<f32>,
|
||||
values: Vec<f32>,
|
||||
uniforms: Vec<DeclaredUniform>,
|
||||
/// The declared `active:` rule, or `None` for the default one.
|
||||
active: Option<Expr>,
|
||||
wgsl: String,
|
||||
helpers: Vec<Helper>,
|
||||
presentation: Option<Presentation>,
|
||||
order: i64,
|
||||
}
|
||||
|
||||
/// One uniform: the name the fragment reads it by, and how to compute it.
|
||||
#[derive(Debug, Clone)]
|
||||
struct DeclaredUniform {
|
||||
/// Interned when the declaration was read, not on every `uniforms()` call.
|
||||
/// See [`crate::operation::Uniform::name`].
|
||||
name: &'static str,
|
||||
expr: Expr,
|
||||
}
|
||||
|
||||
impl DeclaredOp {
|
||||
/// Read one `ops/<id>.yaml` and build the operation it declares.
|
||||
///
|
||||
/// `ctx` names the source in error messages — a path, usually.
|
||||
///
|
||||
/// A `rust:` node is an error here rather than a silent `None`: it names a
|
||||
/// hand-written type in this crate, which is by definition not something a
|
||||
/// declaration can produce, and a caller that got one back as "nothing to
|
||||
/// do" would drop an operation out of the chain without saying so.
|
||||
pub fn from_yaml(text: &str, ctx: &str, library: &SharedHelpers) -> Result<Self, String> {
|
||||
let names = library.names();
|
||||
match decl::read_node(text, ctx, &names)? {
|
||||
Node::Declared(d) => Self::new(&d, library),
|
||||
Node::Rust { id, ty, .. } => Err(format!(
|
||||
"`{id}` declares `rust: {ty}`, which names a hand-written type \
|
||||
rather than describing an operation. Only a full declaration \
|
||||
can be read at run time."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the operation a parsed declaration describes.
|
||||
pub fn new(declaration: &Declaration, library: &SharedHelpers) -> Result<Self, String> {
|
||||
let params: Vec<ParamId> = declaration
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| ParamId::interned(&p.id))
|
||||
.collect();
|
||||
let defaults: Vec<f32> = declaration
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| as_f32(p.default))
|
||||
.collect();
|
||||
|
||||
// Helpers in the order the composer will see them: the shared ones the
|
||||
// node asked for, in the order it asked, then its own definitions.
|
||||
// Order decides emission order in the generated shader, so it is part
|
||||
// of the output rather than an implementation detail.
|
||||
let mut helpers =
|
||||
Vec::with_capacity(declaration.shared_helpers.len() + declaration.local_helpers.len());
|
||||
for name in &declaration.shared_helpers {
|
||||
// `decl::read_node` has already rejected a name the library does
|
||||
// not define, so this is a library that changed underneath a
|
||||
// declaration rather than a declaration with a typo in it.
|
||||
let helper = library.get(name).ok_or_else(|| {
|
||||
format!(
|
||||
"`{}` asks for the shared helper `{name}`, which this \
|
||||
helper library does not define",
|
||||
declaration.id
|
||||
)
|
||||
})?;
|
||||
helpers.push(Helper {
|
||||
name: intern(&helper.name),
|
||||
source: intern(&helper.source()),
|
||||
});
|
||||
}
|
||||
for helper in &declaration.local_helpers {
|
||||
helpers.push(Helper {
|
||||
name: intern(&helper.name),
|
||||
source: intern(&helper.source()),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
descriptor: Arc::new(OpDescriptor {
|
||||
id: OpId::interned(&declaration.id),
|
||||
label: LocalizedKey::interned(&declaration.label),
|
||||
params: declaration.params.iter().map(param_descriptor).collect(),
|
||||
attributes: declaration
|
||||
.attributes
|
||||
.iter()
|
||||
.copied()
|
||||
.map(attribute)
|
||||
.collect(),
|
||||
}),
|
||||
values: defaults.clone(),
|
||||
params,
|
||||
defaults,
|
||||
uniforms: declaration
|
||||
.uniforms
|
||||
.iter()
|
||||
.map(|u| DeclaredUniform {
|
||||
name: intern(&u.name),
|
||||
expr: u.expr.clone(),
|
||||
})
|
||||
.collect(),
|
||||
active: declaration.active.clone(),
|
||||
wgsl: declaration.wgsl_body(),
|
||||
helpers,
|
||||
presentation: declaration.presentation.as_deref().map(presentation),
|
||||
order: declaration.order,
|
||||
})
|
||||
}
|
||||
|
||||
/// Where this node sits in the chain, from its `order:`.
|
||||
///
|
||||
/// Not part of [`Operation`] — the graph holds operations in a `Vec` and
|
||||
/// order *is* that position (ARCH §3.4). Exposed so whoever assembles a
|
||||
/// chain out of declarations can sort them, which is what `build.rs` does
|
||||
/// at the other end.
|
||||
pub fn order(&self) -> i64 {
|
||||
self.order
|
||||
}
|
||||
|
||||
fn index_of(&self, id: ParamId) -> Option<usize> {
|
||||
self.params.iter().position(|p| *p == id)
|
||||
}
|
||||
|
||||
/// One parameter's current value, by the name an expression calls it.
|
||||
///
|
||||
/// Returns 0.0 for a name that is not a parameter, matching what the
|
||||
/// generated `param()` does with an unknown id. It cannot happen —
|
||||
/// [`expr::parse`] rejects a name that is not declared — but a silent zero
|
||||
/// is a better failure here than a panic inside a render.
|
||||
fn value_named(&self, name: &str) -> f32 {
|
||||
self.params
|
||||
.iter()
|
||||
.position(|p| p.0 == name)
|
||||
.map_or(0.0, |i| self.values[i])
|
||||
}
|
||||
}
|
||||
|
||||
impl Operation for DeclaredOp {
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
self.descriptor.clone()
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
match self.index_of(id) {
|
||||
Some(i) => self.values[i] = value,
|
||||
// The same complaint the generated `set_param` makes, for the same
|
||||
// reason: a parameter that does not exist is a sidecar or a UI
|
||||
// naming something this build does not have, and dropping it
|
||||
// silently is how an edit comes to be half-applied.
|
||||
None => log::warn!("{}: unknown parameter {id}", self.descriptor.id),
|
||||
}
|
||||
}
|
||||
|
||||
fn param(&self, id: ParamId) -> f32 {
|
||||
self.index_of(id).map_or(0.0, |i| self.values[i])
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
match &self.active {
|
||||
Some(expr) => expr.eval(&|name| self.value_named(name)) != 0.0,
|
||||
// The default rule, and the honest one: the operation is doing
|
||||
// something exactly when a parameter has moved off its default.
|
||||
// Short-circuiting in declaration order, which is what the
|
||||
// generated `a != d || b != d` does.
|
||||
None => self.values.iter().zip(&self.defaults).any(|(v, d)| v != d),
|
||||
}
|
||||
}
|
||||
|
||||
fn wgsl_body(&self) -> String {
|
||||
self.wgsl.clone()
|
||||
}
|
||||
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
self.uniforms
|
||||
.iter()
|
||||
.map(|u| Uniform {
|
||||
name: u.name,
|
||||
value: u.expr.eval(&|name| self.value_named(name)),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn helpers(&self) -> &[Helper] {
|
||||
&self.helpers
|
||||
}
|
||||
|
||||
fn presentation(&self) -> Option<Presentation> {
|
||||
self.presentation.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// A declared parameter as the descriptor the panel reads.
|
||||
///
|
||||
/// Every arm calls the constructor `build.rs` renders a call to, so the two
|
||||
/// produce the same `ParamDescriptor` by construction rather than by
|
||||
/// coincidence.
|
||||
fn param_descriptor(p: &decl::ParamDef) -> ParamDescriptor {
|
||||
let id = intern(&p.id);
|
||||
let label = intern(&p.label);
|
||||
match &p.kind {
|
||||
decl::Kind::Stops { min, max } => {
|
||||
ParamDescriptor::stops(id, label, as_f32(*min), as_f32(*max))
|
||||
}
|
||||
decl::Kind::Amount => ParamDescriptor::amount(id, label),
|
||||
decl::Kind::Switch => ParamDescriptor::switch(id, label),
|
||||
decl::Kind::Fraction { default } => ParamDescriptor::fraction(id, label, as_f32(*default)),
|
||||
decl::Kind::Scalar {
|
||||
min,
|
||||
max,
|
||||
default,
|
||||
unit,
|
||||
scale,
|
||||
precision,
|
||||
} => ParamDescriptor::scalar(
|
||||
id,
|
||||
label,
|
||||
as_f32(*min),
|
||||
as_f32(*max),
|
||||
as_f32(*default),
|
||||
match unit {
|
||||
decl::Unit::None => Unit::None,
|
||||
decl::Unit::Stops => Unit::Stops,
|
||||
decl::Unit::Kelvin => Unit::Kelvin,
|
||||
decl::Unit::Percent => Unit::Percent,
|
||||
},
|
||||
match scale {
|
||||
decl::Scale::Linear => Scale::Linear,
|
||||
decl::Scale::Perceptual => Scale::Perceptual,
|
||||
},
|
||||
// `decl::read_kind` refuses a precision that does not fit, so this
|
||||
// cast cannot lose anything.
|
||||
*precision as u8,
|
||||
),
|
||||
decl::Kind::Enum { variants } => ParamDescriptor::choice(
|
||||
id,
|
||||
label,
|
||||
variants.iter().map(|v| LocalizedKey::interned(v)).collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn attribute(a: decl::Attr) -> Attribute {
|
||||
match a {
|
||||
decl::Attr::Tone => Attribute::Tone,
|
||||
decl::Attr::Colour => Attribute::Colour,
|
||||
decl::Attr::Detail => Attribute::Detail,
|
||||
decl::Attr::Optics => Attribute::Optics,
|
||||
decl::Attr::Geometry => Attribute::Geometry,
|
||||
decl::Attr::Effect => Attribute::Effect,
|
||||
}
|
||||
}
|
||||
|
||||
fn widget(w: decl::Widget) -> WidgetKind {
|
||||
match w {
|
||||
decl::Widget::ToneCurve => WidgetKind::ToneCurve,
|
||||
decl::Widget::ColourWheel => WidgetKind::ColourWheel,
|
||||
decl::Widget::CropOverlay => WidgetKind::CropOverlay,
|
||||
decl::Widget::GradientHandle => WidgetKind::GradientHandle,
|
||||
decl::Widget::BrushMask => WidgetKind::BrushMask,
|
||||
decl::Widget::WhitePoint => WidgetKind::WhitePoint,
|
||||
}
|
||||
}
|
||||
|
||||
fn presentation(p: &decl::PresentationDef) -> Presentation {
|
||||
Presentation {
|
||||
widgets: p.widgets.iter().copied().map(widget).collect(),
|
||||
demand: WidgetDemand {
|
||||
two_dimensional: p.two_dimensional,
|
||||
precise_pointing: p.precise_pointing,
|
||||
},
|
||||
params: p.params.iter().map(|n| ParamId::interned(n)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// TRACES: FR-PLG-2d
|
||||
/// The two spellings of the attribute vocabulary are one vocabulary.
|
||||
///
|
||||
/// `decl::Attr` exists because the reader is compiled by `build.rs`, which
|
||||
/// cannot see `crate::descriptor`. That is a duplicated closed list, and a
|
||||
/// duplicated closed list is exactly the thing FR-PLG-2d warns about: an
|
||||
/// attribute added to one and not the other would put an operation in a
|
||||
/// category the panel does not know it has.
|
||||
#[test]
|
||||
fn the_attribute_vocabulary_is_the_same_on_both_sides() {
|
||||
assert_eq!(decl::Attr::ALL.len(), Attribute::ALL.len());
|
||||
for (a, b) in decl::Attr::ALL.iter().zip(Attribute::ALL) {
|
||||
// Same order, so an index into one indexes the other.
|
||||
assert_eq!(attribute(*a), b);
|
||||
// And the name a declaration writes resolves to the same variant.
|
||||
assert_eq!(Attribute::from_name(a.name()), Some(b), "{}", a.name());
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-PLG-2d
|
||||
/// Every `WidgetKind` is nameable from a declaration.
|
||||
///
|
||||
/// A widget the core can ask for but a declaration cannot name is a widget
|
||||
/// only a hand-written operation may have, which would make the two kinds
|
||||
/// of node unequal in exactly the way FR-PLG-2 forbids.
|
||||
#[test]
|
||||
fn every_widget_kind_can_be_declared() {
|
||||
assert_eq!(decl::Widget::ALL.len(), 6);
|
||||
let named: Vec<WidgetKind> = decl::Widget::ALL.iter().copied().map(widget).collect();
|
||||
for kind in [
|
||||
WidgetKind::ToneCurve,
|
||||
WidgetKind::ColourWheel,
|
||||
WidgetKind::CropOverlay,
|
||||
WidgetKind::GradientHandle,
|
||||
WidgetKind::BrushMask,
|
||||
WidgetKind::WhitePoint,
|
||||
] {
|
||||
assert!(named.contains(&kind), "{kind:?} cannot be declared");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bundled_helper_library_parses() {
|
||||
// It is `include_str!`'d, so a syntax error in it is a panic at first
|
||||
// use rather than a build failure. This is the first use.
|
||||
assert!(!builtin_helpers().helpers.is_empty());
|
||||
}
|
||||
|
||||
fn exposure() -> DeclaredOp {
|
||||
DeclaredOp::from_yaml(
|
||||
include_str!("../../ops/exposure.yaml"),
|
||||
"ops/exposure.yaml",
|
||||
builtin_helpers(),
|
||||
)
|
||||
.expect("exposure declares an operation")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_declaration_becomes_an_operation_with_its_declared_descriptor() {
|
||||
let op = exposure();
|
||||
let d = op.descriptor();
|
||||
assert_eq!(d.id, OpId("exposure"));
|
||||
assert_eq!(d.label, LocalizedKey("op.exposure"));
|
||||
assert_eq!(d.params.len(), 1);
|
||||
assert_eq!(d.params[0].id, ParamId("exposure"));
|
||||
assert_eq!(d.attributes, vec![Attribute::Tone]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_declared_operation_is_neutral_until_a_parameter_moves() {
|
||||
let mut op = exposure();
|
||||
assert!(!op.is_active());
|
||||
assert_eq!(op.uniforms()[0].value, 1.0);
|
||||
|
||||
op.set_param(ParamId("exposure"), 1.0);
|
||||
assert!(op.is_active());
|
||||
// A stop is a doubling — the same assertion `exposure.yaml`'s own
|
||||
// declared test makes against the generated implementation.
|
||||
assert_eq!(op.uniforms()[0].value, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_interned_id_matches_a_literal_one() {
|
||||
// The property that lets a declared operation be addressed by the same
|
||||
// `ParamId` constants the generated code matches on. If interning ever
|
||||
// stopped deduplicating, this would still pass — `ParamId` compares
|
||||
// string contents — but the point is that the two are interchangeable
|
||||
// at every call site.
|
||||
let mut op = exposure();
|
||||
op.set_param(ParamId::interned("exposure"), 2.0);
|
||||
assert_eq!(op.param(ParamId("exposure")), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rust_node_is_refused_rather_than_silently_dropped() {
|
||||
let err = DeclaredOp::from_yaml(
|
||||
include_str!("../../ops/tone_curve.yaml"),
|
||||
"ops/tone_curve.yaml",
|
||||
builtin_helpers(),
|
||||
)
|
||||
.expect_err("a `rust:` node is not a declaration");
|
||||
assert!(err.contains("hand-written type"), "{err}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user