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 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-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. diff --git a/docs/qa/native-depth-shader-variants.md b/docs/qa/native-depth-shader-variants.md new file mode 100644 index 000000000..03b923658 --- /dev/null +++ b/docs/qa/native-depth-shader-variants.md @@ -0,0 +1,23 @@ +# 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 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 +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. 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/docs/qa/native-shader-macro-scopes.md b/docs/qa/native-shader-macro-scopes.md new file mode 100644 index 000000000..5e71b7ee0 --- /dev/null +++ b/docs/qa/native-shader-macro-scopes.md @@ -0,0 +1,39 @@ +# 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. + +The actual offered source fcaecd13 also passes all 101 native Metal checks +(receipt 7f7a699ac9); this is separate from the fork run d01eccac34. diff --git a/main.lua b/main.lua index cf453d89d..34c57bdbb 100644 --- a/main.lua +++ b/main.lua @@ -499,6 +499,8 @@ 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) -- 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/AlphaDepthCompat.lua b/src/render/AlphaDepthCompat.lua new file mode 100644 index 000000000..f05e827e6 --- /dev/null +++ b/src/render/AlphaDepthCompat.lua @@ -0,0 +1,209 @@ +-- 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,send=methods.send,sendColor=methods.sendColor,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 +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 + 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/ShaderBranches.lua b/src/render/ShaderBranches.lua new file mode 100644 index 000000000..c3cf334b3 --- /dev/null +++ b/src/render/ShaderBranches.lua @@ -0,0 +1,154 @@ +-- 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 + 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 + 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. + -- 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) + 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,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 + -- 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 macroDepth>0 or before0 and table.concat(samples,'+')or'vec4(0.0)' + end + -- 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 + 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=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 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 pokeportBranchVertexSample();\n#endif\n' + end,1) + 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 new file mode 100644 index 000000000..bec8609c6 --- /dev/null +++ b/src/render/ShaderSpecialization.lua @@ -0,0 +1,299 @@ +-- 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,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={} +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 + -- 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 + -- 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 + 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.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 + 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 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 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,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. + 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) + 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 + 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,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) + 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 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==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=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 + 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) + 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={},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) + 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 +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/alpha_depth_compat.lua b/tests/engine/alpha_depth_compat.lua new file mode 100644 index 000000000..d2a2acb72 --- /dev/null +++ b/tests/engine/alpha_depth_compat.lua @@ -0,0 +1,113 @@ +-- 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 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 + 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') + 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)) + 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_branches.lua b/tests/engine/shader_branches.lua new file mode 100644 index 000000000..5f297b2bd --- /dev/null +++ b/tests/engine/shader_branches.lua @@ -0,0 +1,94 @@ +-- 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) +for _,body in ipairs{ + 'float x=0.0,gate=1.0;if(gate>.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 + 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_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/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..00f19d9c7 --- /dev/null +++ b/tests/native_shader/main.lua @@ -0,0 +1,383 @@ +-- 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 + 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 + 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() + 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 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 +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)) + 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==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) + 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 + 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>=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') + 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. + 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() + 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() + 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 +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