From 1e8fddb4c06c00ba35a186cb039a12643b5db047 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Tue, 27 Oct 2026 05:53:15 +0100 Subject: [PATCH 01/18] Specialize stable Metal branches with native Shader ownership --- LICENSE-ROXAS2712.md | 88 ++++++ docs/metal-shader-branches.md | 97 +++++++ main.lua | 1 + src/core/Performance.lua | 2 + src/render/ShaderBranches.lua | 128 +++++++++ src/render/ShaderSamplerGuard.lua | 74 +++++ src/render/ShaderSpecialization.lua | 266 ++++++++++++++++++ tests/engine/shader_branches.lua | 61 ++++ tests/engine/shader_specialization_policy.lua | 33 +++ tests/native_shader/conf.lua | 7 + tests/native_shader/main.lua | 231 +++++++++++++++ 11 files changed, 988 insertions(+) create mode 100644 LICENSE-ROXAS2712.md create mode 100644 docs/metal-shader-branches.md create mode 100644 src/render/ShaderBranches.lua create mode 100644 src/render/ShaderSamplerGuard.lua create mode 100644 src/render/ShaderSpecialization.lua create mode 100644 tests/engine/shader_branches.lua create mode 100644 tests/engine/shader_specialization_policy.lua create mode 100644 tests/native_shader/conf.lua create mode 100644 tests/native_shader/main.lua diff --git a/LICENSE-ROXAS2712.md b/LICENSE-ROXAS2712.md new file mode 100644 index 000000000..fbcffd47c --- /dev/null +++ b/LICENSE-ROXAS2712.md @@ -0,0 +1,88 @@ +# Roxas2712 — Fork Contributions + +Version 1.0 · Effective 25 October 2026 + +Copyright © 2026 Roxas2712, to the extent of rights held. +All rights reserved except as expressly granted below or already granted. + +## 1. Scope and ownership + +This notice applies only to original contributions made by Roxas2712 +(GitHub: Roxas2712) to `Roxas2712/Gen2Recomped`, including original additions +within modified files, insofar as he owns the relevant rights. Git history, +pull-request diffs and source provenance identify contributions; a commit +author or a whole-file change alone does not establish ownership of every +part of that file. + +It does not claim ownership of Cedric's work, Gen1Recomp / BOIS CLUB GAMES +work, other contributors' work, third-party dependencies, or ROM-derived +content. Their applicable licences and notices remain in force. It does not +grant permission to distribute the complete combined fork where another +rights holder's permission is required. + +## 2. General permission and reserved rights + +For contributions covered by this notice and not already licensed more +broadly, you may read and study the published source and make private, +non-commercial modifications and builds for your own use. This grants no +general permission to redistribute, sublicense, sell, or offer those +contributions as a hosted service. Other uses require Roxas2712's +written permission, except for the specific permission below and rights +available independently under applicable law. + +Keep the relevant copyright, licence and attribution notices. Do not +represent Roxas2712's contributions as exclusively authored by someone +else. Public source availability alone does not put them in the public +domain or grant an open-source licence. + +## 3. Specific permission for Cedric / UNDERdecoded + +Roxas2712 expressly grants Cedric (Ced; GitHub: UNDERdecoded / +UNDERdecodedHD) a non-exclusive, worldwide, perpetual, royalty-free +permission to use, reproduce, integrate, modify and distribute the covered +contributions that Roxas2712 submits or expressly offers through pull +requests to `UNDERdecoded/Gen2Recomped`, for Cedric's Gen2Recomped / +RetroRecompiled project. This includes its renamed continuation maintained +by Cedric, its official builds, and its commercial operation. + +Cedric may license the incorporated contributions to recipients as part of +that project, subject to preserving this permission's applicable credit and +notice requirements. This permission is not a copyright assignment, is not +exclusive, and does not cover unpublished work, separate repositories, +ModCompass service operation, credentials, or content not offered in those +pull requests. Roxas2712 retains his own rights to his contributions. + +For uses relying on this permission: + +- retain the relevant source copyright and licence notices; +- give visible credit in the project's README or equivalent public project + page, and in the application's existing About / Credits view, if present; +- use at least the following credit, or a substantially equivalent one: + + > Contributions by Roxas2712 — + > https://github.com/Roxas2712 + +A maintained consolidated contributor credit is sufficient; a separate +credit on every screen or for every change is not required. Adapted +contributions must not be attributed exclusively to another author. Other +contributors' credits must also remain intact. + +## 4. Earlier licences and permissions remain effective + +This notice does not revoke, narrow, or retrospectively add conditions to +any permission already validly granted, including MIT / GPL grants, +separate agreements, or permissions arising from previously submitted +pull requests. Where an earlier grant permits a use independently of this +notice, its existing terms govern that use. For such earlier uses, the +visible credit above is requested to the extent it is not already required. + +For subsequent contributions, this is the default statement of Roxas2712's own reserved rights and the specific PR permission, subject to any +applicable existing licence obligations or separately agreed terms. It +does not change another rights holder's licence or guarantee compatibility +of a particular combination of code. + +## 5. No warranty + +To the extent permitted by applicable law, these contributions are provided +as is, without warranty. No support obligation, endorsement, or rights to +third-party code, game assets, names, or trademarks are granted. diff --git a/docs/metal-shader-branches.md b/docs/metal-shader-branches.md new file mode 100644 index 000000000..81780f640 --- /dev/null +++ b/docs/metal-shader-branches.md @@ -0,0 +1,97 @@ +# Bounded Metal shader branch specialization + +Large world programs can keep expensive lighting/material branches dynamic even +when their scalar controls stay at zero or one. On Apple-silicon macOS with +LÖVE 12/Metal, the native compiler adapter specializes eligible `if` reads of +positive float zero/one and booleans. It preserves declarations, arithmetic, +source artwork, render resolution and post-processing. It uses no mod names or +private shader snippets. Other backends, smaller programs and unsupported +source/value shapes keep their original programs. + +The caller retains the real original Shader userdata and interface. Every +successful `send`/`sendColor` updates that C shader first; original errors, +return arity and nil values remain intact. Bounded immutable snapshots preserve +partial uniform-array tails and per-send matrix layout. Eager variant writes +preserve batch ordering and release replaced C texture references. Missing or +released replay resources, mutable Data, buffers and unknown values fall back +to the original. A later foreign Shader-method owner also disables optimization. + +An unnamed Channel holds the original C shader without keeping its Lua userdata +alive. Retirement queries the real native graphics-stack depth, including raw C +push/pop calls. Disabled/orphan records retire only at an empty-stack boundary; +hidden native stack frames keep their mappings and C leases. Public `getShader` +returns the original live interface, including native reconstruction after +explicit release. No public reference-count API is assumed. + +Sampler declarations remain visible to the Metal stage linker behind a private +zero-valued guard. Vertex-only declarations stay in their stage; unsupported +syntax or compilation failure retains the original program. The optional canvas +compatibility adapter provides its preceding constructor as `compileNative`: +derived programs must not have their already admitted clip convention analyzed +again after their forwarding entry is modified. An older installed adapter +without this seam leaves specialization disabled. No producer macro is injected. + +Limits: 64–256 KiB per eligible source, 16 live sources, 2 MiB combined source, +16 variants per source, 64 live variants, 256 observed uniforms and 32,768 +snapshot nodes per source. Source shadowing/macros and unsupported shapes stay +conservative. `POKEPORT_SHADER_BRANCHES=0` restores the original compiler path. + +## Native qualification + +Frozen native source `6f82c5cb00`, fork main `f8a05682` plus the compatibility +composition, unchanged private Origami 1.0.9 `ae59c77c`: baseline `a2aac5b097` +versus native adapter `55614c8cc0`. Both use actual Metal on M3/16 GB, High, +1024×768 scene/window, active FXAA, render scale one, supersampling off and +native VSync off. No QA Shader prototype is installed. + +Each row measures 120 ordinary native walking frames, after settling and a +separate collection/recovery test. Native frame serial increments once per +sample. The shared hardware lease covers source setup through final cleanup. +This initial deterministic driver calls the Game update body directly. A +follow-up through the complete native `PlatformHooks.update` frame owner is +required before treating these figures as full mod-update measurements. + +| View | Original mean ms | Adapter mean ms | Adapter p95 ms | Adapter max ms | +| --- | ---: | ---: | ---: | ---: | +| open | 5.69 | 5.32 | 6.87 | 9.39 | +| third | 18.98 | 11.43 | 12.87 | 15.10 | +| first | 20.02 | 11.86 | 12.83 | 14.12 | + +All three complete scene captures are pixel-exact, excluding only the changing +FPS HUD (`x >= 890 && y < 56`). Twelve actual variants are used, with no +compiler/replay failures, unsupported values or fallback draws. The original +failed native integration (`a67197449f`) double-adapted clip space; its failure +is preserved and corrected through the constructor seam, not an image flip. + +Actual GPU fixture `5155a3c3f4` passes 72 checks, including spatial projection, +real Shader identity, multiple/nil returns from a prior owner, partial arrays, +matrix layout, mutable color inputs, sampler release, raw native stack frames, +Shader release/GC, Data fallback, source caps and later foreign method ownership. +The deliberate wrong-branch pixel control is detected. Focused native engine, +orientation, JIT, map, follower, options, pacing and Crystal VRAM checks pass in +the same frozen composition. + +These are internal rendering observations while the console is locked. They +are not OS-window, interactive 60-FPS, outdoor, full-game or all-platform +acceptance. Cold compilation is synchronous: 7.21 seconds accumulated across +the twelve first-used variants in this run, outside the steady walking samples. +First-use hitches remain a limitation; there is no asynchronous-compilation +claim. Player profiles and saves are not used. QA enforces 20 GiB free, above +the requested 8 GiB minimum. + +## Repeating the API fixture + +`tests/native_shader` is a ROM-free LÖVE fixture. Its engine root comes from +`SHADER_TEST_ENGINE` (default: the repository containing the fixture); a frozen +runner must set it to the exact copied source. Use a disposable QA identity, +the project's managed storage/hardware runner and Metal. Set +`QA_NATIVE_SHADER_ADAPTER=1`; `QA_SHADER_MULTIPLE_RETURNS=1` additionally exercises +an existing method owner. `QA_SHADER_WRONG_COMPILER=1` deliberately re-enters the +actual canvas adapter and must fail the spatial pixel comparison. That negative +requires the canvas adapter; the standalone compiler fixture also runs without +it. Never use a player identity for this fixture. + +Contributions by Roxas2712 — https://github.com/Roxas2712. +See [the contribution terms](../LICENSE-ROXAS2712.md) for Cedric / UNDERdecoded's +specific incorporation permission and applicable credit requirements. Existing +notices remain intact. diff --git a/main.lua b/main.lua index cf453d89d..418b147a2 100644 --- a/main.lua +++ b/main.lua @@ -499,6 +499,7 @@ end function love.load(args) BootTrace.mark("love.load enter") GraphicsStack.install() + require("src.render.ShaderSpecialization").install() pcall(function() require('src.core.SwitchAudioCheck').run() end) -- Teach SDL about the pads its built-in database misses (Switch Pro over diff --git a/src/core/Performance.lua b/src/core/Performance.lua index 861121ddd..88fe42d22 100644 --- a/src/core/Performance.lua +++ b/src/core/Performance.lua @@ -41,6 +41,8 @@ -- tests, the same way src/core/GameVersion.lua does. local Performance = {} +-- Bounded Metal branch specialization; original programs own every fallback. +Performance.shaderBranchSpecialization = true -- Option-row order (auto first, then most to least capable). Performance.TIERS = { "auto", "high", "balanced", "low" } diff --git a/src/render/ShaderBranches.lua b/src/render/ShaderBranches.lua new file mode 100644 index 000000000..25667f479 --- /dev/null +++ b/src/render/ShaderBranches.lua @@ -0,0 +1,128 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Pure source planning for an optional compiler optimization. This module does +-- not change graphics state. Unknown syntax keeps its original runtime branch. +local B={} +local scalar={float=true,bool=true} +local qualifier={highp=true,mediump=true,lowp=true,LOVE_HIGHP_OR_MEDIUMP=true} +local function blank(text)return(text:gsub('[^\n]',' '))end +local function uncomment(source) + local pieces,at={},1 + while at<=#source do + local first=source:find('/',at,true) + if not first then pieces[#pieces+1]=source:sub(at);break end + pieces[#pieces+1]=source:sub(at,first-1) + local pair=source:sub(first,first+1);local finish + if pair=='//'then finish=source:find('\n',first+2,true)or(#source+1) + elseif pair=='/*'then local last=source:find('*/',first+2,true);if not last then return end;finish=last+2 end + if finish then pieces[#pieces+1]=blank(source:sub(first,finish-1));at=finish + else pieces[#pieces+1]='/';at=first+1 end + end + return table.concat(pieces) +end +local function pattern(name)return'%f[%w_]'..name..'%f[^%w_]'end +local function withoutDirectives(clean) + local continued=false;local out={} + for line in(clean..'\n'):gmatch('(.-)\n')do + local directive=continued or line:match('^%s*#')~=nil + continued=directive and line:match('\\%s*$')~=nil + out[#out+1]=directive and blank(line)or line + end + return table.concat(out,'\n') +end +function B.plan(source) + if type(source)~='string'then return nil,'source is not text'end + local clean=uncomment(source);if not clean then return nil,'unfinished comment'end + local code=withoutDirectives(clean) + local declarations,names={},{} + local at=1 + while true do + local first,last,body=code:find('%f[%w_]uniform%s+([^;{}]+);',at) + if not first then break end + local q,kind,name=body:match('^([%w_]+)%s+([%w_]+)%s+([%a_][%w_]*)%s*$') + if not(q and qualifier[q])then kind,name=body:match('^([%w_]+)%s+([%a_][%w_]*)%s*$')end + if scalar[kind]and name and not name:match('^love_')and not name:match('^pokeport')then + if names[name]and names[name]~=kind then return nil,'conflicting uniform kinds'end + names[name]=kind;declarations[#declarations+1]={first,last} + end + at=last+1 + end + local conditions={};at=1 + while true do + local first,last=code:find('%f[%w_]if%f[^%w_]%s*%(',at) + if not first then break end + local depth,finish=1,last+1 + while finish<=#clean and depth>0 do + local c=clean:sub(finish,finish) + if c=='('then depth=depth+1 elseif c==')'then depth=depth-1 end + finish=finish+1 + end + if depth~=0 then return nil,'unfinished branch condition'end + conditions[#conditions+1]={last,finish-1};at=finish + end + -- Uniform arithmetic remains dynamic. Reject possible shadow declarations, + -- member names and macros before substituting any read in an if condition. + -- A comma may introduce another local declaration; conservatively reject + -- that spelling even when it is merely a function argument. + local outside=clean + local ranges={};for _,r in ipairs(declarations)do ranges[#ranges+1]=r end + table.sort(ranges,function(a,b)return a[1]>b[1]end) + for _,r in ipairs(ranges)do outside=outside:sub(1,r[1]-1)..blank(outside:sub(r[1],r[2]))..outside:sub(r[2]+1)end + local order={} + for name in pairs(names)do + local use=pattern(name) + local shadow=outside:find('[%w_]+%s+'..name..'%f[^%w_]') + or outside:find('[,%.]%s*'..name..'%f[^%w_]') + local macro=false;local pos=1 + while true do + local a,b=outside:find(use,pos);if not a then break end + if code:sub(a,b)~=clean:sub(a,b)then macro=true;break end + pos=b+1 + end + if shadow or macro then names[name]=nil + else + local used=false;for _,r in ipairs(conditions)do if clean:sub(r[1],r[2]):find(use)then used=true;break end end + if used then order[#order+1]=name else names[name]=nil end + end + end + table.sort(order) + return{source=source,clean=clean,kinds=names,order=order,conditions=conditions} +end +function B.literal(kind,value) + if kind=='bool'and type(value)=='boolean'then return tostring(value)end + if kind=='float'and type(value)=='number'then + if value==1 then return'1.0'end + if value==0 and 1/value==math.huge then return'0.0'end + end +end +function B.key(plan,values) + local constants,key={},{} + for _,name in ipairs(plan.order)do + local literal=B.literal(plan.kinds[name],values[name]) + if literal then constants[name]=literal;key[#key+1]=name..'='..literal end + end + return constants,table.concat(key,';'),#key +end +function B.rewrite(plan,constants) + local source=plan.source + for i=#plan.conditions,1,-1 do + local range=plan.conditions[i];local first,last=range[1],range[2] + local lexical=plan.clean:sub(first,last);local original=source:sub(first,last) + local changes={} + for _,name in ipairs(plan.order)do + local value=constants[name] + if value then + assert(value=='0.0'or value=='1.0'or value=='true'or value=='false','unqualified literal') + local at=1 + while true do + local a,b=lexical:find(pattern(name),at);if not a then break end + changes[#changes+1]={a,b,value};at=b+1 + end + end + end + table.sort(changes,function(a,b)return a[1]>b[1]end) + for _,c in ipairs(changes)do original=original:sub(1,c[1]-1)..c[3]..original:sub(c[2]+1)end + source=source:sub(1,first-1)..original..source:sub(last+1) + end + return source +end +return B diff --git a/src/render/ShaderSamplerGuard.lua b/src/render/ShaderSamplerGuard.lua new file mode 100644 index 000000000..7cd6e34e7 --- /dev/null +++ b/src/render/ShaderSamplerGuard.lua @@ -0,0 +1,74 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Keep original live 2D samplers visible to the Metal stage linker when a +-- branch is folded. The private control uniform is always zero. Unsupported +-- stage/resource syntax is allowed to fail compilation and use the original. +local G={uniform='pokeportBranchSamplerGuard'} +function G.rewrite(source,clean,hasUniform) + assert(not clean:find(G.uniform,1,true),'sampler guard name already present') + assert(not clean:find('%f[%w_]buffer%f[^%w_]'),'storage buffers stay native') + assert(not clean:find('uniform%s+Image%s+[%w_]+%s*%['),'sampler arrays stay native') + for kind in clean:gmatch('uniform%s+([%w_]+)%s+')do + assert(not kind:find('Image')or kind=='Image','other sampler types stay native') + end + local names={vertex={},pixel={}};local seen={vertex={},pixel={}} + local function both(a,b)if a==false or b==false then return false end;if a and b then return true end end + local function either(a,b)if a or b then return true end;if a==false and b==false then return false end end + local function invert(a)if a~=nil then return not a end end + local function symbol(name,stage) + if name=='VERTEX'then return stage=='vertex'end + if name=='PIXEL'then return stage=='pixel'end + end + for _,stage in ipairs{'vertex','pixel'}do + local allowed=true;local stack={} + for line in(clean..'\n'):gmatch('(.-)\n')do + local directive,arg=line:match('^%s*#%s*(%w+)%s*(.-)%s*$') + if directive=='ifdef'or directive=='ifndef'or directive=='if'then + local condition + if directive=='if'then + local text=arg:gsub('%s+','') + local name=text:match('^defined%(([%w_]+)%)$')or text:match('^defined([%w_]+)$') + if name then condition=symbol(name,stage) + elseif text=='0'then condition=false elseif text=='1'then condition=true end + else condition=symbol(arg:match('^(%w+)'),stage);if directive=='ifndef'then condition=invert(condition)end end + stack[#stack+1]={parent=allowed,taken=condition};allowed=both(allowed,condition) + elseif directive=='else'then + local top=assert(stack[#stack]);allowed=both(top.parent,invert(top.taken));top.taken=true + elseif directive=='elif'then + local top=assert(stack[#stack]);allowed=both(top.parent,invert(top.taken));top.taken=either(top.taken,nil) + elseif directive=='endif'then + local top=assert(stack[#stack]);stack[#stack]=nil;allowed=top.parent + elseif allowed~=false then + for name in line:gmatch('uniform%s+Image%s+([%w_]+)%s*;')do + if not seen[stage][name]and hasUniform(name)then seen[stage][name]=true;names[stage][#names[stage]+1]=name end + end + end + end + assert(#stack==0,'unbalanced shader stage directives') + assert(#names[stage]<=16,'sampler guard stage cap') + end + local function sum(stage) + local samples={};for _,name in ipairs(names[stage])do samples[#samples+1]='Texel('..name..',vec2(0.0))'end + return #samples>0 and table.concat(samples,'+')or'vec4(0.0)' + end + local prefix='uniform float '..G.uniform..';\n' + local pragma,rest=source:match('^(#pragma[^\n]*\n)(.*)$') + source=pragma and(pragma..prefix..rest)or(prefix..source) + local outputs,maxOutput={},0 + for slot in clean:gmatch('love_Canvases%[(%d+)%]')do maxOutput=math.max(maxOutput,tonumber(slot))end + assert(maxOutput<8,'sampler guard output cap') + for slot=0,maxOutput do outputs[#outputs+1]='love_Canvases['..slot..']=pokeportBranchSample;'end + local n + source,n=source:gsub('(void%s+effect%s*%(%s*%)%s*{)',function(entry) + return entry..'\n#ifdef PIXEL\nif('..G.uniform..'>.5){vec4 pokeportBranchSample='..sum('pixel')..';'..table.concat(outputs)..'return;}\n#endif\n' + end,1) + if n==0 then + source=source:gsub('(vec4%s+effect%s*%([^)]*%)%s*{)',function(entry) + return entry..'\n#ifdef PIXEL\nif('..G.uniform..'>.5)return '..sum('pixel')..';\n#endif\n' + end,1) + end + source=source:gsub('(vec4%s+position%s*%([^)]*%)%s*{)',function(entry) + return entry..'\n#ifdef VERTEX\nif('..G.uniform..'>.5)return '..sum('vertex')..';\n#endif\n' + end,1) + return source +end +return G diff --git a/src/render/ShaderSpecialization.lua b/src/render/ShaderSpecialization.lua new file mode 100644 index 000000000..3490eb9b0 --- /dev/null +++ b/src/render/ShaderSpecialization.lua @@ -0,0 +1,266 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Optional, bounded Metal compiler adapter. The caller keeps the real original +-- Shader interface. Its C program always receives every successful write and +-- remains the fallback for unsupported source, values or resource lifetimes. +local Branches=require('src.render.ShaderBranches') +local Guard=require('src.render.ShaderSamplerGuard') +local S={} +local installed=false +local stats={sources=0,variants=0,compiled=0,uses=0,fallbacks=0,failures=0,unsupported=0,compileSeconds=0} +local limits={sources=16,sourceBytes=2*1024*1024,minBytes=65536,maxBytes=262144,variants=64,perSource=16,uniforms=256,arguments=256,leaves=32768} +local originals=setmetatable({},{__mode='k'}) +local variants={} +local records={} +local hooks={} +local sourceBytes=0 +local raw,compiling +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +local function original(record) + local shader=record.lease:peek() + originals[shader]=record;record.aliases[shader]=true + return shader +end +local function disable(record) + record.disabled=true + local current=raw.getShader() + if variants[current]==record then raw.setShader(original(record))end +end +local function liveAlias(record) + for alias in pairs(record.aliases)do + if pcall(record.hook.hasUniform,alias,Guard.uniform)then return true end + end + return false +end +local function retire(record) + if variants[raw.getShader()]==record then raw.setShader(original(record))end + for alias in pairs(record.aliases)do originals[alias]=nil end + for _,variant in pairs(record.variants)do + if variant then variants[variant.shader]=nil;record.hook.release(variant.shader);stats.variants=stats.variants-1 end + end + record.lease:clear();record.lease:release() + record.retired=true;sourceBytes=sourceBytes-#record.plan.source;stats.sources=stats.sources-1 +end +local function sweep() + -- This queries the actual native stack, including raw C push/pop callers. + -- Never retire mappings or C leases while a hidden native frame may own a + -- variant. There is no public Shader reference-count API to rely upon. + if raw.getStackDepth()~=0 then return end + for i=#records,1,-1 do + local record=records[i] + if record.disabled or not liveAlias(record)then retire(record);table.remove(records,i)end + end +end +local function snapshot(value,budget,depth) + budget.n=budget.n+1 + if budget.n>limits.leaves then error('uniform snapshot cap')end + local kind=type(value) + if kind=='number'or kind=='boolean'then return value end + if kind=='string'and(value=='row'or value=='column')then return value end + if kind=='userdata'and value.typeOf and value:typeOf('Texture')then + return{resource=setmetatable({value},{__mode='v'})} + end + if kind~='table'or depth>=4 or getmetatable(value)~=nil then error('unsupported uniform value')end + local out={};local size=0 + for key,item in next,value do + if type(key)~='number'or key<1 or key%1~=0 or key>256 then error('unsupported uniform table')end + size=math.max(size,key);out[key]=snapshot(item,budget,depth+1) + end + for i=1,size do if out[i]==nil then error('sparse uniform table')end end + return out +end +local function restore(value) + if type(value)~='table'then return value end + if value.resource then + local resource=value.resource[1] + if not resource or not pcall(resource.getDimensions,resource)then error('uniform resource no longer replayable')end + return resource + end + local out={};for i=1,#value do out[i]=restore(value[i])end;return out +end +local function remember(record,method,args) + if record.disabled then return end + local name=args[1] + if type(name)~='string'or not name:match('^[%a_][%w_]*$')or args.n<2 or args.n>limits.arguments+2 then disable(record);return end + local budget={n=0};local row={method=method,n=args.n-1} + for i=2,args.n do row[i-1]=snapshot(args[i],budget,0)end + if budget.n>limits.leaves then error('uniform snapshot cap')end + local first=type(args[2])=='string'and 3 or 2 + row.coverage=args.n-first+1;row.leaves=budget.n + local bank=record.bank[name] + if not bank then + if record.uniformCount>=limits.uniforms then error('uniform count cap')end + record.uniformCount=record.uniformCount+1;bank={};record.bank[name]=bank + end + -- A later successful native send overwrites an array prefix. Keep exactly + -- the older longer prefixes needed to reconstruct the untouched tail. This + -- also preserves per-send row/column matrix layout without reinterpretation. + for i=#bank,1,-1 do + if bank[i].coverage<=row.coverage then record.leaves=record.leaves-bank[i].leaves;table.remove(bank,i)end + end + record.leaves=record.leaves+row.leaves + if record.leaves>limits.leaves then error('uniform bank cap')end + bank[#bank+1]=row + if record.plan.kinds[name]then + local literal=row.n==1 and Branches.literal(record.plan.kinds[name],args[2])or nil + if record.scalars[name]~=literal then record.scalars[name]=literal;record.scalarRevision=record.scalarRevision+1 end + end + -- Eager writes keep existing variant textures current, release their old C + -- texture references, and flush pending batches at the native uniform seam. + for _,variant in pairs(record.variants)do + if variant and record.hook.hasUniform(variant.shader,name)then + record.hook[method](variant.shader,name,unpack(args,2,args.n)) + end + end + record.bankRevision=record.bankRevision+1 +end +local function afterSend(record,method,args,...) + if not record.disabled then + local ok,why=pcall(remember,record,method,args) + if not ok then stats.unsupported=stats.unsupported+1;stats.lastFallback=tostring(why):sub(1,160);disable(record)end + end + return ... +end +local function hook(shader) + local methods=debug.getmetatable(shader).__index + local prior=hooks[methods];if prior then return prior end + local h={send=methods.send,sendColor=methods.sendColor,release=methods.release,hasUniform=methods.hasUniform,methods=methods,wrapped={}} + hooks[methods]=h + for _,method in ipairs{'send','sendColor'}do + if h[method]then h.wrapped[method]=function(self,...) + local record=not compiling and originals[self] + if not record or record.disabled then return h[method](self,...)end + return afterSend(record,method,pack(...),h[method](self,...)) + end;methods[method]=h.wrapped[method]end + end + methods.release=function(self,...) + local record=originals[self] + if record then disable(record)end + return h.release(self,...) + end + h.wrapped.release=methods.release + return h +end +local function construct(record,key,constants) + if record.count>=limits.perSource or stats.variants>=limits.variants then return end + -- Reconstruct resources before compiling. A missing/released Lua Texture is + -- still correctly owned by the original C shader, so retain that path. + local replay={} + for name,bank in pairs(record.bank)do + local rows={};replay[name]=rows + for _,row in ipairs(bank)do + local values={n=row.n,method=row.method} + for i=1,row.n do values[i]=restore(row[i])end + rows[#rows+1]=values + end + end + local shader + local started=love.timer.getTime();compiling=true + local ok,why=pcall(function() + local source=Branches.rewrite(record.plan,constants) + source=Guard.rewrite(source,record.plan.clean,function(name)return record.hook.hasUniform(original(record),name)end) + shader=raw.compile(source) + if record.hook.hasUniform(shader,Guard.uniform)then record.hook.send(shader,Guard.uniform,0)end + for name,rows in pairs(replay)do + if record.hook.hasUniform(shader,name)then + for _,row in ipairs(rows)do record.hook[row.method](shader,name,unpack(row,1,row.n))end + end + end + end) + compiling=false;stats.compileSeconds=stats.compileSeconds+love.timer.getTime()-started + record.count=record.count+1 + if not ok then + if shader then record.hook.release(shader)end + record.variants[key]=false;stats.failures=stats.failures+1;stats.lastFailure=tostring(why):sub(1,1000) + return nil,why + end + local variant={shader=shader} + record.variants[key]=variant;variants[shader]=record + stats.variants=stats.variants+1;stats.compiled=stats.compiled+1 + return variant +end +local function choose(record) + if record.disabled then return end + local h=record.hook + if h.methods.send~=h.wrapped.send or h.methods.sendColor~=h.wrapped.sendColor or h.methods.release~=h.wrapped.release or h.methods.hasUniform~=h.hasUniform then + stats.unsupported=stats.unsupported+1;stats.lastFallback='Shader methods changed after admission';disable(record);return + end + if love.graphics.isGammaCorrect and love.graphics.isGammaCorrect()~=record.gamma then disable(record);return end + if record.keyRevision~=record.scalarRevision then + local constants,key={},{} + for _,name in ipairs(record.plan.order)do + local value=record.scalars[name] + if value then constants[name]=value;key[#key+1]=name..'='..value end + end + record.constants,record.key=constants,table.concat(key,';');record.keyRevision=record.scalarRevision + end + if record.key==''then return end + local variant=record.variants[record.key] + if variant==nil then + if record.retryKey==record.key and record.retryRevision==record.bankRevision then return end + local ok,value=pcall(construct,record,record.key,record.constants) + if ok then variant=value else stats.unsupported=stats.unsupported+1;stats.lastFallback=tostring(value):sub(1,160)end + if not variant then record.retryKey,record.retryRevision=record.key,record.bankRevision end + end + return variant +end +local function beforeDraw() + if stats.sources==0 then return end + local actual=raw.getShader();local record=variants[actual]or originals[actual] + if not record then return end + local variant=choose(record) + if variant then raw.setShader(variant.shader);stats.uses=stats.uses+1 + else raw.setShader(original(record));stats.fallbacks=stats.fallbacks+1 end +end +local function admit(shader,source,second,third,n) + if compiling or n>3 or second~=nil or third~=nil or type(source)~='string'or #sourcelimits.maxBytes then return end + sweep() + if stats.sources>=limits.sources or sourceBytes+#source>limits.sourceBytes then return end + -- Legacy canvas-Y programs retain their existing synchronization owner. + if shader:hasUniform('pokeportLegacyCanvasY')then return end + local plan=Branches.plan(source) + if not plan or #plan.order==0 or plan.clean:find('void%s+[vp]ertexmain%s*%(')or plan.clean:find('void%s+pixelmain%s*%(')then return end + local h=hook(shader);local lease=love.thread.newChannel();lease:push(shader) + local record={plan=plan,lease=lease,hook=h,aliases=setmetatable({},{__mode='k'}),variants={},bank={},scalars={},uniformCount=0,leaves=0,count=0,scalarRevision=0,bankRevision=0,gamma=love.graphics.isGammaCorrect and love.graphics.isGammaCorrect()} + record.aliases[shader]=true;originals[shader]=record;records[#records+1]=record + sourceBytes=sourceBytes+#source;stats.sources=stats.sources+1 +end +function S.install() + if installed then return false end + local env=os.getenv('POKEPORT_SHADER_BRANCHES') + local performance=require('src.core.Performance') + if env=='0'or(env~='1'and not performance.shaderBranchSpecialization)then return false end + local g=love and love.graphics + if not(g and love.getVersion and love.getVersion()>=12 and love.system and love.system.getOS and love.system.getOS()=='OS X'and jit and jit.arch=='arm64' + and love.thread and love.thread.newChannel and love.timer and love.timer.getTime and g.getStackDepth and g.getRendererInfo + and g.newShader and g.getShader and g.setShader and debug and debug.getmetatable)then return false end + local renderer=g.getRendererInfo() + if type(renderer)~='string'or not renderer:lower():find('metal',1,true)then return false end + local legacy=package.loaded['src.render.LegacyShaderCompat'] + if legacy and type(legacy.compileNative)~='function'then return false end + installed=true;raw={newShader=g.newShader,setShader=g.setShader,getShader=g.getShader,getStackDepth=g.getStackDepth,present=g.present} + raw.compile=legacy and legacy.compileNative or raw.newShader + g.newShader=function(...) + local shader=raw.newShader(...) + local a,b,c=... + local ok=pcall(admit,shader,a,b,c,select('#',...)) + if not ok then stats.failures=stats.failures+1 end + return shader + end + g.getShader=function() + local shader=raw.getShader();local record=variants[shader] + return record and original(record)or shader + end + for _,name in ipairs{'draw','drawInstanced','drawFromShader','drawLayer','drawIndirect','drawFromShaderIndirect','rectangle','polygon','circle','ellipse','arc','line','points','print','printf'}do + local native=g[name] + if native then g[name]=function(...)beforeDraw();return native(...)end end + end + if raw.present then g.present=function(...)sweep();return raw.present(...)end end + return true +end +function S.status() + local out={installed=installed,sourceBytes=sourceBytes} + for k,v in pairs(stats)do out[k]=v end + return out +end +return S diff --git a/tests/engine/shader_branches.lua b/tests/engine/shader_branches.lua new file mode 100644 index 000000000..7cdc095b7 --- /dev/null +++ b/tests/engine/shader_branches.lua @@ -0,0 +1,61 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local B=require('src.render.ShaderBranches') +local qualified=assert(B.plan('uniform LOVE_HIGHP_OR_MEDIUMP float gate;void f(){if(gate>.5){}}')) +T.eq(qualified.order[1],'gate','standard precision macro is a qualifier') +local source=[[ +uniform float enabled; +uniform highp float amount; +uniform bool lit; +uniform int count; +uniform float unused; +uniform float array[4]; +// if(enabled) amount +vec4 effect(){ + if(enabled > .5 && lit){float enabledExtra=amount*2.0;} + if(!lit){if(enabled < .5){return vec4(amount);}} + if(count>0 && array[0]>.5){return vec4(1.0);} + return vec4(amount); +} +]] +local plan=assert(B.plan(source)) +T.eq(table.concat(plan.order,','),'enabled,lit','only branch-only float/bool reads qualify') +local constants,key,count=B.key(plan,{enabled=0,lit=true,amount=1,count=0}) +T.eq(key,'enabled=0.0;lit=true');T.eq(count,2) +local result=B.rewrite(plan,constants) +T.check(result:find('uniform float enabled;',1,true),'original declaration is retained') +T.check(result:find('// if(enabled) amount',1,true),'comments remain byte-identical') +T.check(result:find('if(0.0 > .5 && true)',1,true),'actual branch reads specialize') +T.check(result:find('if(!true){if(0.0 < .5)',1,true),'nested branch reads specialize') +T.check(result:find('enabledExtra=amount*2.0',1,true),'identifier prefixes and arithmetic remain unchanged') +T.check(result:find('count>0 && array[0]',1,true),'integer and array paths stay dynamic') +T.eq(B.rewrite(plan,{}),source,'no qualified value is exact passthrough') +for _,value in ipairs{.5,2,-1,math.huge,-math.huge,0/0}do T.eq(B.literal('float',value),nil,'arbitrary/nonfinite values stay dynamic')end +T.eq(B.literal('float',-1/math.huge),nil,'negative zero stays dynamic') +T.eq(B.literal('float',0),'0.0');T.eq(B.literal('float',1),'1.0') +T.eq(B.literal('bool',false),'false');T.eq(B.literal('bool',true),'true') +T.eq(B.literal('bool',0),nil);T.eq(B.literal('int',0),nil) +for _,suffix in ipairs{ + 'float other(float gate){if(gate>0.0)return 1.0;return 0.0;}', + 'void other(){float a=1.0,gate=0.0;if(gate>.5){}}', + '#define ALIAS gate\nvoid other(){if(gate>.5){}}', + 'void other(){if(bool gate=true){}}', + '#if (gate)\n#endif\nvoid other(){if(gate>.5){}}', + '#define DEFINE uniform float gate;\nvoid other(){if(gate>.5){}}', +}do + local p=assert(B.plan('uniform float gate;\n'..suffix)) + T.eq(#p.order,0,'shadowed or macro candidate is rejected') +end +local arithmetic=assert(B.plan('uniform float gate; void f(){float b=gate*2.0;if(gate>.5){}}')) +T.eq(#arithmetic.order,1,'a branch read can specialize while arithmetic stays dynamic') +T.check(B.rewrite(arithmetic,{gate='0.0'}):find('float b=gate*2.0;if(0.0>.5)',1,true),'uniform arithmetic is preserved byte-for-byte') +local commented=assert(B.plan('uniform float gate; void f(){if(/* gate */ gate>.5){}}')) +local c=B.key(commented,{gate=1}) +T.check(B.rewrite(commented,c):find('if(/* gate */ 1.0>.5)',1,true),'inline comment is preserved while actual token changes') +T.eq(B.plan('uniform float gate; /* unfinished'),nil) +T.eq(B.plan('uniform float gate; void f(){if(gate'),nil) +T.eq(B.plan('uniform float gate; uniform bool gate;'),nil) +T.eq(B.plan({}),nil) +T.check(not pcall(B.rewrite,plan,{enabled='malicious code'}),'only qualified literal bytes can enter output') +T.finish('shader branch planning, unchanged arithmetic and conservative source guards') diff --git a/tests/engine/shader_specialization_policy.lua b/tests/engine/shader_specialization_policy.lua new file mode 100644 index 000000000..e9c4e349e --- /dev/null +++ b/tests/engine/shader_specialization_policy.lua @@ -0,0 +1,33 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local P=require('src.core.Performance') +local savedLove,savedJit,savedEnv,savedFlag=love,jit,os.getenv,P.shaderBranchSpecialization +local savedLegacy=package.loaded['src.render.LegacyShaderCompat'] +local function inert()error('passthrough policy must not call graphics')end +local function case(policy,enabled,version,osName,arch,renderer,missing,oldLegacy) + local g={newShader=inert,getShader=inert,setShader=inert,draw=inert,getStackDepth=inert,getRendererInfo=function()return renderer end} + love={graphics=g,getVersion=function()return version end,system={getOS=function()return osName end},thread={newChannel=inert},timer={getTime=inert}} + if missing then love[missing]=nil end + jit={arch=arch};P.shaderBranchSpecialization=enabled + package.loaded['src.render.LegacyShaderCompat']=oldLegacy and{}or nil + os.getenv=function(name)if name=='POKEPORT_SHADER_BRANCHES'then return policy end;return savedEnv(name)end + local S=assert(loadfile('src/render/ShaderSpecialization.lua'))() + T.eq(S.install(),false,'unqualified policy remains inactive') + T.eq(g.newShader,inert,'original Shader constructor remains exact') + T.eq(g.draw,inert,'native drawing entry remains exact') + T.eq(S.status().installed,false) +end +case('0',true,12,'OS X','arm64','Metal') +case(nil,false,12,'OS X','arm64','Metal') +case('1',true,11,'OS X','arm64','Metal') +case('1',true,12,'Windows','arm64','Metal') +case('1',true,12,'OS X','x64','Metal') +case('1',true,12,'OS X','arm64','OpenGL') +case('1',true,12,'OS X','arm64','Metal','thread') +case('1',true,12,'OS X','arm64','Metal','system') +case('1',true,12,'OS X','arm64','Metal','timer') +case('1',true,12,'OS X','arm64','Metal',nil,true) +love,jit,os.getenv,P.shaderBranchSpecialization=savedLove,savedJit,savedEnv,savedFlag +package.loaded['src.render.LegacyShaderCompat']=savedLegacy +T.finish('native shader adapter platform and explicit passthrough policies') diff --git a/tests/native_shader/conf.lua b/tests/native_shader/conf.lua new file mode 100644 index 000000000..6f2740555 --- /dev/null +++ b/tests/native_shader/conf.lua @@ -0,0 +1,7 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +function love.conf(t) + t.identity=assert(os.getenv('POKEPORT_IDENTITY')) + t.window.width,t.window.height,t.window.vsync=64,64,0 + t.graphics=t.graphics or{};t.graphics.renderers={assert(os.getenv('POKEPORT_GRAPHICS_BACKEND'))} + t.modules.audio=false +end diff --git a/tests/native_shader/main.lua b/tests/native_shader/main.lua new file mode 100644 index 000000000..fa82e8d3a --- /dev/null +++ b/tests/native_shader/main.lua @@ -0,0 +1,231 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- QA foundation only: qualify native resource leases before an adapter exists. +io.stdout:setvbuf('no') +local engine=os.getenv('SHADER_TEST_ENGINE')or(love.filesystem.getSource()..'/../..') +package.path=engine..'/?.lua;'..engine..'/?/init.lua' +local count=0 +local function check(value,label)count=count+1;assert(value,label);print('CHECK',count,label)end +local function pack(...)return{n=select('#',...),...}end +local function equalReturns(a,b) + if a.n~=b.n then return false end + for i=1,a.n do if a[i]~=b[i]then return false end end + return true +end +local code=[[ +uniform vec4 tint; +vec4 effect(vec4 color,Image image,vec2 uv,vec2 pixel){return tint;} +]] +local function tests() + local g=love.graphics + check(type(g.getStackDepth)=='function','actual native stack-depth query exists') + local start=g.getStackDepth() + check(start==0,'fresh native graphics stack is empty') + local channel=love.thread.newChannel() + local shader=g.newShader(code) + local original=tostring(shader) + channel:push(shader) + check(channel:peek()==shader,'lease preserves live userdata identity') + local sendReturns=pack(shader:send('tint',{1,0,0,1})) + print('NATIVE_SEND_RETURNS',sendReturns.n,tostring(sendReturns[1])) + check(shader:release()==true,'first native release succeeds') + check(shader:release()==false,'repeated native release returns false') + check(not pcall(shader.hasUniform,shader,'tint'),'released interface is unusable') + local alias=channel:peek() + check(alias~=shader,'lease reconstructs a fresh valid userdata') + check(tostring(alias)==original,'fresh alias owns the same C shader') + check(alias:hasUniform('tint'),'shader remains live after original release') + check(equalReturns(pack(alias:send('tint',{1,0,0,1})),sendReturns),'lease preserves actual native send arity and values') + local weak=setmetatable({alias},{__mode='v'}) + alias=nil;collectgarbage('collect');collectgarbage('collect') + check(weak[1]==nil,'C lease does not keep Lua userdata alive') + alias=channel:peek() + check(alias:hasUniform('tint'),'C lease survives Lua collection') + local other=g.newShader(code);other:send('tint',{0,1,0,1}) + local rawPush,rawPop=g.push,g.pop + g.setShader(alias);rawPush('all');g.setShader(other) + check(g.getStackDepth()==start+1,'native push is visible without wrapper counters') + alias:release();channel:clear();rawPop() + local rebound=g.getShader() + check(rebound:hasUniform('tint'),'native C stack retains shader after all Lua leases release') + check(tostring(rebound)==original,'native pop restores original C shader') + check(g.getStackDepth()==start,'native pop returns to empty stack') + g.setShader();rebound:release();other:release();channel:release() + check(type(shader.getReferenceCount)~='function','do not depend on an unavailable reference-count API') + + -- Sampler values are C-owned after send. A future adapter must either replay + -- updates eagerly or fall back, rather than resending a released Lua Texture. + local target=g.newCanvas(4,4,{dpiscale=1}) + local imageData=love.image.newImageData(1,1);imageData:setPixel(0,0,.25,.5,.75,1) + local texture=g.newImage(imageData);imageData:release() + local sample=g.newShader('uniform Image sampleImage; vec4 effect(vec4 c,Image t,vec2 uv,vec2 p){return Texel(sampleImage,vec2(.5));}') + local samplerReturns=pack(sample:send('sampleImage',texture)) + check(equalReturns(samplerReturns,sendReturns),'native sampler and numeric send return contracts agree') + texture:release() + check(not pcall(sample.send,sample,'sampleImage',texture),'released sampler cannot be replayed from Lua') + g.setCanvas(target);g.setShader(sample);g.setBlendMode('replace','premultiplied');g.setColor(1,1,1,1);g.rectangle('fill',0,0,4,4) + g.setCanvas();g.setShader() + local pixels=target:newImageData();local r,b,c,a=pixels:getPixel(1,1) + check(math.abs(r-.25)<1/255 and math.abs(b-.5)<1/255 and math.abs(c-.75)<1/255 and a==1,'C-owned sampler still draws correct pixels after Lua release') + check(math.abs(r-.75)>1/255,'wrong-color negative is observable') + pixels:release();target:release();sample:release() + check(g.getStackDepth()==start,'fixture leaves no native graphics scope') + print('SHADER_CONTRACT_PASS',count) +end +local function adapterTests() + local g=love.graphics + local hasLegacy,legacy=pcall(require,'src.render.LegacyShaderCompat') + if hasLegacy then legacy.install()end + if os.getenv('QA_SHADER_WRONG_COMPILER')=='1'then + assert(hasLegacy,'projection negative requires actual canvas compatibility adapter') + legacy.compileNative=g.newShader + end + local rawGet,rawSet,rawPush,rawPop=g.getShader,g.setShader,g.push,g.pop + local temporary=g.newShader(code);local nativeSend=debug.getmetatable(temporary).__index.send;temporary:release() + if os.getenv('QA_SHADER_MULTIPLE_RETURNS')=='1'then + local temporary=g.newShader(code);local methods=debug.getmetatable(temporary).__index + local send,sendColor=methods.send,methods.sendColor + methods.send=function(self,...)send(self,...);return nil,'native-owner',nil,42 end + methods.sendColor=function(self,...)sendColor(self,...);return false,nil,'native-color-owner',nil end + temporary:release() + end + local S=require('src.render.ShaderSpecialization') + check(S.install(),'native Metal adapter installs explicitly for QA') + check(not S.install(),'native adapter install is idempotent') + local code=[[ +uniform LOVE_HIGHP_OR_MEDIUMP float gate; +uniform vec4 colors[3]; +uniform vec4 tint; +uniform mat4 sceneMatrix; +#ifdef VERTEX +uniform Image vertexOnly; +vec4 independentPosition(mat4 supplied,vec4 vertex){vec4 clip=sceneMatrix*vertex;clip.x+=Texel(vertexOnly,vec2(.5)).r*.0001;return clip;} +vec4 position(mat4 supplied,vec4 vertex){vec4 clip=independentPosition(supplied,vertex);clip.y=-clip.y;return clip;} +#endif +#ifdef PIXEL +uniform Image ownedTexture; +vec4 effect(vec4 c,Image t,vec2 uv,vec2 p){ + vec4 base=(colors[0]+colors[1]+colors[2]+tint)*.25; + if(gate>.5){base+=Texel(ownedTexture,uv)*.125;} + return base; +} +#endif +]] + local reference=g.newShader(code) + local candidate=g.newShader(code..string.rep(' \n',32768)) + check(type(candidate)=='userdata'and candidate:typeOf('Shader'),'optimized source keeps real Shader userdata') + check(candidate:hasUniform('gate'),'original uniform interface is retained') + check(S.status().sources==1,'only the large source is admitted') + local function send(method,name,...) + local native=pack(reference[method](reference,name,...)) + local adapted=pack(candidate[method](candidate,name,...)) + check(equalReturns(native,adapted),'actual native '..method..' return arity/values are preserved') + end + local image=love.image.newImageData(1,1);image:setPixel(0,0,.25,.5,.75,1) + local texture=g.newImage(image);image:release() + send('send','ownedTexture',texture) + send('send','vertexOnly',texture) + send('send','sceneMatrix','row',{.25,0,0,-1,0,.25,0,-1,0,0,1,0,0,0,0,1}) + send('send','colors',{1,0,0,1},{0,1,0,1},{0,0,1,1}) + send('send','colors',{.5,.25,0,1}) + local mutable={.25,.5,.75,1};send('sendColor','tint',mutable) + mutable[1],mutable[2],mutable[3]=1,1,1 + send('send','gate',0) + local target=g.newCanvas(8,8,{dpiscale=1}) + local function compare(rightShader) + g.setCanvas(target);g.origin();g.setBlendMode('replace','premultiplied');g.setColor(1,1,1,1) + g.clear(0,0,0,0) + g.setShader(reference);g.rectangle('fill',0,1,4,2) + g.setShader(rightShader or candidate);g.rectangle('fill',4,1,4,2) + local real,visible=rawGet(),g.getShader() + g.setCanvas();g.setShader() + local data=target:newImageData();local same=true + for y=0,7 do for x=0,3 do + local a={data:getPixel(x,y)};local b={data:getPixel(x+4,y)} + for i=1,4 do if a[i]~=b[i]then same=false end end + end end + data:release();return same,real,visible + end + local same,actual,visible=compare() + check(same,'initial partial-array history and mutable sendColor snapshot are pixel exact') + check(visible==candidate and actual~=candidate,'public shader identity maps actual compiled variant to original') + check(S.status().compiled==1,'first actual native variant compiled') + send('send','gate',1) + check(compare(),'new branch replays old array tails and color snapshot exactly') + check(S.status().compiled==2,'second branch has an independent compiled program') + candidate:send('gate',0) + check(not compare(),'deliberately wrong branch is detected by independent native pixel oracle') + send('send','gate',1);check(compare(),'restored branch restores original pixels') + send('send','colors',{.125,0,0,1}) + check(compare(),'eager prefix update preserves untouched array tail in existing variant') + texture:release() + send('send','gate',0);check(compare(),'previously compiled zero branch survives sampler Lua release') + send('send','gate',1);check(compare(),'previously compiled sampled branch retains C texture ownership') + check(not pcall(candidate.send,candidate,'ownedTexture',texture),'native invalid released resource error propagates') + check(compare(),'rejected native send does not corrupt prior variant uniforms') + check(S.status().failures==0,'qualified native compiler and replay have no errors') + -- A raw native push bypasses adapter Lua wrappers. Its hidden C reference + -- must keep the variant-to-original mapping alive through explicit release. + g.setCanvas(target);g.setShader(candidate);g.rectangle('fill',0,0,8,4) + rawPush('all');rawSet();check(candidate:release(),'original explicit release preserves native success return') + check(not candidate:release(),'original repeated release preserves native false return') + rawPop() + local alias=g.getShader() + check(alias~=candidate and alias:hasUniform('gate'),'hidden C-stack variant restores valid original alias') + g.rectangle('fill',0,0,8,4) + check(rawGet()==alias,'released source forces original C shader at next draw') + g.setCanvas();g.setShader();g.present() + check(S.status().sources==0 and S.status().variants==0,'empty native stack releases disabled programs and C leases') + alias:release();reference:release();target:release() + -- Mutable native Data is a supported source API, but is intentionally not a + -- replayable adapter value. Its original C send and original pixels survive. + local dataCode=[[ +uniform float gate; +uniform vec4 tint; +vec4 effect(vec4 c,Image t,vec2 uv,vec2 p){if(gate>.5)return tint;return vec4(1.0)-tint;} +]] + reference=g.newShader(dataCode);candidate=g.newShader(dataCode..string.rep(' \n',32768)) + target=g.newCanvas(8,8,{dpiscale=1}) + send('send','gate',0);send('send','tint',{.2,.4,.6,.8});check(compare(),'second native source has an exact compiled baseline') + local bytes=love.data.newByteData(love.data.pack('string','ffff',.1,.3,.5,.7)) + send('send','tint',bytes) + check(compare(),'mutable Data send falls back to original C shader with exact pixels') + check(S.status().unsupported==1,'unsupported replay is counted separately from shader compiler failure') + bytes:release();g.setShader();g.present() + reference:release();candidate:release();target:release() + -- Actual GC of the original userdata must not be defeated by adapter maps. + local weak=setmetatable({},{__mode='v'}) + do + local shader=g.newShader(code..string.rep(' \n',32768)) + weak[1]=shader + end + collectgarbage('collect');collectgarbage('collect');g.present() + check(weak[1]==nil,'adapter retains no dead original Lua userdata') + check(S.status().sources==0,'orphan source is retired at a native empty-stack boundary') + local held={} + for i=1,17 do held[i]=g.newShader(code..string.rep(' \n',32768))end + check(S.status().sources==16,'hard source cap is enforced') + g.setShader(held[17]);g.rectangle('fill',0,0,4,4) + check(rawGet()==held[17]and g.getShader()==held[17],'source beyond the cap draws with its exact original C shader') + g.setShader();for _,shader in ipairs(held)do shader:release()end;g.present() + check(S.status().sources==0 and S.status().variants==0,'capped source leases retire without lingering programs') + reference=g.newShader(dataCode);candidate=g.newShader(dataCode..string.rep(' \n',32768));target=g.newCanvas(8,8,{dpiscale=1}) + send('send','gate',0);send('send','tint',{.2,.4,.6,.8});check(compare(),'foreign-method fixture starts with a compiled exact baseline') + local methods=debug.getmetatable(candidate).__index;local ownedSend=methods.send + methods.send=nativeSend + reference:send('tint',{.9,.1,.2,1});candidate:send('tint',{.9,.1,.2,1}) + local exact,actual=compare() + check(exact and actual==candidate,'late foreign send owner falls back to original values and pixels') + methods.send=ownedSend;g.setShader();g.present() + reference:release();candidate:release();target:release() + check(g.getStackDepth()==0,'native adapter fixture leaves no graphics scope') + print('SHADER_ADAPTER_PASS',count) +end +function love.load() + local ok,why=xpcall(function()tests();if os.getenv('QA_NATIVE_SHADER_ADAPTER')=='1'then adapterTests()end end,debug.traceback) + if not ok then print('SHADER_CONTRACT_FAIL',why)end + love.event.quit(ok and 0 or 1) +end +function love.errorhandler(why) + print('SHADER_CONTRACT_ERROR',debug.traceback(tostring(why))) + return function()return 1 end +end From 5e6a2e2f9a1900df8426ed4afefa45ddf924ba43 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Tue, 27 Oct 2026 06:17:22 +0100 Subject: [PATCH 02/18] Document full native-frame shader qualification and remaining limits --- docs/metal-shader-branches.md | 46 ++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/docs/metal-shader-branches.md b/docs/metal-shader-branches.md index 81780f640..e2b3d0f40 100644 --- a/docs/metal-shader-branches.md +++ b/docs/metal-shader-branches.md @@ -38,24 +38,26 @@ conservative. `POKEPORT_SHADER_BRANCHES=0` restores the original compiler path. ## Native qualification -Frozen native source `6f82c5cb00`, fork main `f8a05682` plus the compatibility -composition, unchanged private Origami 1.0.9 `ae59c77c`: baseline `a2aac5b097` -versus native adapter `55614c8cc0`. Both use actual Metal on M3/16 GB, High, -1024×768 scene/window, active FXAA, render scale one, supersampling off and -native VSync off. No QA Shader prototype is installed. +Frozen native source `a5c7462ef0`, committed compiler `4344eb05` on fork main +`0191734c`, unchanged private Origami 1.0.9 `ae59c77c`: baseline `562ad4a1f9` +versus default-enabled native adapter `de603b9839`. Both use actual Metal on +M3/16 GB, High, 1024×768 scene/window, active FXAA, render scale one, +supersampling off and native VSync off. No QA Shader prototype is installed. -Each row measures 120 ordinary native walking frames, after settling and a -separate collection/recovery test. Native frame serial increments once per -sample. The shared hardware lease covers source setup through final cleanup. -This initial deterministic driver calls the Game update body directly. A -follow-up through the complete native `PlatformHooks.update` frame owner is -required before treating these figures as full mod-update measurements. +The QA input adapter replaces only the deterministic driver's direct Game +update call in memory with the real `PlatformHooks.update(Game, 1/60)` owner. +Engine and mod files remain unchanged. The complete outer mod hook chain runs +exactly once per measured step; native renderer serial also advances once. +Each view measures 120 ordinary directional-input frames after settling and a +separate collection/recovery test. The player occupies 17 / 13 / 13 distinct +native positions in open/Third/First, and scene clocks match across both runs. +The shared hardware lease covers source setup through final cleanup. | View | Original mean ms | Adapter mean ms | Adapter p95 ms | Adapter max ms | | --- | ---: | ---: | ---: | ---: | -| open | 5.69 | 5.32 | 6.87 | 9.39 | -| third | 18.98 | 11.43 | 12.87 | 15.10 | -| first | 20.02 | 11.86 | 12.83 | 14.12 | +| open | 6.77 | 6.21 | 10.61 | 12.07 | +| third | 21.29 | 11.43 | 12.64 | 14.24 | +| first | 20.07 | 11.85 | 12.89 | 15.65 | All three complete scene captures are pixel-exact, excluding only the changing FPS HUD (`x >= 890 && y < 56`). Twelve actual variants are used, with no @@ -63,20 +65,26 @@ compiler/replay failures, unsupported values or fallback draws. The original failed native integration (`a67197449f`) double-adapted clip space; its failure is preserved and corrected through the constructor seam, not an image flip. -Actual GPU fixture `5155a3c3f4` passes 72 checks, including spatial projection, +Actual default-policy GPU fixtures `586e40dc29` (composed) and `efda7ed3c9` +(standalone upstream implementation) each pass 73 checks, including spatial projection, real Shader identity, multiple/nil returns from a prior owner, partial arrays, matrix layout, mutable color inputs, sampler release, raw native stack frames, Shader release/GC, Data fallback, source caps and later foreign method ownership. -The deliberate wrong-branch pixel control is detected. Focused native engine, +Both the deliberate wrong-branch control and the separate double-projection +negative `189f31ddff` are detected. Independent public CPU qualification +`c303516873` passes 38 source / 40 policy / 128 canvas-seam assertions. Focused native engine, orientation, JIT, map, follower, options, pacing and Crystal VRAM checks pass in the same frozen composition. These are internal rendering observations while the console is locked. They are not OS-window, interactive 60-FPS, outdoor, full-game or all-platform -acceptance. Cold compilation is synchronous: 7.21 seconds accumulated across -the twelve first-used variants in this run, outside the steady walking samples. +acceptance. Cold compilation is synchronous. Initial control `55614c8cc0` accumulated +7.21 seconds across twelve first-used variants; the full-frame repeat records +1.92 seconds. Both are outside the steady samples; no cache-cause attribution +is claimed. First-use hitches remain a limitation; there is no asynchronous-compilation -claim. Player profiles and saves are not used. QA enforces 20 GiB free, above +claim. The existing overworld-battle Card fails in both baseline and adapter +runs; this change does not qualify complete battle support. Player profiles and saves are not used. QA enforces 20 GiB free, above the requested 8 GiB minimum. ## Repeating the API fixture From c9d70728b1be02c5737cb52039766f4e8dd1f0b1 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 15:22:09 +0100 Subject: [PATCH 03/18] Plan and rewrite large branch shaders without repeated program copies --- src/render/ShaderBranches.lua | 57 ++++++++++++++++++-------------- tests/engine/shader_branches.lua | 21 ++++++++++++ 2 files changed, 53 insertions(+), 25 deletions(-) diff --git a/src/render/ShaderBranches.lua b/src/render/ShaderBranches.lua index 25667f479..3c6c81263 100644 --- a/src/render/ShaderBranches.lua +++ b/src/render/ShaderBranches.lua @@ -63,25 +63,36 @@ function B.plan(source) -- member names and macros before substituting any read in an if condition. -- A comma may introduce another local declaration; conservatively reject -- that spelling even when it is merely a function argument. - local outside=clean - local ranges={};for _,r in ipairs(declarations)do ranges[#ranges+1]=r end - table.sort(ranges,function(a,b)return a[1]>b[1]end) - for _,r in ipairs(ranges)do outside=outside:sub(1,r[1]-1)..blank(outside:sub(r[1],r[2]))..outside:sub(r[2]+1)end + local pieces,previous={},1 + for _,r in ipairs(declarations)do + pieces[#pieces+1]=clean:sub(previous,r[1]-1) + pieces[#pieces+1]=blank(clean:sub(r[1],r[2]));previous=r[2]+1 + end + pieces[#pieces+1]=clean:sub(previous) + local outside=table.concat(pieces) + local used={} + for _,r in ipairs(conditions)do + for name in clean:sub(r[1],r[2]):gmatch('%f[%w_][%w_]+%f[^%w_]')do used[name]=true end + end local order={} for name in pairs(names)do local use=pattern(name) - local shadow=outside:find('[%w_]+%s+'..name..'%f[^%w_]') - or outside:find('[,%.]%s*'..name..'%f[^%w_]') - local macro=false;local pos=1 + local shadow,macro=false,false;local pos=1 while true do local a,b=outside:find(use,pos);if not a then break end + -- Inspect the predecessor of this actual token. Searching an unanchored + -- word+whitespace pattern rescans every suffix of long GLSL identifiers + -- for every candidate, even when the candidate is absent there. + local before=a-1 + while before>0 and outside:sub(before,before):match('%s')do before=before-1 end + local predecessor=outside:sub(before,before) + if predecessor:match('[,%.]')or beforeb[1]end) - for _,c in ipairs(changes)do original=original:sub(1,c[1]-1)..c[3]..original:sub(c[2]+1)end - source=source:sub(1,first-1)..original..source:sub(last+1) end - return source + pieces[#pieces+1]=source:sub(previous) + return table.concat(pieces) end return B diff --git a/tests/engine/shader_branches.lua b/tests/engine/shader_branches.lua index 7cdc095b7..afeda2146 100644 --- a/tests/engine/shader_branches.lua +++ b/tests/engine/shader_branches.lua @@ -58,4 +58,25 @@ T.eq(B.plan('uniform float gate; void f(){if(gate'),nil) T.eq(B.plan('uniform float gate; uniform bool gate;'),nil) T.eq(B.plan({}),nil) T.check(not pcall(B.rewrite,plan,{enabled='malicious code'}),'only qualified literal bytes can enter output') +local large,expected={},{} +for i=1,64 do + local line=('uniform float gate%d;\n'):format(i);large[#large+1]=line;expected[#expected+1]=line +end +-- Large generated programs must retain comments, arithmetic, nested branches +-- and complete identifier boundaries while specializing every selected read. +local values={};for i=1,64 do values['gate'..i]=i%2==0 and'0.0'or'1.0'end +for i=1,1024 do + local name='gate'..((i-1)%64+1) + local line=('void f%d(){if(/* %s */%s>.5){if(%s<.5){float v=%s*2.;}}}\n'):format(i,name,name,name,name) + large[#large+1]=line + expected[#expected+1]=('void f%d(){if(/* %s */%s>.5){if(%s<.5){float v=%s*2.;}}}\n'):format(i,name,values[name],values[name],name) +end +large=table.concat(large);expected=table.concat(expected) +local big=assert(B.plan(large));T.eq(#big.order,64) +T.eq(B.rewrite(big,values),expected,'large nested shader output is exact, without rebuilding the program per branch') +T.eq(B.rewrite(big,{}),large,'large dynamic program is byte-identical') +for _,prefix in ipairs{'word ','word\n','word\t',',',' ,\n','.',' .\t'}do + local p=assert(B.plan('uniform float gate;void f(){'..prefix..'gate;if(gate>.5){}}')) + T.eq(#p.order,0,'token predecessor retains shadow/member rejection: '..prefix) +end T.finish('shader branch planning, unchanged arithmetic and conservative source guards') From b4f1851b1025d67d93ed45e70692ad936f3c8028 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 15:40:35 +0100 Subject: [PATCH 04/18] Index shader identifier guards in one source pass --- src/render/ShaderBranches.lua | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/render/ShaderBranches.lua b/src/render/ShaderBranches.lua index 3c6c81263..61371cffd 100644 --- a/src/render/ShaderBranches.lua +++ b/src/render/ShaderBranches.lua @@ -19,7 +19,6 @@ local function uncomment(source) end return table.concat(pieces) end -local function pattern(name)return'%f[%w_]'..name..'%f[^%w_]'end local function withoutDirectives(clean) local continued=false;local out={} for line in(clean..'\n'):gmatch('(.-)\n')do @@ -74,23 +73,25 @@ function B.plan(source) for _,r in ipairs(conditions)do for name in clean:sub(r[1],r[2]):gmatch('%f[%w_][%w_]+%f[^%w_]')do used[name]=true end end - local order={} - for name in pairs(names)do - local use=pattern(name) - local shadow,macro=false,false;local pos=1 - while true do - local a,b=outside:find(use,pos);if not a then break end + local rejected={};local pos=1 + while true do + local a,b=outside:find('%f[%w_][%w_]+%f[^%w_]',pos);if not a then break end + local name=outside:sub(a,b) + if names[name]and not rejected[name]then -- Inspect the predecessor of this actual token. Searching an unanchored -- word+whitespace pattern rescans every suffix of long GLSL identifiers -- for every candidate, even when the candidate is absent there. local before=a-1 while before>0 and outside:sub(before,before):match('%s')do before=before-1 end local predecessor=outside:sub(before,before) - if predecessor:match('[,%.]')or before Date: Wed, 28 Oct 2026 15:49:07 +0100 Subject: [PATCH 05/18] Document actual shader planning costs and remaining native frame stalls --- docs/qa/native-foundation/shader-planning.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docs/qa/native-foundation/shader-planning.md diff --git a/docs/qa/native-foundation/shader-planning.md b/docs/qa/native-foundation/shader-planning.md new file mode 100644 index 000000000..822d1c4a2 --- /dev/null +++ b/docs/qa/native-foundation/shader-planning.md @@ -0,0 +1,13 @@ +# Bounded work for large shader planning + +The old branch planner searched the entire program for each uniform, and its shadow check repeatedly searched identifier suffixes. Its rewriter rebuilt the complete program for each condition. Both now index identifiers and assemble changed source spans in a single pass. Existing conservative shadow, member, macro, qualifier and literal guards remain intact. Rendering resolution, shader arithmetic, uniforms, material branches and post-processing are unchanged. + +On two actual world shader inputs (98,913 and 96,648 bytes, 23/22 admitted flags, 273/266 conditions), the exact predecessor and the new planner produce equal plans and byte-identical rewrites for all-zero, all-one and mixed flag banks. Seven measured CPU repetitions after two warm-ups give median planning 418.659 to 5.157 ms and 402.507 to 4.702 ms. Rewrite medians are 7.104 to 0.193 ms and 6.267 to 0.185 ms. These are isolated CPU planning measurements, not a claimed frame-rate multiplier. No private shader text is published. + +The planner fixture passes 48 checks, including exact output for a generated nested large program and unchanged shadow/macro rejection. Platform/passthrough policy checks pass 40 assertions. The first optimization composition passed 246 frozen CPU groups, and both the fork and older independent public implementation passed 73 real Metal Shader-contract checks. The final token-index pass additionally passes the actual-program byte-equivalence comparison; final native repetition is recorded separately when available. + +A separate native FireRed water-view diagnostic, with the first optimization, observed an 844-ms frame (840 ms in drawing, under 1 ms in presentation). Native compilation remains synchronous and dominated the sampled slow setup. A 120-frame warm interval without the Lua sampler averaged 18.309 ms (about 55 FPS), p95 27.346 ms, maximum 42.186 ms. It used High quality, 1024x768 and a 512x384 scene reflection target. No capture, texture readback, file write or forced collection ran in either frame window. This short fixed-camera diagnostic is not stable 60-FPS, rain, Third/First-person, interactive-window or full-game acceptance. Existing first-use driver compilation remains unresolved. + +QA used owned profiles, preserved private source and player saves, and enforced 20 GiB free (above the requested 8 GiB reserve). + +Contributions by Roxas2712 — https://github.com/Roxas2712. See LICENSE-ROXAS2712.md for the specific integration/distribution permission for Cedric / UNDERdecoded with visible credit. Existing upstream/third-party licences and rights remain intact. From b1ad66623eae0e6b58713e1885d96018cd530659 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 16:01:01 +0100 Subject: [PATCH 06/18] Record final native GPU contracts for linear shader planning --- docs/qa/native-foundation/shader-planning.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/qa/native-foundation/shader-planning.md b/docs/qa/native-foundation/shader-planning.md index 822d1c4a2..c9a93ca5b 100644 --- a/docs/qa/native-foundation/shader-planning.md +++ b/docs/qa/native-foundation/shader-planning.md @@ -4,7 +4,7 @@ The old branch planner searched the entire program for each uniform, and its sha On two actual world shader inputs (98,913 and 96,648 bytes, 23/22 admitted flags, 273/266 conditions), the exact predecessor and the new planner produce equal plans and byte-identical rewrites for all-zero, all-one and mixed flag banks. Seven measured CPU repetitions after two warm-ups give median planning 418.659 to 5.157 ms and 402.507 to 4.702 ms. Rewrite medians are 7.104 to 0.193 ms and 6.267 to 0.185 ms. These are isolated CPU planning measurements, not a claimed frame-rate multiplier. No private shader text is published. -The planner fixture passes 48 checks, including exact output for a generated nested large program and unchanged shadow/macro rejection. Platform/passthrough policy checks pass 40 assertions. The first optimization composition passed 246 frozen CPU groups, and both the fork and older independent public implementation passed 73 real Metal Shader-contract checks. The final token-index pass additionally passes the actual-program byte-equivalence comparison; final native repetition is recorded separately when available. +The planner fixture passes 48 checks, including exact output for a generated nested large program and unchanged shadow/macro rejection. Platform/passthrough policy checks pass 40 assertions. The first optimization composition passed 246 frozen CPU groups, and both the fork and older independent public implementation passed 73 real Metal Shader-contract checks. The final token-index pass passes the actual-program byte-equivalence comparison and both independent real Metal contract repetitions (73 checks each). The later composed current-main regression, including the door descriptor integration, passes 250 frozen CPU groups. The public implementation retains its original upstream controllers. A separate native FireRed water-view diagnostic, with the first optimization, observed an 844-ms frame (840 ms in drawing, under 1 ms in presentation). Native compilation remains synchronous and dominated the sampled slow setup. A 120-frame warm interval without the Lua sampler averaged 18.309 ms (about 55 FPS), p95 27.346 ms, maximum 42.186 ms. It used High quality, 1024x768 and a 512x384 scene reflection target. No capture, texture readback, file write or forced collection ran in either frame window. This short fixed-camera diagnostic is not stable 60-FPS, rain, Third/First-person, interactive-window or full-game acceptance. Existing first-use driver compilation remains unresolved. From 5587a16a4bc87570c6c510eff772f706eb2464d3 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 16:51:59 +0100 Subject: [PATCH 07/18] Compile target-specific Metal cutout variants with native method ownership --- main.lua | 1 + src/render/AlphaDepthCompat.lua | 204 ++++++++++++++++++++++++++ src/render/ShaderSamplerGuard.lua | 13 +- src/render/ShaderSpecialization.lua | 50 +++++-- tests/engine/alpha_depth_compat.lua | 110 ++++++++++++++ tests/engine/shader_sampler_guard.lua | 33 +++++ tests/native_shader/main.lua | 76 ++++++++++ 7 files changed, 470 insertions(+), 17 deletions(-) create mode 100644 src/render/AlphaDepthCompat.lua create mode 100644 tests/engine/alpha_depth_compat.lua create mode 100644 tests/engine/shader_sampler_guard.lua diff --git a/main.lua b/main.lua index 418b147a2..34c57bdbb 100644 --- a/main.lua +++ b/main.lua @@ -499,6 +499,7 @@ end function love.load(args) BootTrace.mark("love.load enter") GraphicsStack.install() + require("src.render.AlphaDepthCompat").install() require("src.render.ShaderSpecialization").install() pcall(function() require('src.core.SwitchAudioCheck').run() end) diff --git a/src/render/AlphaDepthCompat.lua b/src/render/AlphaDepthCompat.lua new file mode 100644 index 000000000..493dee109 --- /dev/null +++ b/src/render/AlphaDepthCompat.lua @@ -0,0 +1,204 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Metal can commit a projected cutout's depth before its alpha discard. +-- Explicitly returning the unchanged fragment depth forces late depth writes: +-- transparent texels leave later foliage intact, opaque pixels still occlude. +local A={} +local installed=false +local marker='POKEPORT_ALPHA_DEPTH_ADAPTED' +local function clean(source) + return (source:gsub('/%*.-%*/',function(s)return s:gsub('[^\n]',' ')end) + :gsub('//[^\n]*','')) +end +local function projected(source) + return type(source)=='string' and clean(source):find('vec4%s+position%s*%(')~=nil +end +local prefix=[[#ifdef PIXEL +#define POKEPORT_ALPHA_DEPTH_ADAPTED 1 +#define effect pokeportAlphaDepthEffect +#endif +#line 1 +]] +local suffix=[[ +#ifdef PIXEL +#undef effect +vec4 effect(vec4 color, Image tex, vec2 uv, vec2 screen) { + vec4 pixel = pokeportAlphaDepthEffect(color, tex, uv, screen); + gl_FragDepth = gl_FragCoord.z; + return pixel; +} +#endif +]] +function A.source(source,hasPosition) + if type(source)~='string' then return source,false end + local code=clean(source) + if not(hasPosition or projected(code)) + or not code:find('vec4%s+effect%s*%(') + or not code:find('%f[%w_]discard%f[^%w_]') + or code:find('%f[%w_]gl_FragDepth%f[^%w_]') + or code:find('%f[%w_]gl_FragDepthEXT%f[^%w_]') + or code:find(marker,1,true) + or code:find('POKEPORT_NATIVE_ALPHA_DEPTH',1,true) + or code:find('#%s*define%s+effect%f[^%w_]') + or code:find('void%s+pixelmain%s*%(')then return source,false end + return prefix..source..'\n'..suffix,true +end +local unpack=table.unpack or unpack +local originals=setmetatable({},{__mode='k'}) +local ignored=setmetatable({},{__mode='k'}) +local records,hooks={},{} +local sourceBytes=0 +local stats={compiled=0,lateDraws=0,retired=0,failures=0} +local raw +local function find(shader) + if not shader then return end + local record=originals[shader] + if record then return record end + if ignored[shader]then return end + for _,candidate in ipairs(records)do + if candidate.lease:peek()==shader then + originals[shader]=candidate;candidate.aliases[shader]=true;return candidate + end + end + ignored[shader]=true +end +local function sweep() + if raw.getStackDepth()~=0 then return end + find(raw.getShader()) -- preserve a shader still held by the native target + for i=#records,1,-1 do + local record=records[i];local live=false + for alias in pairs(record.aliases)do + if pcall(record.hook.hasUniform,alias,'pokeportAlphaDepthLifetimeProbe')then live=true;break end + end + if record.disabled or not live then + for alias in pairs(record.aliases)do originals[alias]=nil end + record.hook.release(record.late);record.lease:clear();record.lease:release() + sourceBytes=sourceBytes-record.bytes;table.remove(records,i);stats.retired=stats.retired+1 + end + end +end +local function mirrored(record,method,args,...) + if not record.disabled then + local name=args[1] + if record.hook.hasUniform(record.late,name)then + local ok=pcall(method,record.late,unpack(args,1,args.n)) + if not ok then record.disabled=true end + end + end + return ... +end +local function released(record,...) + record.disabled=true + return ... +end +local function hook(shader) + local methods=debug.getmetatable(shader).__index + if hooks[methods]then return hooks[methods]end + local h={hasUniform=methods.hasUniform,release=methods.release,wrapped={}} + hooks[methods]=h + for _,name in ipairs{'send','sendColor'}do + local method=methods[name] + if method then h.wrapped[name]=function(self,...) + local record=find(self) + if not record or record.disabled then return method(self,...)end + local args={n=select('#',...),...} + -- The original must accept the write first. A rejected native write must + -- neither alter the companion nor change the public error/return contract. + return mirrored(record,method,args,method(self,...)) + end;methods[name]=h.wrapped[name]end + end + methods.release=function(self,...) + local record=find(self) + if record then return released(record,h.release(self,...))end + return h.release(self,...) + end + h.wrapped.release=methods.release + return h +end +function A.ownsShaderMethods(methods) + local h=hooks[methods] + return h and methods.send==h.wrapped.send and methods.sendColor==h.wrapped.sendColor + and methods.release==h.wrapped.release and methods.hasUniform==h.hasUniform or false +end +local function depthTarget() + local target=raw.getCanvas() + if type(target)~='table'then return false end + local depth=rawget(target,'depthstencil') + if type(depth)=='table'then depth=depth[1]end + return depth and depth.getFormat and depth:getFormat():match('^depth')~=nil or false +end +-- Derivative programs owned by another native compiler adapter can select +-- this exact path once, without creating a second companion or changing +-- programs around every draw. Colour-only targets retain their own program. +function A.fragmentDepthTarget() + if not installed then return false end + local _,writes=raw.getDepthMode() + return writes and depthTarget()or false +end +local function restored(shader,ok,...) + raw.setShader(shader) + if not ok then error((...),0)end + return ... +end +local function draw(native,...) + local shader=raw.getShader();local record=find(shader) + if not record or record.disabled then return native(...)end + local _,writes=raw.getDepthMode() + -- Metal rejects a fragment-depth output on a colour-only render target, + -- even if depth testing is off. Query the actual native attachment, so raw + -- push/pop and nested previews cannot leave stale target bookkeeping here. + -- Unobservable temporary/backbuffer depth targets keep the original program. + if not writes or not depthTarget()then return native(...)end + stats.lateDraws=stats.lateDraws+1 + raw.setShader(record.late) + return restored(shader,pcall(native,...)) +end +function A.install() + local g=love and love.graphics + if installed or not(love and love.getVersion and love.getVersion()>=12 and g + and g.getRendererInfo and g.newShader and g.getShader and g.setShader + and g.getCanvas and g.getDepthMode and g.getStackDepth + and love.thread and love.thread.newChannel and debug and debug.getmetatable)then return false end + local renderer=g.getRendererInfo() + if type(renderer)~='string' or not renderer:lower():find('metal',1,true)then return false end + installed=true + raw={newShader=g.newShader,getShader=g.getShader,setShader=g.setShader, + getCanvas=g.getCanvas,getDepthMode=g.getDepthMode,getStackDepth=g.getStackDepth,present=g.present} + A.compileNative=raw.newShader + local function read(arg) + if type(arg)=='string' and not arg:find('\n',1,true) then + local fs=love.filesystem + if fs and fs.getInfo and fs.getInfo(arg,'file')then return fs.read(arg)or arg end + elseif type(arg)=='userdata' and arg.typeOf and arg:typeOf('FileData')then return arg:getString()end + return arg + end + g.newShader=function(a,b,c) + local shader=raw.newShader(a,b,c) + local first,second=read(a),read(b) + local size=(type(first)=='string'and #first or 0)+(type(second)=='string'and #second or 0) + if size>262144 then ignored[shader]=true;return shader end + local hasPosition=projected(first)or projected(second) + local adapted,changed=A.source(first,hasPosition) + local adapted2,changed2=A.source(second,hasPosition) + if not(changed or changed2)then ignored[shader]=true;return shader end + sweep() + if #records>=32 or sourceBytes+size>2*1024*1024 then ignored[shader]=true;return shader end + local ok,late=pcall(raw.newShader,changed and adapted or a,changed2 and adapted2 or b,c) + if not ok then stats.failures=stats.failures+1;ignored[shader]=true;return shader end + local h=hook(shader);local lease=love.thread.newChannel();lease:push(shader) + local record={late=late,hook=h,lease=lease,bytes=size,aliases=setmetatable({},{__mode='k'})} + originals[shader]=record;record.aliases[shader]=true;records[#records+1]=record;sourceBytes=sourceBytes+size;stats.compiled=stats.compiled+1 + return shader + end + for _,name in ipairs{'draw','drawInstanced','drawFromShader','drawLayer','drawIndirect','drawFromShaderIndirect','rectangle','polygon','circle','ellipse','arc','line','points','print','printf'}do + local native=g[name] + if native then g[name]=function(...)return draw(native,...)end end + end + if raw.present then g.present=function(...)sweep();return raw.present(...)end end + return true +end +function A.status() + local out={installed=installed,sources=#records,sourceBytes=sourceBytes} + for key,value in pairs(stats)do out[key]=value end + return out +end +return A diff --git a/src/render/ShaderSamplerGuard.lua b/src/render/ShaderSamplerGuard.lua index 7cd6e34e7..7b44cbe54 100644 --- a/src/render/ShaderSamplerGuard.lua +++ b/src/render/ShaderSamplerGuard.lua @@ -5,6 +5,7 @@ local G={uniform='pokeportBranchSamplerGuard'} function G.rewrite(source,clean,hasUniform) assert(not clean:find(G.uniform,1,true),'sampler guard name already present') + assert(not clean:find('pokeportBranchVertexSample',1,true)and not clean:find('pokeportBranchPixelSample',1,true),'sampler helper name already present') assert(not clean:find('%f[%w_]buffer%f[^%w_]'),'storage buffers stay native') assert(not clean:find('uniform%s+Image%s+[%w_]+%s*%['),'sampler arrays stay native') for kind in clean:gmatch('uniform%s+([%w_]+)%s+')do @@ -50,7 +51,9 @@ function G.rewrite(source,clean,hasUniform) local samples={};for _,name in ipairs(names[stage])do samples[#samples+1]='Texel('..name..',vec2(0.0))'end return #samples>0 and table.concat(samples,'+')or'vec4(0.0)' end - local prefix='uniform float '..G.uniform..';\n' + -- A valid source may declare a stage sampler after position(). Prototypes + -- keep the injected call legal there; definitions follow all declarations. + local prefix='uniform float '..G.uniform..';\n#ifdef VERTEX\nvec4 pokeportBranchVertexSample();\n#endif\n#ifdef PIXEL\nvec4 pokeportBranchPixelSample();\n#endif\n' local pragma,rest=source:match('^(#pragma[^\n]*\n)(.*)$') source=pragma and(pragma..prefix..rest)or(prefix..source) local outputs,maxOutput={},0 @@ -59,16 +62,16 @@ function G.rewrite(source,clean,hasUniform) for slot=0,maxOutput do outputs[#outputs+1]='love_Canvases['..slot..']=pokeportBranchSample;'end local n source,n=source:gsub('(void%s+effect%s*%(%s*%)%s*{)',function(entry) - return entry..'\n#ifdef PIXEL\nif('..G.uniform..'>.5){vec4 pokeportBranchSample='..sum('pixel')..';'..table.concat(outputs)..'return;}\n#endif\n' + return entry..'\n#ifdef PIXEL\nif('..G.uniform..'>.5){vec4 pokeportBranchSample=pokeportBranchPixelSample();'..table.concat(outputs)..'return;}\n#endif\n' end,1) if n==0 then source=source:gsub('(vec4%s+effect%s*%([^)]*%)%s*{)',function(entry) - return entry..'\n#ifdef PIXEL\nif('..G.uniform..'>.5)return '..sum('pixel')..';\n#endif\n' + return entry..'\n#ifdef PIXEL\nif('..G.uniform..'>.5)return pokeportBranchPixelSample();\n#endif\n' end,1) end source=source:gsub('(vec4%s+position%s*%([^)]*%)%s*{)',function(entry) - return entry..'\n#ifdef VERTEX\nif('..G.uniform..'>.5)return '..sum('vertex')..';\n#endif\n' + return entry..'\n#ifdef VERTEX\nif('..G.uniform..'>.5)return pokeportBranchVertexSample();\n#endif\n' end,1) - return source + return source..'\n#ifdef VERTEX\nvec4 pokeportBranchVertexSample(){return '..sum('vertex')..';}\n#endif\n#ifdef PIXEL\nvec4 pokeportBranchPixelSample(){return '..sum('pixel')..';}\n#endif\n' end return G diff --git a/src/render/ShaderSpecialization.lua b/src/render/ShaderSpecialization.lua index 3490eb9b0..002df3ebb 100644 --- a/src/render/ShaderSpecialization.lua +++ b/src/render/ShaderSpecialization.lua @@ -6,7 +6,7 @@ local Branches=require('src.render.ShaderBranches') local Guard=require('src.render.ShaderSamplerGuard') local S={} local installed=false -local stats={sources=0,variants=0,compiled=0,uses=0,fallbacks=0,failures=0,unsupported=0,compileSeconds=0} +local stats={sources=0,variants=0,compiled=0,uses=0,depthUses=0,depthCompiled=0,fallbacks=0,failures=0,unsupported=0,compileSeconds=0} local limits={sources=16,sourceBytes=2*1024*1024,minBytes=65536,maxBytes=262144,variants=64,perSource=16,uniforms=256,arguments=256,leaves=32768} local originals=setmetatable({},{__mode='k'}) local variants={} @@ -123,25 +123,33 @@ local function afterSend(record,method,args,...) end local function hook(shader) local methods=debug.getmetatable(shader).__index - local prior=hooks[methods];if prior then return prior end + local prior=hooks[methods] + if prior then + local alpha=package.loaded['src.render.AlphaDepthCompat'] + -- The depth adapter hooks this native class lazily, on its first cutout. + -- Rebase only that exact engine-owned interposition. Foreign owners keep + -- the existing conservative fallback; old closures must forward once. + if not(alpha and alpha.ownsShaderMethods and alpha.ownsShaderMethods(methods))then return prior end + end local h={send=methods.send,sendColor=methods.sendColor,release=methods.release,hasUniform=methods.hasUniform,methods=methods,wrapped={}} hooks[methods]=h for _,method in ipairs{'send','sendColor'}do if h[method]then h.wrapped[method]=function(self,...) local record=not compiling and originals[self] - if not record or record.disabled then return h[method](self,...)end + if not record or record.disabled or record.hook~=h then return h[method](self,...)end return afterSend(record,method,pack(...),h[method](self,...)) end;methods[method]=h.wrapped[method]end end methods.release=function(self,...) local record=originals[self] - if record then disable(record)end + if record and record.hook==h then disable(record)end return h.release(self,...) end h.wrapped.release=methods.release + if prior then for _,record in ipairs(records)do if record.hook==prior then record.hook=h end end end return h end -local function construct(record,key,constants) +local function construct(record,key,constants,depth) if record.count>=limits.perSource or stats.variants>=limits.variants then return end -- Reconstruct resources before compiling. A missing/released Lua Texture is -- still correctly owned by the original C shader, so retain that path. @@ -159,7 +167,15 @@ local function construct(record,key,constants) local ok,why=pcall(function() local source=Branches.rewrite(record.plan,constants) source=Guard.rewrite(source,record.plan.clean,function(name)return record.hook.hasUniform(original(record),name)end) - shader=raw.compile(source) + local compiler=raw.compile + if record.depthAdapter then + compiler=record.depthAdapter.compileNative + if depth then + local changed;source,changed=record.depthAdapter.source(source) + assert(changed,'native depth variant no longer admissible') + end + end + shader=compiler(source) if record.hook.hasUniform(shader,Guard.uniform)then record.hook.send(shader,Guard.uniform,0)end for name,rows in pairs(replay)do if record.hook.hasUniform(shader,name)then @@ -174,9 +190,10 @@ local function construct(record,key,constants) record.variants[key]=false;stats.failures=stats.failures+1;stats.lastFailure=tostring(why):sub(1,1000) return nil,why end - local variant={shader=shader} + local variant={shader=shader,depth=depth} record.variants[key]=variant;variants[shader]=record stats.variants=stats.variants+1;stats.compiled=stats.compiled+1 + if depth then stats.depthCompiled=stats.depthCompiled+1 end return variant end local function choose(record) @@ -195,12 +212,14 @@ local function choose(record) record.constants,record.key=constants,table.concat(key,';');record.keyRevision=record.scalarRevision end if record.key==''then return end - local variant=record.variants[record.key] + local depth=record.depthAdapter and record.depthAdapter.fragmentDepthTarget()or false + local key=record.key..(record.depthAdapter and(depth and';@native-depth'or';@native-color')or'') + local variant=record.variants[key] if variant==nil then - if record.retryKey==record.key and record.retryRevision==record.bankRevision then return end - local ok,value=pcall(construct,record,record.key,record.constants) + if record.retryKey==key and record.retryRevision==record.bankRevision then return end + local ok,value=pcall(construct,record,key,record.constants,depth) if ok then variant=value else stats.unsupported=stats.unsupported+1;stats.lastFallback=tostring(value):sub(1,160)end - if not variant then record.retryKey,record.retryRevision=record.key,record.bankRevision end + if not variant then record.retryKey,record.retryRevision=key,record.bankRevision end end return variant end @@ -209,7 +228,9 @@ local function beforeDraw() local actual=raw.getShader();local record=variants[actual]or originals[actual] if not record then return end local variant=choose(record) - if variant then raw.setShader(variant.shader);stats.uses=stats.uses+1 + if variant then + if actual~=variant.shader then raw.setShader(variant.shader)end + stats.uses=stats.uses+1;if variant.depth then stats.depthUses=stats.depthUses+1 end else raw.setShader(original(record));stats.fallbacks=stats.fallbacks+1 end end local function admit(shader,source,second,third,n) @@ -222,6 +243,11 @@ local function admit(shader,source,second,third,n) if not plan or #plan.order==0 or plan.clean:find('void%s+[vp]ertexmain%s*%(')or plan.clean:find('void%s+pixelmain%s*%(')then return end local h=hook(shader);local lease=love.thread.newChannel();lease:push(shader) local record={plan=plan,lease=lease,hook=h,aliases=setmetatable({},{__mode='k'}),variants={},bank={},scalars={},uniformCount=0,leaves=0,count=0,scalarRevision=0,bankRevision=0,gamma=love.graphics.isGammaCorrect and love.graphics.isGammaCorrect()} + local alpha=package.loaded['src.render.AlphaDepthCompat'] + if alpha and type(alpha.compileNative)=='function'and type(alpha.fragmentDepthTarget)=='function'then + local _,eligible=alpha.source(source) + if eligible then record.depthAdapter=alpha end + end record.aliases[shader]=true;originals[shader]=record;records[#records+1]=record sourceBytes=sourceBytes+#source;stats.sources=stats.sources+1 end diff --git a/tests/engine/alpha_depth_compat.lua b/tests/engine/alpha_depth_compat.lua new file mode 100644 index 000000000..1daeafc6a --- /dev/null +++ b/tests/engine/alpha_depth_compat.lua @@ -0,0 +1,110 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local A=require('src.render.AlphaDepthCompat') +local vertex='vec4 position(mat4 m,vec4 v){return m*v;}' +local pixel='vec4 effect(vec4 c,Image t,vec2 uv,vec2 sc){vec4 p=Texel(t,uv);if(p.a<0.5)discard;return p*c;}' +local combined=vertex..'\n'..pixel +local adapted,changed=A.source(combined) +T.check(changed,'projected alpha cutout gets a late depth boundary') +T.check(adapted:find(combined,1,true)~=nil,'authored body, alpha test and projection stay byte-identical') +T.check(adapted:find('gl_FragDepth = gl_FragCoord.z;',1,true)~=nil,'depth remains the rasterized source depth') +T.check(adapted:find('pokeportAlphaDepthEffect(color, tex, uv, screen)',1,true)~=nil,'effect executes exactly once through the wrapper') +T.eq(select(2,A.source(adapted)),false,'idempotent through layered compilers') +T.eq(select(2,A.source(pixel)),false,'ordinary 2D pixel program stays untouched') +T.check(select(2,A.source(pixel,true)),'separate projected vertex source admits pixel program') +for _,source in ipairs{ + vertex, + vertex..'\nvec4 effect(vec4 c,Image t,vec2 uv,vec2 sc){return Texel(t,uv)*c;}', + combined..'\n// gl_FragDepth is authored below\nvoid depth(){gl_FragDepth=0.5;}', + combined..'\nvoid depth(){gl_FragDepthEXT=0.5;}', + '#define POKEPORT_NATIVE_ALPHA_DEPTH 1\n'..combined, + 'void vertexmain(){}\n'..pixel, + '/*'..vertex..'*/\n'..pixel, + vertex..'\n// discard\nvec4 effect(vec4 c,Image t,vec2 uv,vec2 sc){return c;}', +}do + local output,rewritten=A.source(source) + T.eq(output,source,'unaffected/custom depth program keeps exact source') + T.eq(rewritten,false,'unaffected/custom depth program is not wrapped') +end +T.check(select(2,A.source('// POKEPORT_NATIVE_ALPHA_DEPTH gl_FragDepth\n'..combined)), + 'comments do not opt out of alpha depth safety') +local graphics,version,filesystem,thread=love.graphics,love.getVersion,love.filesystem,love.thread +for _,backend in ipairs{'OpenGL','Metal'}do + package.loaded['src.render.AlphaDepthCompat']=nil + local calls,selected,target,writes,throwDraw={},nil,nil,true,false + local methods={} + function methods:hasUniform()assert(not self.released,'released');return true end + function methods:send(name,value)assert(value~='bad','invalid native uniform');self.values[name]=value;return 'sent',nil,3 end + methods.sendColor=methods.send + function methods:release()self.released=true end + local function compiler(...) + local shader=setmetatable({values={}},{__index=methods}) + calls[#calls+1]={args={...},shader=shader};return shader + end + local options={defines={OWNED_VALUE=1}} + love.getVersion=function()return 12,0,0 end + love.filesystem={getInfo=function(path)if path=='cutout.glsl'then return{}end end, + read=function()return pixel end} + love.thread={newChannel=function()local held;return{ + push=function(_,v)held=v end,peek=function()return held end, + clear=function()held=nil end,release=function()end}end} + love.graphics={getRendererInfo=function()return backend end,newShader=compiler, + getShader=function()return selected end,setShader=function(s)selected=s end, + getCanvas=function()return target end,getDepthMode=function()return 'lequal',writes end, + getStackDepth=function()return 0 end,present=function()end, + draw=function()if throwDraw then error('native draw failed',0)end;return selected,nil,7 end, + validateShader=function()return true end} + local compat=require('src.render.AlphaDepthCompat') + T.eq(compat.install(),backend=='Metal',backend..' explicit backend scope') + T.eq(compat.fragmentDepthTarget(),false,backend..' unobservable target cannot select a depth variant') + local shader=love.graphics.newShader('cutout.glsl',vertex,options) + T.eq(shader,calls[1].shader,backend..' returns the real original Shader') + T.eq(calls[1].args[1],'cutout.glsl',backend..' original file-backed program untouched') + T.eq(calls[1].args[2],vertex,backend..' original projection body untouched') + T.eq(calls[1].args[3],options,backend..' exact compile options identity') + T.eq(#calls,backend=='Metal'and 2 or 1,backend..' bounded companion compilation') + if backend=='Metal'then + T.eq(compat.compileNative,compiler,'derivative compiler retains the exact preceding constructor') + T.check(compat.ownsShaderMethods(methods),'only the actual installed depth method owner is recognized') + local owned=methods.send;methods.send=function(...)return owned(...)end + T.eq(compat.ownsShaderMethods(methods),false,'a later foreign owner is not mistaken for an engine hook') + methods.send=owned;T.check(compat.ownsShaderMethods(methods)) + local late=calls[2].shader + T.check(calls[2].args[1]:find('gl_FragDepth = gl_FragCoord.z;',1,true)~=nil,'late program keeps rasterized depth') + T.eq(calls[2].args[2],vertex,'separate vertex source preserved') + T.eq(calls[2].args[3],options,'companion compile options identity') + local result={shader:send('gain',2)} + T.eq(result[1],'sent','native send return preserved');T.eq(result[3],3,'send nil-bearing return preserved') + T.eq(late.values.gain,2,'successful scalar upload mirrored eagerly') + local ok=pcall(shader.send,shader,'gain','bad') + T.eq(ok,false,'native validation failure propagated') + T.eq(late.values.gain,2,'rejected write never corrupts companion') + shader:sendColor('tint',{1,.5,.2}) + T.eq(late.values.tint,shader.values.tint,'texture/array/colour arguments forwarded directly without retained Lua snapshots') + selected=shader;target={depthstencil={{getFormat=function()return 'depth24'end}}} + T.eq(compat.fragmentDepthTarget(),true,'the shared variant seam uses the actual depth binding') + local actual,empty,last=love.graphics.draw() + T.eq(actual,late,'explicit depth attachment selects late alpha program') + T.eq(empty,nil,'draw nil-bearing return preserved');T.eq(last,7,'draw return arity retained') + T.eq(selected,shader,'original restored immediately after native draw') + target={getFormat=function()return 'rgba8'end} + T.eq(compat.fragmentDepthTarget(),false,'colour-only variants never declare fragment depth') + T.eq(love.graphics.draw(),shader,'colour-only target retains original even with stale depth-write state') + target=nil;T.eq(love.graphics.draw(),shader,'backbuffer without observable attachment retains original') + target={depthstencil={{getFormat=function()return 'stencil8'end}}} + T.eq(love.graphics.draw(),shader,'stencil-only attachment cannot accept fragment depth') + target={depthstencil={{getFormat=function()return 'depth24'end}}};writes=false + T.eq(compat.fragmentDepthTarget(),false,'a non-writing native pass selects the colour program') + T.eq(love.graphics.draw(),shader,'non-writing ghost/transparent pass retains original') + writes=true;throwDraw=true + local ok,err=pcall(love.graphics.draw) + T.eq(ok,false,'native draw failure propagated');T.eq(err,'native draw failed','native draw error retained') + T.eq(selected,shader,'draw error restores the original shader') + throwDraw=false;selected=nil;shader:release();love.graphics.present() + T.check(late.released,'companion retired after public release at an empty native stack') + T.eq(compat.install(),false,'single installed constructor wrapper') + end +end +love.graphics,love.getVersion,love.filesystem,love.thread=graphics,version,filesystem,thread +T.finish('Metal alpha-cutout late depth, exact native shader/target/lifetime contracts') diff --git a/tests/engine/shader_sampler_guard.lua b/tests/engine/shader_sampler_guard.lua new file mode 100644 index 000000000..e68e2b3ca --- /dev/null +++ b/tests/engine/shader_sampler_guard.lua @@ -0,0 +1,33 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local G=require('src.render.ShaderSamplerGuard') +local source=[[ +#pragma language glsl3 +vec4 position(mat4 m,vec4 v){return m*v;} +uniform Image lateTexture; +#ifdef PIXEL +uniform Image pixelOnly; +vec4 effect(vec4 c,Image t,vec2 uv,vec2 p){return Texel(pixelOnly,uv);} +#endif +]] +local out=G.rewrite(source,source,function()return true end) +T.check(out:sub(1,23)=='#pragma language glsl3\n','language pragma keeps its original position') +T.check(out:find('vec4 pokeportBranchVertexSample();',1,true)out:find('uniform Image lateTexture;',1,true),'the vertex helper body follows late sampler declarations') +T.check(out:find('vec4 pokeportBranchPixelSample(){',1,true)>out:find('uniform Image pixelOnly;',1,true),'the pixel helper follows its own stage declarations') +T.check(out:find('return m*v;',1,true),'authored vertex body remains exact') +T.check(out:find('return Texel(pixelOnly,uv);',1,true),'authored fragment body remains exact') +local vertex=assert(out:match('vec4 pokeportBranchVertexSample%(%){(.-)}')) +local pixel=assert(out:match('vec4 pokeportBranchPixelSample%(%){(.-)}')) +T.check(vertex:find('Texel(lateTexture,',1,true)~=nil);T.check(vertex:find('pixelOnly',1,true)==nil,'pixel-only bindings never leak into the vertex helper') +T.check(pixel:find('Texel(lateTexture,',1,true)~=nil);T.check(pixel:find('Texel(pixelOnly,',1,true)~=nil) +out=G.rewrite(source,source,function()return false end) +T.check(out:find('vec4 pokeportBranchVertexSample(){return vec4(0.0);}',1,true)~=nil,'inactive native samplers do not create new resource bindings') +T.check(out:find('vec4 pokeportBranchPixelSample(){return vec4(0.0);}',1,true)~=nil) +local multi='uniform Image live; void effect(){love_Canvases[2]=Texel(live,vec2(0.));}' +out=G.rewrite(multi,multi,function()return true end) +T.check(out:find('love_Canvases[2]=pokeportBranchSample;return;',1,true)~=nil,'the existing multiple-output guard remains intact') +for _,name in ipairs{G.uniform,'pokeportBranchVertexSample','pokeportBranchPixelSample'}do + T.check(not pcall(G.rewrite,source..'\nfloat '..name..';',source..'\nfloat '..name..';',function()return true end),'reserved helper collisions fall back to the native source') +end +T.finish('stage-scoped sampler retention after valid late declarations') diff --git a/tests/native_shader/main.lua b/tests/native_shader/main.lua index fa82e8d3a..301bc9873 100644 --- a/tests/native_shader/main.lua +++ b/tests/native_shader/main.lua @@ -75,6 +75,8 @@ local function adapterTests() local g=love.graphics local hasLegacy,legacy=pcall(require,'src.render.LegacyShaderCompat') if hasLegacy then legacy.install()end + local hasAlpha,alpha=pcall(require,'src.render.AlphaDepthCompat') + if hasAlpha then alpha.install()end if os.getenv('QA_SHADER_WRONG_COMPILER')=='1'then assert(hasLegacy,'projection negative requires actual canvas compatibility adapter') legacy.compileNative=g.newShader @@ -176,6 +178,80 @@ vec4 effect(vec4 c,Image t,vec2 uv,vec2 p){ g.setCanvas();g.setShader();g.present() check(S.status().sources==0 and S.status().variants==0,'empty native stack releases disabled programs and C leases') alias:release();reference:release();target:release() + local alpha=package.loaded['src.render.AlphaDepthCompat'] + if alpha and alpha.fragmentDepthTarget and alpha.compileNative then + local vertex=[[ +uniform mat4 sceneMatrix; +#ifdef VERTEX +vec4 independentPosition(mat4 supplied,vec4 vertex){return sceneMatrix*vertex;} +vec4 position(mat4 supplied,vec4 vertex){vec4 clip=independentPosition(supplied,vertex);clip.y=-clip.y;return clip;} +#endif +]] + local fragment=[[ +uniform float gate; +uniform Image ownedTexture; +#ifdef PIXEL +vec4 effect(vec4 c,Image t,vec2 uv,vec2 p){ + vec4 pixel=Texel(ownedTexture,uv);if(gate<.5)pixel.rgb=vec3(0.,0.,1.); + if(pixel.a<.5)discard; + DEPTH_WRITE + return pixel; +} +#endif +]] + local plain=vertex..fragment:gsub('DEPTH_WRITE','') + reference=g.newShader(plain) + -- Independent explicit-depth oracle, without the adapter's source helper. + local depthReference=g.newShader(vertex..fragment:gsub('DEPTH_WRITE','gl_FragDepth=gl_FragCoord.z;')) + candidate=g.newShader(plain..string.rep(' \n',32768)) + target=g.newCanvas(8,8,{dpiscale=1}) + local depth=g.newCanvas(8,8,{format='depth24stencil8',readable=true,dpiscale=1}) + local pixels=love.image.newImageData(2,1);pixels:setPixel(0,0,0,1,0,0);pixels:setPixel(1,0,0,1,0,1) + local cutout=g.newImage(pixels);pixels:release();cutout:setFilter('nearest','nearest') + local matrix={.25,0,0,-1,0,.25,0,-1,0,0,1,0,0,0,0,1} + send('send','sceneMatrix','row',matrix);send('send','gate',1);send('send','ownedTexture',cutout) + depthReference:send('sceneMatrix','row',matrix);depthReference:send('gate',1);depthReference:send('ownedTexture',cutout) + local behind=g.newShader(vertex..[[ +#ifdef PIXEL +vec4 effect(vec4 c,Image t,vec2 uv,vec2 p){return vec4(1.,0.,0.,1.);} +#endif +]]) + behind:send('sceneMatrix','row',matrix) + local beforeCompiled=S.status().compiled;local beforeCompanions=alpha.status().compiled + check(S.status().sources==1,'the native cutout source is actually admitted') + local function render(withDepth) + g.setCanvas(withDepth and{target,depthstencil=depth}or target);g.origin() + g.setBlendMode('replace','premultiplied');g.setColor(1,1,1,1);g.clear(0,0,0,0,0,1) + g.setDepthMode('always',withDepth) + check(alpha.fragmentDepthTarget()==withDepth,'actual native colour/depth attachments select the matching path') + g.setShader(withDepth and depthReference or reference);g.draw(cutout,0,0,0,2,8) + g.setShader(candidate);g.draw(cutout,4,0,0,2,8) + check(g.getShader()==candidate,'depth and colour variants retain original public identity') + if withDepth then + g.setDepthMode('less',true);g.setShader(behind);g.rectangle('fill',0,0,8,8) + end + g.setCanvas();g.setShader();g.setDepthMode('always',false) + local data=love.graphics.readbackTexture(target) + local same=true + for y=0,7 do for x=0,3 do + local a={data:getPixel(x,y)};local b={data:getPixel(x+4,y)} + for i=1,4 do if a[i]~=b[i]then same=false end end + end end + check(same,'target-specific variants match independent native pixels') + if withDepth then + local r,green,blue,a=data:getPixel(4,4);check(r==1 and green==0 and blue==0 and a==1,'discarded front texels leave the later rear geometry visible') + r,green,blue,a=data:getPixel(7,4);check(r==0 and green==1 and blue==0 and a==1,'opaque front texels retain depth occlusion') + end + data:release() + end + render(false);render(true);render(false) + for key,value in pairs(S.status())do print('DEPTH_VARIANT_STATUS',key,value)end + check(S.status().compiled==beforeCompiled+2,'one colour and one depth variant are reused across target changes') + check(S.status().depthUses>0,'the native depth variant is actually drawn') + check(alpha.status().compiled==beforeCompanions,'variants do not manufacture duplicate depth companions') + candidate:release();reference:release();depthReference:release();behind:release();cutout:release();target:release();depth:release();g.present() + check(S.status().sources==0,'depth variants retire at the actual native empty-stack boundary') + end -- Mutable native Data is a supported source API, but is intentionally not a -- replayable adapter value. Its original C send and original pixels survive. local dataCode=[[ From 192b495357bd6d747e4f1031d4cf0e60674eadd5 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 16:56:51 +0100 Subject: [PATCH 08/18] Verify live shader programs across lazy native depth ownership --- tests/native_shader/main.lua | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/tests/native_shader/main.lua b/tests/native_shader/main.lua index 301bc9873..4c56c41e7 100644 --- a/tests/native_shader/main.lua +++ b/tests/native_shader/main.lua @@ -180,6 +180,16 @@ vec4 effect(vec4 c,Image t,vec2 uv,vec2 p){ alias:release();reference:release();target:release() local alpha=package.loaded['src.render.AlphaDepthCompat'] if alpha and alpha.fragmentDepthTarget and alpha.compileNative then + -- Keep an earlier admitted program alive when the first cutout lazily + -- adds the native depth method owner to this shared Shader class. + local carryText=[[ +uniform float gate;uniform vec4 tint; +vec4 effect(vec4 c,Image t,vec2 uv,vec2 p){if(gate>.5)return tint;return vec4(1.)-tint;} +]] + local carried=g.newShader(carryText..string.rep(' \n',32768)) + local carryReference=g.newShader(carryText) + carried:send('gate',1);carried:send('tint',{.1,.2,.3,1}) + target=g.newCanvas(8,8,{dpiscale=1});g.setCanvas(target);g.setShader(carried);g.rectangle('fill',0,0,8,8);g.setCanvas();g.setShader() local vertex=[[ uniform mat4 sceneMatrix; #ifdef VERTEX @@ -204,7 +214,6 @@ vec4 effect(vec4 c,Image t,vec2 uv,vec2 p){ -- Independent explicit-depth oracle, without the adapter's source helper. local depthReference=g.newShader(vertex..fragment:gsub('DEPTH_WRITE','gl_FragDepth=gl_FragCoord.z;')) candidate=g.newShader(plain..string.rep(' \n',32768)) - target=g.newCanvas(8,8,{dpiscale=1}) local depth=g.newCanvas(8,8,{format='depth24stencil8',readable=true,dpiscale=1}) local pixels=love.image.newImageData(2,1);pixels:setPixel(0,0,0,1,0,0);pixels:setPixel(1,0,0,1,0,1) local cutout=g.newImage(pixels);pixels:release();cutout:setFilter('nearest','nearest') @@ -218,7 +227,7 @@ vec4 effect(vec4 c,Image t,vec2 uv,vec2 p){return vec4(1.,0.,0.,1.);} ]]) behind:send('sceneMatrix','row',matrix) local beforeCompiled=S.status().compiled;local beforeCompanions=alpha.status().compiled - check(S.status().sources==1,'the native cutout source is actually admitted') + check(S.status().sources==2,'the native cutout and preceding program remain admitted') local function render(withDepth) g.setCanvas(withDepth and{target,depthstencil=depth}or target);g.origin() g.setBlendMode('replace','premultiplied');g.setColor(1,1,1,1);g.clear(0,0,0,0,0,1) @@ -249,7 +258,14 @@ vec4 effect(vec4 c,Image t,vec2 uv,vec2 p){return vec4(1.,0.,0.,1.);} check(S.status().compiled==beforeCompiled+2,'one colour and one depth variant are reused across target changes') check(S.status().depthUses>0,'the native depth variant is actually drawn') check(alpha.status().compiled==beforeCompanions,'variants do not manufacture duplicate depth companions') - candidate:release();reference:release();depthReference:release();behind:release();cutout:release();target:release();depth:release();g.present() + local cutoutReference=reference;reference=carryReference + carryReference:send('gate',1);carryReference:send('tint',{.4,.2,.8,1});carried:send('tint',{.4,.2,.8,1}) + check(compare(carried),'an earlier live program retains exact mutable uniforms across the owned hook interposition') + carried:send('tint',{1,0,0,1});check(not compare(carried),'earlier-program pixel comparison detects a wrong-value control') + carried:send('tint',{.4,.2,.8,1});check(compare(carried),'earlier native variant remains reusable after the owned interposition') + check(S.status().unsupported==0,'the known native depth owner does not retire earlier programs') + reference=cutoutReference + candidate:release();reference:release();depthReference:release();behind:release();cutout:release();carried:release();carryReference:release();target:release();depth:release();g.present() check(S.status().sources==0,'depth variants retire at the actual native empty-stack boundary') end -- Mutable native Data is a supported source API, but is intentionally not a From 089d3851a9ee2886b28f1f1c679841d5d40e14ef Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 17:12:08 +0100 Subject: [PATCH 09/18] Run native depth and sampler source contracts in CI --- .github/workflows/native-depth-variants.yml | 29 +++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/native-depth-variants.yml diff --git a/.github/workflows/native-depth-variants.yml b/.github/workflows/native-depth-variants.yml new file mode 100644 index 000000000..e6db3914a --- /dev/null +++ b/.github/workflows/native-depth-variants.yml @@ -0,0 +1,29 @@ +name: Native depth shader variants +on: + pull_request: + paths: + - 'main.lua' + - 'src/render/AlphaDepthCompat.lua' + - 'src/render/ShaderSpecialization.lua' + - 'src/render/ShaderSamplerGuard.lua' + - 'tests/engine/alpha_depth_compat.lua' + - 'tests/engine/shader_sampler_guard.lua' + - 'tests/engine/shader_specialization_policy.lua' + - 'tests/native_shader/**' + - '.github/workflows/native-depth-variants.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + contracts: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - run: sudo apt-get update && sudo apt-get install -y luajit + - name: Native source guards and target ownership + run: | + luajit tests/engine/alpha_depth_compat.lua + luajit tests/engine/shader_sampler_guard.lua + luajit tests/engine/shader_branches.lua + luajit tests/engine/shader_specialization_policy.lua From 39a1b4e876dedd0173b2dce61114e9058165fb6e Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 17:19:14 +0100 Subject: [PATCH 10/18] Attest native shader predecessors without masking foreign value owners --- src/render/AlphaDepthCompat.lua | 7 ++++++- src/render/ShaderSpecialization.lua | 2 +- tests/engine/alpha_depth_compat.lua | 3 +++ tests/native_shader/main.lua | 20 +++++++++++++++++--- 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/render/AlphaDepthCompat.lua b/src/render/AlphaDepthCompat.lua index 493dee109..f05e827e6 100644 --- a/src/render/AlphaDepthCompat.lua +++ b/src/render/AlphaDepthCompat.lua @@ -93,7 +93,7 @@ end local function hook(shader) local methods=debug.getmetatable(shader).__index if hooks[methods]then return hooks[methods]end - local h={hasUniform=methods.hasUniform,release=methods.release,wrapped={}} + local h={hasUniform=methods.hasUniform,release=methods.release,send=methods.send,sendColor=methods.sendColor,wrapped={}} hooks[methods]=h for _,name in ipairs{'send','sendColor'}do local method=methods[name] @@ -119,6 +119,11 @@ function A.ownsShaderMethods(methods) return h and methods.send==h.wrapped.send and methods.sendColor==h.wrapped.sendColor and methods.release==h.wrapped.release and methods.hasUniform==h.hasUniform or false end +function A.followsShaderMethods(methods,prior,hasUniform) + local h=hooks[methods] + return A.ownsShaderMethods(methods)and h.send==prior.send and h.sendColor==prior.sendColor + and h.release==prior.release and h.hasUniform==hasUniform or false +end local function depthTarget() local target=raw.getCanvas() if type(target)~='table'then return false end diff --git a/src/render/ShaderSpecialization.lua b/src/render/ShaderSpecialization.lua index 002df3ebb..ebbddd348 100644 --- a/src/render/ShaderSpecialization.lua +++ b/src/render/ShaderSpecialization.lua @@ -129,7 +129,7 @@ local function hook(shader) -- The depth adapter hooks this native class lazily, on its first cutout. -- Rebase only that exact engine-owned interposition. Foreign owners keep -- the existing conservative fallback; old closures must forward once. - if not(alpha and alpha.ownsShaderMethods and alpha.ownsShaderMethods(methods))then return prior end + if not(alpha and alpha.followsShaderMethods and alpha.followsShaderMethods(methods,prior.wrapped,prior.hasUniform))then return prior end end local h={send=methods.send,sendColor=methods.sendColor,release=methods.release,hasUniform=methods.hasUniform,methods=methods,wrapped={}} hooks[methods]=h diff --git a/tests/engine/alpha_depth_compat.lua b/tests/engine/alpha_depth_compat.lua index 1daeafc6a..d2a2acb72 100644 --- a/tests/engine/alpha_depth_compat.lua +++ b/tests/engine/alpha_depth_compat.lua @@ -38,6 +38,7 @@ for _,backend in ipairs{'OpenGL','Metal'}do function methods:send(name,value)assert(value~='bad','invalid native uniform');self.values[name]=value;return 'sent',nil,3 end methods.sendColor=methods.send function methods:release()self.released=true end + local preceding={send=methods.send,sendColor=methods.sendColor,release=methods.release};local precedingHas=methods.hasUniform local function compiler(...) local shader=setmetatable({values={}},{__index=methods}) calls[#calls+1]={args={...},shader=shader};return shader @@ -67,6 +68,8 @@ for _,backend in ipairs{'OpenGL','Metal'}do if backend=='Metal'then T.eq(compat.compileNative,compiler,'derivative compiler retains the exact preceding constructor') T.check(compat.ownsShaderMethods(methods),'only the actual installed depth method owner is recognized') + T.check(compat.followsShaderMethods(methods,preceding,precedingHas),'the exact predecessor is attested') + T.eq(compat.followsShaderMethods(methods,{send=function()end,sendColor=preceding.sendColor,release=preceding.release},precedingHas),false,'an intervening foreign value owner cannot be hidden by a later engine hook') local owned=methods.send;methods.send=function(...)return owned(...)end T.eq(compat.ownsShaderMethods(methods),false,'a later foreign owner is not mistaken for an engine hook') methods.send=owned;T.check(compat.ownsShaderMethods(methods)) diff --git a/tests/native_shader/main.lua b/tests/native_shader/main.lua index 4c56c41e7..c4a62dfde 100644 --- a/tests/native_shader/main.lua +++ b/tests/native_shader/main.lua @@ -190,6 +190,14 @@ vec4 effect(vec4 c,Image t,vec2 uv,vec2 p){if(gate>.5)return tint;return vec4(1. local carryReference=g.newShader(carryText) carried:send('gate',1);carried:send('tint',{.1,.2,.3,1}) target=g.newCanvas(8,8,{dpiscale=1});g.setCanvas(target);g.setShader(carried);g.rectangle('fill',0,0,8,8);g.setCanvas();g.setShader() + local transformed=os.getenv('QA_SHADER_FOREIGN_BEFORE_DEPTH')=='1' + if transformed then + local methods=debug.getmetatable(carried).__index;local preceding=methods.send + methods.send=function(self,name,value,...) + if name=='tint'and type(value)=='table'then value={value[1]*.5,value[2]*.5,value[3]*.5,value[4]}end + return preceding(self,name,value,...) + end + end local vertex=[[ uniform mat4 sceneMatrix; #ifdef VERTEX @@ -255,18 +263,24 @@ vec4 effect(vec4 c,Image t,vec2 uv,vec2 p){return vec4(1.,0.,0.,1.);} end render(false);render(true);render(false) for key,value in pairs(S.status())do print('DEPTH_VARIANT_STATUS',key,value)end - check(S.status().compiled==beforeCompiled+2,'one colour and one depth variant are reused across target changes') - check(S.status().depthUses>0,'the native depth variant is actually drawn') + if transformed then + check(S.status().compiled==beforeCompiled,'an intervening foreign owner keeps its original native programs') + check(S.status().unsupported>0,'a later native hook cannot hide the earlier foreign value owner') + else + check(S.status().compiled==beforeCompiled+2,'one colour and one depth variant are reused across target changes') + check(S.status().depthUses>0,'the native depth variant is actually drawn') + end check(alpha.status().compiled==beforeCompanions,'variants do not manufacture duplicate depth companions') local cutoutReference=reference;reference=carryReference carryReference:send('gate',1);carryReference:send('tint',{.4,.2,.8,1});carried:send('tint',{.4,.2,.8,1}) check(compare(carried),'an earlier live program retains exact mutable uniforms across the owned hook interposition') carried:send('tint',{1,0,0,1});check(not compare(carried),'earlier-program pixel comparison detects a wrong-value control') carried:send('tint',{.4,.2,.8,1});check(compare(carried),'earlier native variant remains reusable after the owned interposition') - check(S.status().unsupported==0,'the known native depth owner does not retire earlier programs') + check(transformed or S.status().unsupported==0,'the known native depth owner does not retire earlier programs') reference=cutoutReference candidate:release();reference:release();depthReference:release();behind:release();cutout:release();carried:release();carryReference:release();target:release();depth:release();g.present() check(S.status().sources==0,'depth variants retire at the actual native empty-stack boundary') + if transformed then print('SHADER_ADAPTER_PASS',count);return end end -- Mutable native Data is a supported source API, but is intentionally not a -- replayable adapter value. Its original C send and original pixels survive. From ef859692efb434cf50bd22c7d47166e27060a757 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 17:22:48 +0100 Subject: [PATCH 11/18] Check foreign uniform transforms against independent native pixels --- tests/native_shader/main.lua | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/native_shader/main.lua b/tests/native_shader/main.lua index c4a62dfde..00a8dd20a 100644 --- a/tests/native_shader/main.lua +++ b/tests/native_shader/main.lua @@ -263,20 +263,20 @@ vec4 effect(vec4 c,Image t,vec2 uv,vec2 p){return vec4(1.,0.,0.,1.);} end render(false);render(true);render(false) for key,value in pairs(S.status())do print('DEPTH_VARIANT_STATUS',key,value)end + local cutoutReference=reference;reference=carryReference + carryReference:send('gate',1);carryReference:send('tint',{.4,.2,.8,1});carried:send('tint',{.4,.2,.8,1}) + check(compare(carried),'an earlier live program retains exact mutable uniforms across the owned hook interposition') + carried:send('tint',{1,0,0,1});check(not compare(carried),'earlier-program pixel comparison detects a wrong-value control') + carried:send('tint',{.4,.2,.8,1});check(compare(carried),'earlier native variant remains reusable after the owned interposition') if transformed then check(S.status().compiled==beforeCompiled,'an intervening foreign owner keeps its original native programs') - check(S.status().unsupported>0,'a later native hook cannot hide the earlier foreign value owner') + check(S.status().unsupported>=2,'a later native hook cannot hide the foreign value owner of either live program') else check(S.status().compiled==beforeCompiled+2,'one colour and one depth variant are reused across target changes') check(S.status().depthUses>0,'the native depth variant is actually drawn') + check(S.status().unsupported==0,'the known native depth owner does not retire earlier programs') end check(alpha.status().compiled==beforeCompanions,'variants do not manufacture duplicate depth companions') - local cutoutReference=reference;reference=carryReference - carryReference:send('gate',1);carryReference:send('tint',{.4,.2,.8,1});carried:send('tint',{.4,.2,.8,1}) - check(compare(carried),'an earlier live program retains exact mutable uniforms across the owned hook interposition') - carried:send('tint',{1,0,0,1});check(not compare(carried),'earlier-program pixel comparison detects a wrong-value control') - carried:send('tint',{.4,.2,.8,1});check(compare(carried),'earlier native variant remains reusable after the owned interposition') - check(transformed or S.status().unsupported==0,'the known native depth owner does not retire earlier programs') reference=cutoutReference candidate:release();reference:release();depthReference:release();behind:release();cutout:release();carried:release();carryReference:release();target:release();depth:release();g.present() check(S.status().sources==0,'depth variants retire at the actual native empty-stack boundary') From 92d38e020744a9838e65271c92a72358920b4c51 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 17:36:49 +0100 Subject: [PATCH 12/18] Document offered native target-specific shader contracts --- docs/qa/native-depth-shader-variants.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 docs/qa/native-depth-shader-variants.md diff --git a/docs/qa/native-depth-shader-variants.md b/docs/qa/native-depth-shader-variants.md new file mode 100644 index 000000000..e4802a7eb --- /dev/null +++ b/docs/qa/native-depth-shader-variants.md @@ -0,0 +1,25 @@ +# Offered native target-specific shader compiler + +Depends on the offered Metal branch compiler (#337). The actual older public +component now includes the native alpha-depth adapter and explicit install +before specialization; no private rendering controllers are imported. + +Projected cutouts use separate bounded colour and writable-depth variants, +compiled through the exact preceding native constructor. No duplicate late +companion is created. Public userdata, uniform values, return values, resource +lifetime and native stack owners are retained. Lazy native method interposition +is admitted only after exact predecessor attestation; foreign value owners keep +the original-program fallback. Valid late sampler declarations are retained. + +The actual offered component passes focused CPU contracts69/16/48/40 for +alpha depth, sampler declaration order, planning and platform policy. Earlier +real Metal runs passed96 default checks and the fork implementation's foreign +owner fixture passed79; the final offered-source GPU rerun is recorded when +terminal. An intentionally weaker owned-host predicate fails the foreign-owner +ownership assertion, while preceding pixel comparisons pass. No60FPS, complete +mod, current upstream-main composition or release qualification is claimed. + +Contributions by Roxas2712 — https://github.com/Roxas2712. Original additions +are expressly offered under LICENSE-ROXAS2712.md, including Cedric/UNDERdecoded +integration/distribution permission with visible credit. Existing upstream and +third-party licences and rights remain intact. From 68241066d411745fd44b38f80d8b600518c911ff Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 17:50:42 +0100 Subject: [PATCH 13/18] Record final offered-source Metal contract --- docs/qa/native-depth-shader-variants.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/qa/native-depth-shader-variants.md b/docs/qa/native-depth-shader-variants.md index e4802a7eb..03b923658 100644 --- a/docs/qa/native-depth-shader-variants.md +++ b/docs/qa/native-depth-shader-variants.md @@ -11,12 +11,10 @@ lifetime and native stack owners are retained. Lazy native method interposition is admitted only after exact predecessor attestation; foreign value owners keep the original-program fallback. Valid late sampler declarations are retained. -The actual offered component passes focused CPU contracts69/16/48/40 for -alpha depth, sampler declaration order, planning and platform policy. Earlier -real Metal runs passed96 default checks and the fork implementation's foreign -owner fixture passed79; the final offered-source GPU rerun is recorded when -terminal. An intentionally weaker owned-host predicate fails the foreign-owner -ownership assertion, while preceding pixel comparisons pass. No60FPS, complete +The actual offered component passes focused CPU contracts 69/16/48/40 for +alpha depth, sampler declaration order, planning and platform policy. The final offered-source Metal contract passes 96 checks (receipt +ab3c28b2f2); the fork implementation's foreign-owner fixture passes 79. An intentionally weaker owned-host predicate fails the foreign-owner +ownership assertion, while preceding pixel comparisons pass. No 60 FPS, complete mod, current upstream-main composition or release qualification is claimed. Contributions by Roxas2712 — https://github.com/Roxas2712. Original additions From fcaecd13fd2b9eac6ec644f4e34d3d4dbee94e61 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 18:18:29 +0100 Subject: [PATCH 14/18] Keep macro-local uniform shadows on their native shader path --- src/render/ShaderBranches.lua | 26 ++++++++++++++++++++++---- tests/engine/shader_branches.lua | 12 ++++++++++++ tests/native_shader/main.lua | 20 ++++++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/render/ShaderBranches.lua b/src/render/ShaderBranches.lua index 61371cffd..c3cf334b3 100644 --- a/src/render/ShaderBranches.lua +++ b/src/render/ShaderBranches.lua @@ -31,7 +31,10 @@ end function B.plan(source) if type(source)~='string'then return nil,'source is not text'end local clean=uncomment(source);if not clean then return nil,'unfinished comment'end + if clean:find('##',1,true)then return nil,'token-pasted names are not qualified'end local code=withoutDirectives(clean) + local macros={} + for name in clean:gmatch('#%s*define%s+([%a_][%w_]*)')do macros[name]=true end local declarations,names={},{} local at=1 while true do @@ -60,8 +63,8 @@ function B.plan(source) end -- Uniform arithmetic remains dynamic. Reject possible shadow declarations, -- member names and macros before substituting any read in an if condition. - -- A comma may introduce another local declaration; conservatively reject - -- that spelling even when it is merely a function argument. + -- Commas may introduce local declarators; retain the conservative guard. + -- Macro arguments are also unknown: expansion may declare shadow names. local pieces,previous={},1 for _,r in ipairs(declarations)do pieces[#pieces+1]=clean:sub(previous,r[1]-1) @@ -73,9 +76,24 @@ function B.plan(source) for _,r in ipairs(conditions)do for name in clean:sub(r[1],r[2]):gmatch('%f[%w_][%w_]+%f[^%w_]')do used[name]=true end end - local rejected={};local pos=1 + local rejected={};local pos,scanned=1,1 + local parents,macroDepth={},0 while true do local a,b=outside:find('%f[%w_][%w_]+%f[^%w_]',pos);if not a then break end + for i=scanned,a-1 do + local char=code:sub(i,i) + if char=='('then + local last=i-1;while last>0 and code:sub(last,last):match('%s')do last=last-1 end + local first=last;while first>0 and code:sub(first,first):match('[%w_]')do first=first-1 end + local callee=code:sub(first+1,last) + local row={macro=macros[callee]or false} + parents[#parents+1]=row;if row.macro then macroDepth=macroDepth+1 end + elseif char==')'and #parents>0 then + if parents[#parents].macro then macroDepth=macroDepth-1 end + parents[#parents]=nil + end + end + scanned=b+1 local name=outside:sub(a,b) if names[name]and not rejected[name]then -- Inspect the predecessor of this actual token. Searching an unanchored @@ -84,7 +102,7 @@ function B.plan(source) local before=a-1 while before>0 and outside:sub(before,before):match('%s')do before=before-1 end local predecessor=outside:sub(before,before) - if predecessor:match('[,%.]')or before0 or before.5){}', + 'for(float x=0.0,gate=0.0;x<1.0;x++){if(gate>.5){}}', + 'float named(float x,float gate){if(gate>.5){}return gate;}', + '#define DECL(a,b) float a,b;\nvoid f(){DECL(other,gate);if(gate>.5){}}', + '#define DECL(a,b) float a,b;\nvoid f(){DECL(gate,other);if(gate>.5){}}', + '#define DECL(a,b) float a,b;\nvoid f(){DECL(other,nested(gate));if(gate>.5){}}', +}do + local p=assert(B.plan('uniform float gate;\n'..body)) + T.eq(#p.order,0,'declarations, loop headers and macro expansion retain the original branch') +end +T.eq(B.plan('#define LOCAL(a,b) float a##b;\nuniform float gate;void f(){LOCAL(ga,te);if(gate>.5){}}'),nil,'token-pasted shadow names cannot be proven') T.check(not pcall(B.rewrite,plan,{enabled='malicious code'}),'only qualified literal bytes can enter output') local large,expected={},{} for i=1,64 do diff --git a/tests/native_shader/main.lua b/tests/native_shader/main.lua index 00a8dd20a..6fc1b2eb4 100644 --- a/tests/native_shader/main.lua +++ b/tests/native_shader/main.lua @@ -323,6 +323,26 @@ vec4 effect(vec4 c,Image t,vec2 uv,vec2 p){if(gate>.5)return tint;return vec4(1. check(exact and actual==candidate,'late foreign send owner falls back to original values and pixels') methods.send=ownedSend;g.setShader();g.present() reference:release();candidate:release();target:release() + g.present() + -- A macro may declare a local with the uniform's spelling. Its first + -- argument has no preceding type/comma, so a token-only guard misses it. + local macroCode=[[ +uniform float gate; +float globalValue(){float value=gate*.25;return value;} +#define DECLARE_LOCAL(a,b) float a=0.0,b=1.0 +vec4 effect(vec4 c,Image t,vec2 uv,vec2 p){ + vec4 pixel=vec4(globalValue(),0.0,0.0,1.0); + DECLARE_LOCAL(gate,other); + if(gate>.5){pixel.g=.5;} + return pixel; +} +]] + reference=g.newShader(macroCode);candidate=g.newShader(macroCode..string.rep(' \n',32768)) + target=g.newCanvas(8,8,{dpiscale=1}) + send('send','gate',0);check(compare(),'macro-local shadow preserves independent native pixels at global zero') + send('send','gate',1);check(compare(),'macro-local shadow preserves independent native pixels at global one') + check(S.status().sources==0,'macro-local declarations retain the original native program') + reference:release();candidate:release();target:release();g.present() check(g.getStackDepth()==0,'native adapter fixture leaves no graphics scope') print('SHADER_ADAPTER_PASS',count) end From 57fa2ef7e10483a62aa3195aebc8178fa4459043 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 18:22:00 +0100 Subject: [PATCH 15/18] Document offered macro-shadow pixel protection --- docs/qa/native-shader-macro-scopes.md | 36 +++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 docs/qa/native-shader-macro-scopes.md diff --git a/docs/qa/native-shader-macro-scopes.md b/docs/qa/native-shader-macro-scopes.md new file mode 100644 index 000000000..d6bdfbb02 --- /dev/null +++ b/docs/qa/native-shader-macro-scopes.md @@ -0,0 +1,36 @@ +# Native shader macro-local shadows + +A shader macro can declare a local variable with the same spelling as a +uniform. Substituting the later local branch with the global uniform value +changes pixels. Track nested macro invocation arguments and preserve their +original native program. Token-pasted identifiers are also unqualified. +The existing conservative treatment of comma declarators remains unchanged. + +Qualification: + +- The actually offered public component passes CPU contracts: + branch planner 55, alpha depth 69, sampler guard 16, platform policy 40 and + the fork canvas adapter 128 is separately checked. +- Real LOVE12 Metal on Apple M3 passes 101 checks, including independent + reference pixels with a live global uniform and a macro-declared local. +- An owned host with exact earlier planner 7727c172 passes global-zero pixels + but fails the global-one pixel comparison. The negative fixture keeps the + current test and native compiler; only the planner is replaced. +- The two actual world-program plans, 98669 and 96404 bytes, retain 16/15 flags + and 273/266 conditions. Previously qualified rewrite bytes match the prior + implementation for all-zero, all-one and mixed values. Private shader + source is retained only in owned QA; it is not published. + +This is a pixel-correctness fix, without 60 FPS acceptance. The current real +rainy Third-Person scene still has a measured 3133.971 ms cold compilation +spike and 59.712 ms mean warm frames on the preceding production source. +All 252 fork native CPU groups passed there. This macro change preserves the +actual world-program output; native Metal compilation and 5370 indexed draws +remain performance blockers. Failed broader optimization experiments are +excluded from this offered change. No private mod or player profile is edited. + +Contributions by Roxas2712, under LICENSE-ROXAS2712.md. Other authors' existing +licences and rights remain in effect. + +Depends on the offered native compiler/depth stack (#337, #388). Current +upstream-main composition and complete release qualification remain open. From f6e87f37242ae22aa0a8a4b07c016bea00e93c59 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 18:33:14 +0100 Subject: [PATCH 16/18] Record actual offered-source native macro pixels --- docs/qa/native-shader-macro-scopes.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/qa/native-shader-macro-scopes.md b/docs/qa/native-shader-macro-scopes.md index d6bdfbb02..5e71b7ee0 100644 --- a/docs/qa/native-shader-macro-scopes.md +++ b/docs/qa/native-shader-macro-scopes.md @@ -34,3 +34,6 @@ licences and rights remain in effect. Depends on the offered native compiler/depth stack (#337, #388). Current upstream-main composition and complete release qualification remain open. + +The actual offered source fcaecd13 also passes all 101 native Metal checks +(receipt 7f7a699ac9); this is separate from the fork run d01eccac34. From 7b140d5821c2e1728b746b1d7c828e7286862662 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 18:42:37 +0100 Subject: [PATCH 17/18] Keep observed intermediate shader floats dynamic at later endpoints --- src/render/ShaderSpecialization.lua | 13 ++++++++++--- tests/native_shader/main.lua | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/render/ShaderSpecialization.lua b/src/render/ShaderSpecialization.lua index ebbddd348..bec8609c6 100644 --- a/src/render/ShaderSpecialization.lua +++ b/src/render/ShaderSpecialization.lua @@ -6,7 +6,7 @@ local Branches=require('src.render.ShaderBranches') local Guard=require('src.render.ShaderSamplerGuard') local S={} local installed=false -local stats={sources=0,variants=0,compiled=0,uses=0,depthUses=0,depthCompiled=0,fallbacks=0,failures=0,unsupported=0,compileSeconds=0} +local stats={sources=0,variants=0,compiled=0,uses=0,depthUses=0,depthCompiled=0,fallbacks=0,failures=0,unsupported=0,compileSeconds=0,continuousUniforms=0} local limits={sources=16,sourceBytes=2*1024*1024,minBytes=65536,maxBytes=262144,variants=64,perSource=16,uniforms=256,arguments=256,leaves=32768} local originals=setmetatable({},{__mode='k'}) local variants={} @@ -102,7 +102,14 @@ local function remember(record,method,args) if record.leaves>limits.leaves then error('uniform bank cap')end bank[#bank+1]=row if record.plan.kinds[name]then - local literal=row.n==1 and Branches.literal(record.plan.kinds[name],args[2])or nil + -- A float observed between endpoints remains dynamic thereafter. + -- Keep later endpoints dynamic too, so a completed fade/camera tween does + -- not synchronously manufacture another endpoint-specific GPU program. + if record.plan.kinds[name]=='float'and row.n==1 and type(args[2])=='number' + and args[2]>0 and args[2]<1 and not record.continuous[name]then + record.continuous[name]=true;stats.continuousUniforms=stats.continuousUniforms+1 + end + local literal=not record.continuous[name]and row.n==1 and Branches.literal(record.plan.kinds[name],args[2])or nil if record.scalars[name]~=literal then record.scalars[name]=literal;record.scalarRevision=record.scalarRevision+1 end end -- Eager writes keep existing variant textures current, release their old C @@ -242,7 +249,7 @@ local function admit(shader,source,second,third,n) local plan=Branches.plan(source) if not plan or #plan.order==0 or plan.clean:find('void%s+[vp]ertexmain%s*%(')or plan.clean:find('void%s+pixelmain%s*%(')then return end local h=hook(shader);local lease=love.thread.newChannel();lease:push(shader) - local record={plan=plan,lease=lease,hook=h,aliases=setmetatable({},{__mode='k'}),variants={},bank={},scalars={},uniformCount=0,leaves=0,count=0,scalarRevision=0,bankRevision=0,gamma=love.graphics.isGammaCorrect and love.graphics.isGammaCorrect()} + local record={plan=plan,lease=lease,hook=h,aliases=setmetatable({},{__mode='k'}),variants={},bank={},scalars={},continuous={},uniformCount=0,leaves=0,count=0,scalarRevision=0,bankRevision=0,gamma=love.graphics.isGammaCorrect and love.graphics.isGammaCorrect()} local alpha=package.loaded['src.render.AlphaDepthCompat'] if alpha and type(alpha.compileNative)=='function'and type(alpha.fragmentDepthTarget)=='function'then local _,eligible=alpha.source(source) diff --git a/tests/native_shader/main.lua b/tests/native_shader/main.lua index 6fc1b2eb4..00f19d9c7 100644 --- a/tests/native_shader/main.lua +++ b/tests/native_shader/main.lua @@ -343,6 +343,32 @@ vec4 effect(vec4 c,Image t,vec2 uv,vec2 p){ send('send','gate',1);check(compare(),'macro-local shadow preserves independent native pixels at global one') check(S.status().sources==0,'macro-local declarations retain the original native program') reference:release();candidate:release();target:release();g.present() + local transitionCode=[[ +uniform float progress;uniform bool toggle; +vec4 effect(vec4 c,Image t,vec2 uv,vec2 p){ + float scaled=progress*.125;vec4 pixel=vec4(scaled,0.0,0.0,1.0); + if(progress>.5){pixel.r+=.5;} + if(toggle){pixel.g=.5;} + return pixel; +} +]] + local compiledBefore=S.status().compiled;local continuousBefore=S.status().continuousUniforms or 0 + reference=g.newShader(transitionCode);candidate=g.newShader(transitionCode..string.rep(' \n',32768)) + target=g.newCanvas(8,8,{dpiscale=1}) + send('send','toggle',false);send('send','progress',0) + check(compare(),'initial transition endpoint retains exact native pixels') + check(S.status().compiled==compiledBefore+1,'initial endpoint is independently optimized') + send('send','progress',.25);check(compare(),'interior float retains its original dynamic arithmetic and branch') + local dynamicCompiled=S.status().compiled + send('send','progress',1);check(compare(),'completed transition retains independent native pixels') + check(S.status().compiled==dynamicCompiled,'endpoints of an observed continuous float do not manufacture another program') + send('send','progress',0);check(compare(),'returning transition reuses the dynamic native program') + check(S.status().compiled==dynamicCompiled,'repeated transition endpoint reuses the existing program') + send('send','toggle',true);check(compare(),'independent boolean flags remain independently optimized') + check(S.status().compiled==dynamicCompiled+1,'continuous float does not disable unrelated boolean optimization') + check(S.status().continuousUniforms==continuousBefore+1,'only the observed interior float is retained as continuous') + reference:release();candidate:release();target:release();g.present() + check(S.status().sources==0 and S.status().variants==0,'transition source and derivatives retire at the native boundary') check(g.getStackDepth()==0,'native adapter fixture leaves no graphics scope') print('SHADER_ADAPTER_PASS',count) end From 6942bef1a83d20add38d811c2555c7edfbffbf26 Mon Sep 17 00:00:00 2001 From: Roxas2712 Date: Wed, 28 Oct 2026 19:10:39 +0100 Subject: [PATCH 18/18] Document offered native shader transition cache --- docs/qa/native-continuous-shader-uniforms.md | 45 ++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 docs/qa/native-continuous-shader-uniforms.md diff --git a/docs/qa/native-continuous-shader-uniforms.md b/docs/qa/native-continuous-shader-uniforms.md new file mode 100644 index 000000000..c1b1d76f7 --- /dev/null +++ b/docs/qa/native-continuous-shader-uniforms.md @@ -0,0 +1,45 @@ +# Reuse dynamic shader programs at interpolation endpoints + +Keep a qualified scalar float dynamic after a successful native write has +actually supplied a value strictly between 0 and 1. Later endpoints reuse that +runtime branch instead of compiling another endpoint-specialized program. +Independent bool flags still specialize. Original uniforms, arithmetic, native +send returns, texture ownership, graphics stacks and release remain intact. +Integer-valued modes outside the interval keep their prior treatment. + +Qualification: + +- The fork and actually offered public component each pass 118 real Metal + checks on LOVE12/Apple M3. These include exact reference pixels across + 0 -> .25 -> 1 -> 0, an independent bool toggle, matrix/array updates, + texture Lua release, native C stack lifetime, cutouts and macro shadows. +- The exact earlier native compiler policy in an owned control host produces + the expected additional program at the endpoint. Its pixel comparisons + pass; this counterexample concerns extra compilation, not incorrect pixels. +- The unchanged private consumer bddb51819c uses 8 observed continuous uniforms. + In its real High Third-Person/rain view, the adapter retains 14 variants + throughout 120 sampled plus 120 unprofiled warm frames. Its recorded compiler + time stays 1.808914 seconds throughout those windows. The preceding policy + grows from 15 to 21 variants and 5.527151 to 7.259581 recorded compiler seconds. +- In the matched sampled window the maximum drops from 3133.971 to 137.913 ms. + Unprofiled warm mean is 56.532 ms (~17.7 FPS), draw 49.565 ms, update 5.151 ms; + all 120 rows have distinct consecutive renderer ticks. The preceding warm + mean is 59.712 ms. There are still 5368 indexed native draws and 9 mirror + planes at 512x3456; reflection coverage remains 436255 pixels. This remains + a throughput blocker. Startup/map-change stalls outside these windows + require separate qualification; 60 FPS and OS-window acceptance remain open. +- Source 15dc92fc was used for that real consumer comparison. Current fork + source 42abe561 includes main 1658cb05 with unchanged shader files and passes + eight focused groups, including the new native RS option contracts 38/148. + The preceding main a8833b35 combination passed all 252 native CPU groups. + +Receipts: root Metal 5259b88625, offered Metal 9781e462f1, old-policy +counterexample 0f517b41cc, real consumer 40b62271cc, combined-main CPU 3683346023. +Quality and private source remain unchanged; only owned QA profiles are used. +The broader dead-body and argument-admission experiments are excluded. + +Contributions by Roxas2712 under LICENSE-ROXAS2712.md; existing upstream and +third-party licences and rights remain intact. + +Depends on the offered compiler/depth/macro stack (#337, #388, #389). Current +upstream-main composition and complete release qualification remain open.