Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions ir/ir.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,23 @@ type Stmt struct {
CF StmtCF
}

// StmtCF is the control-flow payload of an ir.Stmt.
type StmtCF interface{ isStmtCF() }
// StmtCF is the control-flow payload of an ir.Stmt. Every implementation must
// also provide exprs and nestedStmts (see ir/uses.go), which every "does this
// stage use X?" walker in this package dispatches through instead of a type
// switch. A new control-flow variant that omits these methods does not
// satisfy StmtCF, so any composite literal assigning it to a Stmt.CF field
// fails to compile — the walker cannot silently forget a variant the way
// ir/uses.go's stmtMatches once forgot ReturnCF (see
// spore.2026-07-27.selena-ir-cf-walker-convention).
type StmtCF interface {
isStmtCF()
// exprs returns the expressions this control-flow node evaluates directly
// (not including expressions nested inside child statement blocks).
exprs() []Expr
// nestedStmts returns the child statement blocks this control-flow node
// contains (e.g. an if's Then/Else, a for's Body). Leaf nodes return nil.
nestedStmts() [][]Stmt
}

// AssignCF reassigns an existing mutable variable: Target = Value.
type AssignCF struct {
Expand Down Expand Up @@ -211,8 +226,17 @@ func (ReturnCF) isStmtCF() {}
func (Index) isExpr() {}

// Expr is the typed expression graph. It is total by construction (no loops,
// no recursion) so every backend can emit it deterministically.
type Expr interface{ isExpr() }
// no recursion) so every backend can emit it deterministically. Every
// implementation must also provide children (see ir/uses.go): the direct
// child expressions a usage walker must recurse into. A new Expr variant that
// omits children does not satisfy Expr, so any composite literal assigning it
// to an Expr-typed field fails to compile instead of silently defeating a
// usage walker that recurses over children by hand.
type Expr interface {
isExpr()
// children returns e's direct child expressions (nil for leaves).
children() []Expr
}

// Ref references a uniform, attribute, varying, or stage-local by name.
type Ref struct{ Name string }
Expand Down
168 changes: 121 additions & 47 deletions ir/uses.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ package ir
// own partial walker, which is how a fragment stage could call fwidth and still
// emit no GL_OES_standard_derivatives directive on some surface kinds. One
// walker in the IR keeps every emitter and the binding descriptor in agreement.
//
// stmtMatches and exprMatches dispatch through the StmtCF.exprs/nestedStmts and
// Expr.children methods (implemented per variant below) instead of a type
// switch. A type switch silently returns false for a case it forgot — the
// defect that shipped ir.ReturnCF with no case here and made an early-return
// derivative invisible to UsesDerivatives (see
// spore.2026-07-27.selena-ir-cf-walker-convention). Dispatching through an
// interface method makes that class of omission a compile error instead: a new
// StmtCF or Expr implementation that does not provide these methods cannot be
// assigned to a Stmt.CF or Expr field at all.

// DerivativeBuiltins is the set of screen-space derivative builtins. GLSL ES
// 1.00 (WebGL 1) provides them only through GL_OES_standard_derivatives; GLSL
Expand Down Expand Up @@ -54,6 +64,28 @@ func UsesSceneSize(m Module) bool { return StageUsesSceneSize(m.Fragment) }
// at an explicit LOD.
func UsesSceneSampleLevel(m Module) bool { return StageUsesSceneSampleLevel(m.Fragment) }

// StageUsesVertexIndexBuiltin reports whether stage's body or output
// expression references the vertexIndex builtin, including a reference nested
// inside an if/for/assign/return — anywhere stmtMatches recurses.
//
// This replaces a hand-rolled walker that used to live in
// lower/lower_vertex.go (irStageUsesVertexIndex / irExprUsesVertexIndex). That
// walker only scanned CF-less statements' Value field, so a vertexIndex read
// inside an authored vertex() reassignment, if, or for body was invisible to
// it: UsesVertexIndex came back false, the backend omitted the
// @builtin(vertex_index)/[[vertex_id]]/gl_VertexID wiring the reference
// needed, and the emitted shader referenced vertexIndex as an undeclared
// identifier — naga rejects it outright. Every conformance material happens to
// read vertexIndex from a top-level `let`, so the corpus never exercised the
// gap. Routing through the shared, exhaustive stmtMatches/exprMatches walker
// closes it the same way UsesDerivatives already does for dpdx/dpdy/fwidth.
func StageUsesVertexIndexBuiltin(stage Stage) bool {
return stageMatches(stage, func(e Expr) bool {
r, ok := e.(Ref)
return ok && r.Name == "vertexIndex"
})
}

func stmtCalls(s Stmt, names map[string]bool) bool {
return stmtMatches(s, func(e Expr) bool {
c, ok := e.(Call)
Expand All @@ -77,27 +109,24 @@ func stageMatches(stage Stage, pred func(Expr) bool) bool {
return exprMatches(stage.Output, pred)
}

// stmtMatches reports whether s (or, for a control-flow statement, any
// expression it evaluates directly or in a nested statement block) matches
// pred. It dispatches through StmtCF.exprs/nestedStmts (see ir.go) rather than
// a type switch, so a new StmtCF variant is exhaustively handled by
// construction — see the file doc comment.
func stmtMatches(s Stmt, pred func(Expr) bool) bool {
if s.CF == nil {
return exprMatches(s.Value, pred)
}
switch cf := s.CF.(type) {
case AssignCF:
return exprMatches(cf.Value, pred)
case IndexAssignCF:
return exprMatches(cf.Index, pred) || exprMatches(cf.Value, pred)
case ReturnCF:
return exprMatches(cf.Value, pred)
case IfCF:
if exprMatches(cf.Cond, pred) {
for _, e := range s.CF.exprs() {
if exprMatches(e, pred) {
return true
}
return stmtsMatch(cf.Then, pred) || stmtsMatch(cf.Else, pred)
case ForCF:
if exprMatches(cf.Cond, pred) || exprMatches(cf.InitValue, pred) || exprMatches(cf.PostValue, pred) {
}
for _, block := range s.CF.nestedStmts() {
if stmtsMatch(block, pred) {
return true
}
return stmtsMatch(cf.Body, pred)
}
return false
}
Expand All @@ -111,49 +140,94 @@ func stmtsMatch(stmts []Stmt, pred func(Expr) bool) bool {
return false
}

// exprMatches reports whether e or any expression reachable from e (via
// Expr.children, see ir.go) matches pred. It dispatches through children
// rather than a type switch so a new Expr variant is exhaustively handled by
// construction — see the file doc comment.
func exprMatches(e Expr, pred func(Expr) bool) bool {
if e == nil {
return false
}
if pred(e) {
return true
}
switch x := e.(type) {
case Call:
return anyMatch(x.Args, pred)
case Construct:
return anyMatch(x.Args, pred)
case Binary:
return exprMatches(x.L, pred) || exprMatches(x.R, pred)
case Unary:
return exprMatches(x.E, pred)
case Swizzle:
return exprMatches(x.E, pred)
case Sample:
return exprMatches(x.UV, pred)
case SampleLevel:
return exprMatches(x.UV, pred) || exprMatches(x.LOD, pred)
case SampleCube:
return exprMatches(x.Dir, pred)
case SceneSample:
return exprMatches(x.UV, pred)
case SceneSampleLevel:
return exprMatches(x.UV, pred) || exprMatches(x.LOD, pred)
case StateSampleUV:
return exprMatches(x.UV, pred)
case Conditional:
return exprMatches(x.Cond, pred) || exprMatches(x.Then, pred) || exprMatches(x.Alt, pred)
case Index:
return exprMatches(x.Arr, pred) || exprMatches(x.Idx, pred)
}
return false
}

func anyMatch(args []Expr, pred func(Expr) bool) bool {
for _, a := range args {
if exprMatches(a, pred) {
for _, c := range e.children() {
if exprMatches(c, pred) {
return true
}
}
return false
}

// --- StmtCF.exprs / StmtCF.nestedStmts -------------------------------------
//
// Every StmtCF implementation in ir.go must provide both methods (the StmtCF
// interface requires them), even when the answer is "none": that requirement
// is what makes a missing case a compile error instead of a silently-false
// walker result.

func (cf AssignCF) exprs() []Expr { return []Expr{cf.Value} }
func (cf AssignCF) nestedStmts() [][]Stmt { return nil }

func (cf IndexAssignCF) exprs() []Expr { return []Expr{cf.Index, cf.Value} }
func (cf IndexAssignCF) nestedStmts() [][]Stmt { return nil }

func (cf ReturnCF) exprs() []Expr { return []Expr{cf.Value} }
func (cf ReturnCF) nestedStmts() [][]Stmt { return nil }

func (cf IfCF) exprs() []Expr { return []Expr{cf.Cond} }
func (cf IfCF) nestedStmts() [][]Stmt {
return [][]Stmt{cf.Then, cf.Else}
}

func (cf ForCF) exprs() []Expr {
return []Expr{cf.InitValue, cf.Cond, cf.PostValue}
}
func (cf ForCF) nestedStmts() [][]Stmt { return [][]Stmt{cf.Body} }

// VarArrayCF declares a local array with no initializer — ElemType and Size
// carry no expression, and it introduces no nested statement block.
func (cf VarArrayCF) exprs() []Expr { return nil }
func (cf VarArrayCF) nestedStmts() [][]Stmt { return nil }

// DiscardCF carries no payload.
func (cf DiscardCF) exprs() []Expr { return nil }
func (cf DiscardCF) nestedStmts() [][]Stmt { return nil }

// BreakCF carries no payload.
func (cf BreakCF) exprs() []Expr { return nil }
func (cf BreakCF) nestedStmts() [][]Stmt { return nil }

// --- Expr.children -----------------------------------------------------
//
// Every Expr implementation in ir.go must provide children (the Expr
// interface requires it), even when the answer is nil (a leaf expression):
// see the note on StmtCF above.

func (Ref) children() []Expr { return nil }
func (Lit) children() []Expr { return nil }
func (IntLit) children() []Expr { return nil }
func (UintLit) children() []Expr { return nil }

func (x Construct) children() []Expr { return x.Args }
func (x Call) children() []Expr { return x.Args }

func (x Binary) children() []Expr { return []Expr{x.L, x.R} }
func (x Unary) children() []Expr { return []Expr{x.E} }
func (x Swizzle) children() []Expr { return []Expr{x.E} }

func (x Sample) children() []Expr { return []Expr{x.UV} }
func (x SampleLevel) children() []Expr { return []Expr{x.UV, x.LOD} }
func (x SampleCube) children() []Expr { return []Expr{x.Dir} }

func (x SceneSample) children() []Expr { return []Expr{x.UV} }
func (x SceneSampleLevel) children() []Expr { return []Expr{x.UV, x.LOD} }
func (SceneSize) children() []Expr { return nil }

func (x Conditional) children() []Expr { return []Expr{x.Cond, x.Then, x.Alt} }

func (StateSample) children() []Expr { return nil }
func (x StateSampleUV) children() []Expr { return []Expr{x.UV} }
func (CellUV) children() []Expr { return nil }

func (x Index) children() []Expr { return []Expr{x.Arr, x.Idx} }
117 changes: 117 additions & 0 deletions ir/uses_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package ir

import "testing"

// TestUsesDerivativesFindsCallInsideReturnCF is the regression test for the
// bug documented in spore.2026-07-27.selena-ir-cf-walker-convention:
// stmtMatches predated ir.ReturnCF and had no case for it, so a derivative
// call packed into an early-return value was invisible to UsesDerivatives.
// stmtMatches now dispatches through StmtCF.exprs/nestedStmts (implemented
// for every variant in this package), so this can no longer regress silently:
// a variant missing those methods fails to compile wherever it is used as a
// StmtCF, rather than falling through this walker unnoticed.
func TestUsesDerivativesFindsCallInsideReturnCF(t *testing.T) {
m := Module{
Fragment: Stage{
Body: []Stmt{
{CF: IfCF{
Cond: Ref{Name: "cond"},
Then: []Stmt{
{CF: ReturnCF{Value: Call{Func: "fwidth", Args: []Expr{Ref{Name: "uv"}}}}},
},
}},
},
Output: Ref{Name: "fallback"},
},
}
if !UsesDerivatives(m) {
t.Fatal("UsesDerivatives = false, want true (fwidth is inside an if's ReturnCF)")
}
}

// TestUsesDerivativesFindsCallInsideAssignCF exercises the AssignCF case of
// the same walker with a derivative nested one level deeper: inside a for
// loop's body.
func TestUsesDerivativesFindsCallInsideAssignCF(t *testing.T) {
m := Module{
Fragment: Stage{
Body: []Stmt{
{Target: "acc", Type: Float, Value: Lit{Value: 0}, Mutable: true},
{CF: ForCF{
InitTarget: "i", InitType: Int, InitValue: IntLit{Value: 0},
Cond: Binary{Op: "<", L: Ref{Name: "i"}, R: IntLit{Value: 4}},
PostTarget: "i", PostValue: Binary{Op: "+", L: Ref{Name: "i"}, R: IntLit{Value: 1}},
Body: []Stmt{
{CF: AssignCF{Target: "acc", Value: Call{Func: "dpdx", Args: []Expr{Ref{Name: "acc"}}}}},
},
}},
},
Output: Ref{Name: "acc"},
},
}
if !UsesDerivatives(m) {
t.Fatal("UsesDerivatives = false, want true (dpdx is inside a for body's AssignCF)")
}
}

// TestUsesDerivativesFalseWithoutDerivative is the negative control for the
// two tests above: a module with no derivative call anywhere must not report
// one.
func TestUsesDerivativesFalseWithoutDerivative(t *testing.T) {
m := Module{
Fragment: Stage{
Body: []Stmt{
{CF: IfCF{
Cond: Ref{Name: "cond"},
Then: []Stmt{
{CF: ReturnCF{Value: Call{Func: "normalize", Args: []Expr{Ref{Name: "uv"}}}}},
},
}},
},
Output: Ref{Name: "fallback"},
},
}
if UsesDerivatives(m) {
t.Fatal("UsesDerivatives = true, want false (no derivative call anywhere)")
}
}

// TestStageUsesVertexIndexBuiltinFindsRefInsideIf is the direct unit test for
// the walker lower/lower_vertex.go now uses in place of its former hand-rolled
// (and control-flow-blind) irStageUsesVertexIndex/irExprUsesVertexIndex. See
// lower/lower_test.go's TestLowerMeshAuthoredVertexUsesVertexIndexInsideControlFlow
// for the end-to-end regression test through the compiler, and
// validate/validate_test.go's
// TestPreFixVertexIndexInControlFlowWouldHaveFailedNagaValidation for the
// empirical proof of what the old behaviour emitted.
func TestStageUsesVertexIndexBuiltinFindsRefInsideIf(t *testing.T) {
stage := Stage{
Body: []Stmt{
{Target: "fi", Type: Float, Value: Lit{Value: 0}, Mutable: true},
{CF: IfCF{
Cond: Ref{Name: "gridSize"},
Then: []Stmt{
{CF: AssignCF{Target: "fi", Value: Call{Func: "float", Args: []Expr{Ref{Name: "vertexIndex"}}}}},
},
}},
},
Output: Ref{Name: "fi"},
}
if !StageUsesVertexIndexBuiltin(stage) {
t.Fatal("StageUsesVertexIndexBuiltin = false, want true (vertexIndex is read inside an if's AssignCF)")
}
}

// TestStageUsesVertexIndexBuiltinFalseWithoutReference is the negative
// control: a stage that never reads vertexIndex must not report one.
func TestStageUsesVertexIndexBuiltinFalseWithoutReference(t *testing.T) {
stage := Stage{
Body: []Stmt{
{Target: "fi", Type: Float, Value: Lit{Value: 1}},
},
Output: Ref{Name: "fi"},
}
if StageUsesVertexIndexBuiltin(stage) {
t.Fatal("StageUsesVertexIndexBuiltin = true, want false (vertexIndex is never referenced)")
}
}
Loading
Loading