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..e2b3d0f40 --- /dev/null +++ b/docs/metal-shader-branches.md @@ -0,0 +1,105 @@ +# 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 `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. + +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 | 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 +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 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. +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. 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. 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 + +`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/docs/qa/native-foundation/shader-planning.md b/docs/qa/native-foundation/shader-planning.md new file mode 100644 index 000000000..c9a93ca5b --- /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 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. + +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. 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..61371cffd --- /dev/null +++ b/src/render/ShaderBranches.lua @@ -0,0 +1,136 @@ +-- 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 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 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 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 before0 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..afeda2146 --- /dev/null +++ b/tests/engine/shader_branches.lua @@ -0,0 +1,82 @@ +-- 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') +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') 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