diff --git a/LICENSE-ROXAS2712.md b/LICENSE-ROXAS2712.md new file mode 100644 index 000000000..8931eb41f --- /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, third-party engine 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/frame-pacing.md b/docs/frame-pacing.md new file mode 100644 index 000000000..d3b291cf2 --- /dev/null +++ b/docs/frame-pacing.md @@ -0,0 +1,47 @@ +# P1: VSync and the software deadline + +The run loop records whether it actually presented a frame. After presentation, +`FrameCap.pace` skips the software wait when VSync is active and the FPS cap is +at least the reported display refresh rate. Without presentation, with VSync +off, with an unknown rate, or with a lower FPS cap, the existing carried +deadline, one-millisecond sleep chunks and stall re-anchor remain active. + +The default-on rollback switch is independent of all other performance work: + +```lua +require("src.core.FrameCap").vsyncPacing = false +``` + +Display flags are sampled at most once per second to avoid allocating their +table each frame. Provider/VSync changes invalidate that sample; other display +rate changes are observed on the next periodic sample. Existing option values, +simulation timing, mod interfaces and callback call sites are unchanged. + +`frame pacing: sleep` in the existing F11 FrameProfile measures the helper, +including its software wait. `present (GPU wait / vsync)` retains its original +scope. Removing a redundant software wait can move wall time into present +without changing FPS. + +The owned-window probe runs the real helper and FrameProfile with the switch +off/on, each after 120 warm-up frames and over 120 measured frames. Run through +the guarded launcher: + +```sh +python3 tools/check_frame_pacing.py --love /path/to/love --report /path/to/owned/qa/report.json +``` + +An older public checkout can use `--qa-tools /path/to/fork/tools`. The launcher +pins inputs, uses a separate QA identity, retains raw profile logs and records +SHA-256 hashes of the actual measured source files alongside the Git parent. + +Observed macOS/arm64, 60-Hz display, VSync 1, cap 60: + +| Backend | Software wait off/on, ms/frame | Present off/on, ms/frame | CPU off/on, ms/frame | +| --- | ---: | ---: | ---: | +| LÖVE 11.5 OpenGL | 0.00 / 0.01 | 16.59 / 16.54 | 0.672 / 0.790 | +| LÖVE 12 Metal | 16.43 / 0.01 | 0.13 / 16.56 | 0.769 / 0.629 | + +Both enabled runs made zero extra sleep calls and stayed near 60 FPS, with no +measured interval exceeding 1.5 display periods. These short blank-window +checks establish the pacing decision and its profiler scope. They do not +establish a universal CPU/FPS gain, loaded-world behavior, or handheld results. diff --git a/docs/frame-profile-sections.md b/docs/frame-profile-sections.md new file mode 100644 index 000000000..7814e4ce3 --- /dev/null +++ b/docs/frame-profile-sections.md @@ -0,0 +1,34 @@ +# Frame-profile sections for world, presentation and DS audio + +F11 enables the existing `FrameProfile` report. These additional sections use +its shared no-op closer when profiling is disabled; there is no new setting. +All labels begin with spaces, so nested timings do not get counted again as +top-level frame time. Multiple spans with the same label accumulate. + +| Label | Work measured | +| --- | --- | +| `world: tiles` | Initial map/neighbour draws, declined-pipeline fallback and the flat upper layer | +| `world: entities` | Flat ghosts, main and above-layer entities, or tilted billboards | +| `world: FX` | Flat/tilted effect groups, pipeline `ctx.drawFx` and final map overlays | +| `tiles: ensureWindow` | Window validation/refill, including cached and missing-art returns | +| `renderer: zone blit` | Actual `blitCanvas` calls, including the unshaded fallback | +| `renderer: present pipeline` | Present processing and final native output | +| `voxel: sun pass` | Shadow preparation, including cache-hit/rate-limit decisions | +| `voxel: terrain` | Main and neighbouring terrain submission, per eye | +| `voxel: water` | Water draw-list construction, planar reflection and water submission, per eye | +| `voxel: cards` | Main character and staged-battle card submission, per eye | +| `audio: Nitro render` | One DS mixer block, including SoundData creation | + +These are wall-clock submission timings, not GPU execution times. Reflection +copies of cards contribute to the water section; the cards section measures +the visible scene's card pass. The world labels describe selected drawing +phases rather than an exhaustive partition of `drawWorld`; setup, sorting and +other work remain visible in the enclosing section. Voxel timings run inside +the selected world pipeline. Hook names, arguments and dispatch order are +unchanged. An exception still takes its existing cleanup/error path; an +unfinished span is discarded by the existing profiler contract. + +A1 adds observability and makes no performance claim. Desktop before/after +frame timings and ARM/device timings: **not measured**. Capture the same scene, +viewport, camera, mod configuration and profile window when measuring later +optimizations. Profiling itself is disabled by F11 / `FrameProfile.setEnabled(false)`. diff --git a/docs/gc-frame-budget.md b/docs/gc-frame-budget.md new file mode 100644 index 000000000..abee388ba --- /dev/null +++ b/docs/gc-frame-budget.md @@ -0,0 +1,51 @@ +# P2: incremental GC in unused deadline time + +`Performance.gcBudget` defaults to `true`. At boot the collector uses pause 120 +and step multiplier 300. Each concrete `Performance.CAPS` row additionally has +`gcPause`, `gcStepMul` and `gcBudgetSeconds`; tiers may reduce the default +0.0005-second idle allowance. No options are rewritten. + +The ordinary engine's fixed `step(1)` moves out of `Game:update`. The run loop +uses no-argument incremental steps before its remaining software wait. It starts +no further step after the earlier of the actual deadline and the half-millisecond +allowance, and stops on completion of a cycle. An expired deadline or a stopped +collector receives no work. VSync never gets an invented future budget; only +the actual remainder of its carried deadline is eligible, and no extra sleep +is added after VSync. + +The budget is cooperative: a single indivisible collector step can finish past +the boundary. It is not a preemption guarantee for an arbitrary heap/finalizer. +The F11 child section `frame pacing: gc budget` is nested in the existing +`frame pacing: sleep` section, so the parent includes collection plus waiting. + +The fork's pre-existing Gen3 `Game3.stepGC` delegation stays at its original +update boundary with the original arguments/behavior. A frame owned by that +public seam gets no second idle GC service. Its delegated/custom work remains +outside the engine idle allowance. This preserves consumers that replace it. + +The independent rollback switch is: + +```lua +require("src.core.Performance").gcBudget = false +``` + +The next frame restores the previous pause/multiplier and uses the old native +update step. The separate P1 switch and callback interfaces are unchanged. + +`tools/check_frame_pacing.py --gc --love /path/to/love --report /owned/qa/gc.json` +runs the guarded native comparison. P1 stays enabled for both phases; only P2 +changes. The workload allocates 512 short tables per frame, with 120 warm-up +and 120 measured frames per phase. Raw FrameProfile logs, original tuning, +heap samples and measured-source SHA-256 hashes are retained. + +Observed composed fork, macOS/arm64, 60 Hz, VSync 1, cap 60: + +| Backend | Original fixed step, ms/frame | New idle mean / max, ms | Heap peak before / after, KiB | +| --- | ---: | ---: | ---: | +| LÖVE 11.5 OpenGL | 0.00 (rounded) | 0.098 / 0.499 | 918 / 775 | +| LÖVE 12 Metal | 0.00 (rounded) | 0.120 / 0.278 | 1,179 / 692 | + +Short-workload CPU means were 1.012 → 0.920 ms/frame and 0.717 → 0.651 ms/frame, +respectively. Both retained approximately 60 FPS and restored the original +200/200 tuning when disabled. These are bounded synthetic allocation checks, +not full-game heap, long-session, arbitrary-finalizer or handheld measurements. diff --git a/docs/hot-path-dispatch.md b/docs/hot-path-dispatch.md new file mode 100644 index 000000000..d1632f35b --- /dev/null +++ b/docs/hot-path-dispatch.md @@ -0,0 +1,51 @@ +# P3: native update/draw dependency dispatch + +`Performance.hotPathHoist` defaults to `true`. Set it to `false` to restore +per-call require/pcall dispatch, the per-frame blit closure and fresh native +presentation records independently of P1/P2. + +`HotModules` lazily retains successful module values. Live fields and explicit +replacement or clearing of `package.loaded` remain observable; failed optional +loads are not cached. The Discord update uses its existing protected call with +the original `dt`, without allocating a wrapper closure after initialization. +No module path, hook argument or hook ordering changes. + +The renderer's blit adapter carries DPI as explicit arguments and still calls +the public `blitCanvas` method. Its existing `uiPresentation` field becomes an +engine-owned live view. A table installed there by a consumer is never mutated +by the reuse path. + +Only the unwrapped native renderer may borrow the private viewport scratch. +Public `endFrame` calls always return detached records, and replacing that +method bypasses the private shortcut. HUD hook arguments are detached at the +moment of dispatch, including a hook registered during compose/output. The +fork's window UI also retains its public record path. Scratch is never included +in a hook record. + +Run `tools/check_frame_pacing.py --hot-path --love /path/to/love --report +/owned/qa/hot.json` through the QA storage guard. The owned synthetic state draws +a rectangle through real `Game:draw`, canvases and composition. Each switch +phase has 120 warm-up and 120 measured frames at 60 Hz, VSync 1, P1 enabled. +The log contains the actual F11 `draw: whole frame` section; raw JSON also records +total draw wall time, process CPU, source fingerprints and an untimed require +counter. This is not a complete overworld, battle or mod scene. Profiling is on +in both measured phases. + +Measured on macOS arm64 (milliseconds/frame, switch off → on): + +| Tree / backend | F11 draw (rounded) | Raw draw mean | Process CPU | require calls | +| --- | ---: | ---: | ---: | ---: | +| Fork / LÖVE 11.5 OpenGL | 0.16 → 0.21 | 0.166 → 0.218 | 0.787 → 0.919 | 15 → 6 | +| Fork / LÖVE 12 Metal | 16.36 → 16.38 | 16.373 → 16.383 | 1.134 → 1.026 | 15 → 6 | +| Public adaptation on ed5189c2 / LÖVE 11.5 OpenGL | 0.21 → 0.21 | 0.223 → 0.215 | 0.952 → 0.936 | 14 → 5 | +| Public adaptation on ed5189c2 / LÖVE 12 Metal | 16.44 → 16.44 | 16.455 → 16.449 | 0.844 → 0.831 | 14 → 5 | + +Queue submission can absorb GPU/VSync waiting in Metal; these are wall-time +sections, not isolated GPU durations. The short runs show reduced dependency +dispatch and known allocations, with mixed CPU/timing results. They do not +establish a general frame-rate or CPU improvement. Dedicated ARM handheld, +Android, long-session and full-game measurements: not measured. + +Regression checks cover cached dependency identity/replacement/retry, detached +public and late HUD records, method wrappers, DPI forwarding and private native +reuse. Existing output/hook tests and the compatibility acceptance are retained. diff --git a/docs/native-field-view.md b/docs/native-field-view.md new file mode 100644 index 000000000..de787c45e --- /dev/null +++ b/docs/native-field-view.md @@ -0,0 +1,61 @@ +# Native GBA field presentation + +`src.core.game3.field_view` connects a source field-draw replacement to the +admitted native overworld. `OverworldController:drawWorld` invokes its `draw` +callback with the native Game3 facade and actual world-view dimensions. The +ordinary renderer remains the direct path when there is no replacement or +presentation override. Requiring the module does not create a session. + +The default `draw(game, width, height, options)` paints the native actors and +native field-effect bodies using owned source terrain atlases. It also works +for offscreen captures. `actorsOnly`, `skipActors`, billboard placement and the +`endUnderActors` upper-layer callback have explicit render scopes. A retained +upper-layer callback expires when that draw ends. Graphics pushes, canvas and +shader state are fenced, including errors. A declining source draw falls back +to the native renderer without consuming another tile-animation cadence. +Rendering never steps native actors, field effects, scripts or Flash tasks. + +Terrain remains in 16px cells. The native room's true extent determines a +connection seam; the source layout's even storage padding cannot enlarge the +room. Camera placement uses the player's cell centre, the capture viewport +and explicit source panning. Connected pairs use their own atlases. Reused +batches rebind rotating texture uploads without rebuilding stationary cell +geometry. The typed map graph is observed once per preparation, rather than +rebuilding map/neighbor observations for each visible cell. + +Palette edits affect owned source atlases and survive asset-cache invalidation; +the caller's palette words are detached. Borrowed native sheets are neither +patched nor released. Transparent reflection covers use the same current +source tile indices and palette words as the source terrain. Batches, cover +images and atlas leases retire with their rendering cohort. Save/dataset/mod +owner changes and a replaced native map cannot revive a retired presentation. +The fork additionally shares admitted-world lookup across map readers and +snapshot/warm-bound observers. Persistent-world hosts require that admission +follow-up; this public field change does not invent a replacement stack. + +Flash radii and default levels come from the imported native records. Native +updates retain the one-pixel animation clock. Both field paths use integer +scanline masks, with compressed equal-span runs and a private geometry cache. +Mask visibility accounts for the actual player centre under camera pan. + +Validation uses synthetic native maps, bank records and actors, not ROMs or +player saves. CPU coverage checks native admission, source atlas ownership, +options, camera coordinates, reloads, palette lifetime and native draw cadence. +The GPU host checks all five GBA editions on LÖVE 11.5 OpenGL and LÖVE 12 +OpenGL/Metal, three capture sizes, true room/connection boundaries, upright +actor markers, palette/texture uploads, scanline masks and resource retirement. +The independent pinned scanline comparison covers 5,025 radius/view/centre +combinations. These checks do not establish unchanged-mod initialization, +original effect-task/art/timing parity, full gameplay or device acceptance. + +Open work includes source-specific mosaic/order customization and void-fade +presentation, full effect-task interfaces, original-mod initialization and a +real gameplay route. iOS, Android and Switch still need their own runtime and +playability qualification. Native actor/effect behavior itself continues to be +owned by the engine. The public integration requires the native draw helpers +proposed in #291 and the separately published map/atlas/session prerequisites; +it must not replace missing host ownership with a simulated world. + +The public patch also reuses the committed native GraphicsScope guard, which +is absent from the public base. It fences real graphics state and leaves +borrowed canvases and images with their native owners. diff --git a/docs/world-draw-hot-paths.md b/docs/world-draw-hot-paths.md new file mode 100644 index 000000000..30a70b297 --- /dev/null +++ b/docs/world-draw-hot-paths.md @@ -0,0 +1,52 @@ +# P4: native world draw dispatch and private worklists + +`Performance.worldDrawHoist` defaults to `true`. Set it to `false` to disable +private list reuse and whole-loop protection and restore per-frame bound FX, +height/entity/focus adapters and individual protected entity draws. The switch +is independent of P1–P3. + +Native FX bodies, entity drawing, ground rise and canopy focus are methods with +explicit frame arguments. Ordinary flat draws use these methods directly. +Fresh no-argument bindings remain at public pipeline and billboard callback +seams: a retained FX callback must keep its own frame/camera. No record, method +signature, hook order or render pipeline field changes. + +Reflectors use a private counted instance list; removed entities are released. +Reflection cover cells use two private numeric columns and a reusable seen set. +Nested draws borrow separate work storage while the outer lease is busy. +An overridden public `rippleCells` still receives fresh pair tables in the old +shape. None of the new pools is included in a public callback record. + +The ordinary entity loop has one protected call. Its original sprite-depth +cleanup still runs before the original error is rethrown. The deferred above- +terrain pass retains its original ordering after the top layer. + +The guarded native host runs 64 synthetic rectangle actors through actual +`drawWorld`, reflection coverage, FX and native canvas composition. There is no +ROM, save/profile, extracted asset or mod scene. Each phase has 120 warm-up and +120 measured frames; VSync 1, 60 Hz, P1/P3 enabled. GC is stopped in each measured +window to expose allocation growth, then restarted. Heap growth includes all +host/profiler allocations and is not retained memory or a leak measurement. + +Observed macOS arm64, switch off → on: + +| Tree / backend | F11 world entities, ms/frame | F11 FX, ms/frame | Raw draw mean, ms | CPU, ms/frame | Heap growth / 120 frames, KiB | +| --- | ---: | ---: | ---: | ---: | ---: | +| Fork / LÖVE 11.5 OpenGL | 0.04 → 0.05 | 0.01 → 0.01 | 0.395 → 0.468 | 1.101 → 1.243 | 2758 → 1059 | +| Fork / LÖVE 12 Metal | 0.02 → 0.02 | 0.01 → 0.01 | 16.405 → 16.408 | 0.728 → 0.703 | 2748 → 1043 | +| Public adaptation / LÖVE 11.5 OpenGL | 0.03 → 0.02 | 0.01 → 0.01 | 0.270 → 0.241 | 0.911 → 0.801 | 2758 → 1059 | +| Public adaptation / LÖVE 12 Metal | 0.02 → 0.02 | 0.01 → 0.01 | 16.417 → 16.407 | 0.612 → 0.597 | 2749 → 1043 | + +Untimed protected calls fall from 55 to 1 in this scene (nine actors are deferred +to the above-terrain pass). Heap growth is about 61% lower. Short timing results +are mixed, and Metal can place GPU/VSync waiting in submission. No general FPS +or CPU improvement is established. Full game, Android, dedicated ARM handheld +and long session: not measured. + +Reproduce with `tools/check_frame_pacing.py --world-draw --love /path/to/love +--report /owned/qa/world.json`. The guarded runner retains raw F11 logs and +measured-source fingerprints. `tests/engine/world_draw_hot.lua` exercises four +editions, operation order/coordinates, batch-call count, private list reuse, +shrinking lists, public ripple records, reentrant covers, retained FX callbacks +and sprite-depth cleanup. A separate local comparison with the unchanged +pre-P4 controller also matched all draw traces in those four editions. diff --git a/main.lua b/main.lua index cf453d89d..3a782863e 100644 --- a/main.lua +++ b/main.lua @@ -1221,6 +1221,9 @@ end function love.run() BootTrace.mark("love.run enter") + local Performance = require("src.core.Performance") + local GCFrameBudget = require("src.core.GCFrameBudget") + GCFrameBudget.configure(Performance) if love.load then love.load(love.arg.parseGameArguments(arg), arg) end BootTrace.mark("love.load returned") @@ -1258,6 +1261,7 @@ function love.run() -- update dt if love.timer then dt = love.timer.step() end + GCFrameBudget.beginFrame(Performance) -- call update and draw -- @@ -1274,12 +1278,14 @@ function love.run() closeU() end + local presented = false if love.graphics and love.graphics.isActive() then love.graphics.origin() love.graphics.clear(love.graphics.getBackgroundColor()) if love.draw then love.draw() end local closeP = FrameProfile.section("present (GPU wait / vsync)") love.graphics.present() + presented = true closeP() end -- Counted after present, so a frame in the trace means a frame that was @@ -1312,24 +1318,9 @@ function love.run() if love.timer then if paced then - -- Sleep out the remainder of the frame budget, measured from the - -- carried deadline, in small chunks so the OS timer stays - -- responsive. vsync is untouched: when it already paces slower - -- than the cap the remainder is <= 0 and this rounds to a no-op. - local budget = 1 / FrameCap.current - nextFrame = nextFrame + budget - local now = love.timer.getTime() - -- A stall (alt-tab, a GC pause, a blocked import) can leave the - -- deadline more than a full budget in the past; re-anchor to now so - -- we pace the next frame rather than burst uncapped to catch up. - if now - nextFrame > budget then - nextFrame = now - end - while true do - local remaining = nextFrame - love.timer.getTime() - if remaining <= 0 then break end - love.timer.sleep(remaining < 0.001 and remaining or 0.001) - end + local closeSleep = FrameProfile.section("frame pacing: sleep") + nextFrame = FrameCap.pace(love.timer, love.window, presented, nextFrame, GCFrameBudget.idle) + closeSleep() else love.timer.sleep(0.001) end diff --git a/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua b/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua index 2084e56b4..2da5d6398 100644 --- a/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua +++ b/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua @@ -14,6 +14,7 @@ local V = ... local Mat4 = V.require("Mat4") local Perf = V.require("Perf") +local FrameProfile = require("src.core.FrameProfile") -- The PERFORMANCE tier's ceilings (lib/Tier.lua). Top-level here rather -- than lazily as in ShadowMap/Water/AntiAlias: Tier requires nothing from -- this mod, so there is no load order to protect, and the sun's rate @@ -2725,8 +2726,10 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) -- reaches far north and barely south, which is right for every rung -- but a head free to face south. local shCx, shCy = FirstPerson.shadowCenter(cx, cy, vh) + local closeSun = FrameProfile.section(" voxel: sun pass") castShadows(state, terrain, nbMesh, posed, shCx, shCy, vw, vh, atlasFor, water, nbWater, battleCards, battleToken) + closeSun() -- Everything between beginScene and endScene, as one function: the flat -- path runs it once, a VR frame runs it once PER EYE -- same posed @@ -2734,12 +2737,15 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) -- about anything but their viewpoint. local function drawScene() + local closeTerrain = FrameProfile.section(" voxel: terrain") Voxel3D.draw(terrain, atlasFor(state.map), nil) for i, nb in ipairs(state.neighbors or {}) do Voxel3D.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy)) end + closeTerrain() + -- Without a shadow map (headless, or a driver that could not make the -- canvas) the old flat decals stand in: ground-only, characters only, -- but better than a world with nothing under anybody. They go down @@ -2824,6 +2830,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) -- lake would otherwise wear one as a black smear. Water covers them, -- which is the same answer the shadow map's own pass gives (see -- ShadowMap.sprites) -- people do not shadow water either way. + local closeWater = FrameProfile.section(" voxel: water") local waterDraws = {} if water then waterDraws[#waterDraws + 1] = { water, atlasFor(state.map), nil } @@ -2859,6 +2866,8 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) planarTex, planarY) end + closeWater() + -- AND THE RIPPLE RINGS ON TOP OF IT, BEFORE ANYBODY IS STANDING IN IT. -- -- THIS LINE'S POSITION IS THE WHOLE FIX. Reported from play: "the ripples @@ -2920,10 +2929,12 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) -- drawEntity resolves the lean-over-the-wall-in-front case, and a -- character genuinely behind a building is far deeper and loses the -- test, so buildings and trees really occlude. + local closeCards = FrameProfile.section(" voxel: cards") drawCast(state, posed, atlasFor) -- and the staged fight's mons over them, exactly as they were painted -- into the water's reflection copy a few lines up (see drawBattleCards) drawBattleCards() + closeCards() -- tall grass last, pulled camera-ward exactly as far as the characters -- were (same per-vertex shader bias, so grass never drifts either): -- relative depth between a walker and the tuft row south of their feet diff --git a/src/audio/NitroAudio.lua b/src/audio/NitroAudio.lua index c27fff829..81b0caf87 100644 --- a/src/audio/NitroAudio.lua +++ b/src/audio/NitroAudio.lua @@ -1,5 +1,6 @@ -- Nintendo DS SSEQ transport over the ROM's SBNK/SWAR instruments. -- PCM/ADPCM mixing is local; no replacement MIDI soundfont is used. +local FrameProfile = require("src.core.FrameProfile") local Sdat=require('src.import.Gen4Sdat') local Audio={};local Player={};Player.__index=Player local archives,players={},setmetatable({}, {__mode="k"}) @@ -194,6 +195,7 @@ function Player:tick() for _,v in ipairs(self.voices) do v.ticks=v.ticks-1;if v.ticks<=0 and v.release==0 then v.release=math.floor(RATE*0.04) end end end function Player:render() + local closeRender=FrameProfile.section(" audio: Nitro render") local sound=love.sound.newSoundData(BLOCK,RATE,16,2) for i=0,BLOCK-1 do self.tickPhase=self.tickPhase+self.tempo*48/60/RATE @@ -215,6 +217,7 @@ function Player:render() sound:setSample(i,1,math.max(-1,math.min(1,left))) sound:setSample(i,2,math.max(-1,math.min(1,right))) end + closeRender() return sound end function Player:update() diff --git a/src/compat/gen1/FieldTiles.lua b/src/compat/gen1/FieldTiles.lua new file mode 100644 index 000000000..ac4c40890 --- /dev/null +++ b/src/compat/gen1/FieldTiles.lua @@ -0,0 +1,198 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Owned field batches sample the typed 16px map views. Native map renderers +-- and their borrowed sheets remain owned by the ordinary engine renderer. +local Map=require('src.core.game3.map') +local Native=require('src.core.game3.tileset_native') +local Graph=require('src.compat.gen1.MapGraph') +local Tiles={};Tiles.__index=Tiles +local function release(value)if value and value.release then value:release()end end +function Tiles.new(api)return setmetatable({api=api,under={},over={},atlases={},leases={},covers={}},Tiles)end +function Tiles:release() + for _,store in ipairs{self.under,self.over}do for _,batch in pairs(store)do release(batch)end end + for _,lease in pairs(self.leases)do lease:release()end + for _,lease in pairs(self.pending or{})do lease:release()end + self.pending=nil + for _,byMid in pairs(self.covers)do for _,entry in pairs(byMid)do release(entry.image);release(entry.prior)end end + self.covers={} + self.under,self.over,self.atlases,self.leases={},{},{},{} + self.window,self.stamp=nil,nil + self.def,self.cam,self.w,self.h,self.neighbors,self.world,self.extents=nil,nil,nil,nil,nil,nil,nil + self.api._nativeBatches,self.api._nativeOverBatches=self.under,self.over + self.api._nativeBatch=nil +end +local function same(a,b) + if not a or #a~=#b then return false end + for i=1,#a do if a[i]~=b[i]then return false end end;return true +end +function Tiles:prepare(game,cam,w,h) + local def,why=Map.currentDef();if not def then return false,why end + local layout=def.midLayout + local world=Map.refreshWorld(game,math.ceil(w/16),math.ceil(h/16),def.id) + local neighbors=Map.neighborList + local runtime=Map._context(game) + local extents={} + local function extent(target) + local size=extents[target] + if not size then + local l=target.midLayout + size=runtime:isOwned(target)and{target.width,target.height}or{l.width,l.height} + extents[target]=size + end + return size[1],size[2] + end + extent(def) + for _,edge in ipairs(world)do extent(edge.def)end + for _,edge in ipairs(neighbors)do extent(edge.def)end + -- Snapshot the typed graph once; resolving the public map facade for every + -- visible cell repeatedly rebuilt the same neighbor/owner observations. + local function sample(cx,cy)return Graph.midAt(cx,cy,def,neighbors,world,extent)end + local x,y=math.floor(cam.x/16)-1,math.floor(cam.y/16)-1 + local cols,rows=math.ceil(w/16)+3,math.ceil(h/16)+3 + local stamp={def,layout,layout._revision or 0,x,y,cols,rows} + for _,edge in ipairs(world)do + local l=edge.def.midLayout + stamp[#stamp+1]=edge.def;stamp[#stamp+1]=l;stamp[#stamp+1]=l._revision or 0 + stamp[#stamp+1]=edge.ox;stamp[#stamp+1]=edge.oy + end + local changed=self.api._nativeDirty or not same(self.stamp,stamp) + for pair,prior in pairs(self.atlases)do + local ts=Native.get(pair) + if ts~=prior or not ts or ts._retired then changed=true;break end + end + if changed then + local cells,pairsUsed={},{} + for cy=y,y+rows-1 do for cx=x,x+cols-1 do + local mid,pair=sample(cx,cy);pair=pair or def.pair or layout.pair + if not pairsUsed[pair]then + local ts,err=Native.get(pair);if not ts then return false,err end + pairsUsed[pair]=ts + end + cells[#cells+1]={cx=cx,cy=cy,mid=mid,pair=pair} + end end + -- Acquire the new cohort before releasing old references, including when + -- a source wrapper temporarily redirects a pair to another owned atlas. + local leases={} + for _,lease in pairs(self.pending or{})do lease:release()end;self.pending=nil + for pair in pairs(pairsUsed)do + local lease,err=Native.acquire(pair) + if not lease then for _,held in pairs(leases)do held:release()end;return false,err end + leases[pair]=lease + end + self.pending=leases + local capacity=cols*rows + for _,store in ipairs{self.under,self.over}do + for pair,batch in pairs(store)do + if not pairsUsed[pair]or batch:getBufferSize()4 then + local quad=n-5;local pixels=model.decoder:tilePixels(entry.tile) + for y=0,7 do for x=0,7 do + local sx,sy=entry.flipX and 7-x or x,entry.flipY and 7-y or y + local color=pixels[sy*8+sx+1]or 0 + if color~=0 then + local offset=(math.floor(quad/2)*8+y)*16+(quad%2)*8+x+1 + local at=(entry.palette*16+color)*4+1 + out[offset]=model.paletteKey:sub(at,at+3) + end + end end + end + end + return table.concat(out) +end +function Tiles:reflectionCover(cx,cy,cam,bgY) + local mid,pair=Graph.midAt(cx,cy,self.def,self.neighbors,self.world,function(target) + local size=self.extents[target];return size[1],size[2] + end) + local ts=self.atlases[pair or self.def.pair] + local model=ts and ts._model + if not model then return false,'reflection source pixels unavailable'end + local layer=model.decoder:reflectionCoverLayer(mid) + if layer==1 then return self:cell(cx,cy,cam,bgY)end + if layer~=2 then return true end + local cache=self.covers[ts];if not cache then cache={};self.covers[ts]=cache end + local entry=cache[mid] + if not entry or entry.revision~=model.revision then + local bytes=reflectionRGBA(model,mid) + if not entry then entry={};cache[mid]=entry end + if entry.bytes~=bytes then + local Upload=require('src.compat.gen1.AtlasUpload') + local data=Upload.data(16,16,bytes) + local image=Upload.image(data);data:release() + release(entry.prior);entry.prior,entry.image=entry.image,image;entry.bytes=bytes + end + entry.revision=model.revision + end + love.graphics.draw(entry.image,cx*16-math.floor(cam.x),cy*16-math.floor(bgY)) + return true +end +return Tiles diff --git a/src/core/FrameCap.lua b/src/core/FrameCap.lua index 2e5b36feb..763e116f2 100644 --- a/src/core/FrameCap.lua +++ b/src/core/FrameCap.lua @@ -25,6 +25,55 @@ FrameCap.DEFAULT = 60 -- save editor are paced before any save applies its stored option. FrameCap.current = FrameCap.DEFAULT +-- P1 rollback switch. Each frame still falls back to software pacing when +-- there was no present, VSync is off, or the display rate is unavailable. +FrameCap.vsyncPacing = true +local _refreshWindow, _refreshVsync, _refreshAt, _refreshRate +local REFRESH_POLL_SECONDS = 1 + +local function presentationPaces(window, now) + if not (window and window.getVSync and window.getMode) then return false end + local vsync = window.getVSync() + if type(vsync) ~= "number" or vsync == 0 then + _refreshVsync = vsync + return false + end + -- getMode returns a new flags table: sample it once a second rather than + -- allocate one each frame. Provider/VSync changes invalidate the sample; + -- display-rate changes are observed on the next periodic sample. + if window ~= _refreshWindow or vsync ~= _refreshVsync or not _refreshAt + or now < _refreshAt or now - _refreshAt >= REFRESH_POLL_SECONDS then + local _, _, flags = window.getMode() + local rate = type(flags) == "table" and tonumber(flags.refreshrate) + _refreshWindow, _refreshVsync, _refreshAt = window, vsync, now + _refreshRate = rate and rate > 0 and rate < math.huge and rate or nil + end + return _refreshRate ~= nil and FrameCap.current >= _refreshRate +end + +-- The run loop owns the deadline and passes back the returned value. Keeping +-- the original fallback here lets tests exercise the real pacing operation. +function FrameCap.pace(timer, window, presented, nextFrame, idleWork) + local now = timer.getTime() + local budget = 1 / FrameCap.current + nextFrame = nextFrame + budget + if FrameCap.vsyncPacing and presented and presentationPaces(window, now) then + if idleWork then + idleWork(timer, nextFrame) + return timer.getTime() + end + return now + end + if now - nextFrame > budget then nextFrame = now end + if idleWork then idleWork(timer, nextFrame) end + while true do + local remaining = nextFrame - timer.getTime() + if remaining <= 0 then break end + timer.sleep(remaining < 0.001 and remaining or 0.001) + end + return nextFrame +end + -- Nearest valid step for an arbitrary value (a hand-edited options.lua or -- an old save with no fpsCap key), so a bad number degrades to something -- sane; nil / non-numbers fall back to the default. A value below MIN or diff --git a/src/core/GCFrameBudget.lua b/src/core/GCFrameBudget.lua new file mode 100644 index 000000000..146d95f48 --- /dev/null +++ b/src/core/GCFrameBudget.lua @@ -0,0 +1,86 @@ +-- Cooperative incremental collection in otherwise unused frame time. +local Profile = require("src.core.FrameProfile") +local Budget = {} +local MAX_SECONDS = 0.0005 +local _collector, _priorPause, _priorMul, _pause, _mul +local _enabled, _seconds, _canQueryRunning, _facadeStepped = false, 0, false, false + +local function nonnegative(value, fallback) + if type(value) ~= "number" or value ~= value or value < 0 or value == math.huge then + return fallback + end + return value +end + +local function restoreTuning() + if _collector and _priorPause ~= nil then + _collector("setpause", _priorPause) + _collector("setstepmul", _priorMul) + end + _collector, _priorPause, _priorMul, _pause, _mul = nil, nil, nil, nil, nil +end + +function Budget.configure(performance, collector) + collector = collector or collectgarbage + if not performance.gcBudget or type(collector) ~= "function" then + restoreTuning() + _enabled = false + return + end + if _collector ~= collector then + restoreTuning() + end + local caps = performance.CAPS[performance.tier] or {} + local pause = math.floor(nonnegative(caps.gcPause, performance.GC_PAUSE)) + local mul = math.floor(nonnegative(caps.gcStepMul, performance.GC_STEP_MUL)) + if not _collector then + _collector = collector + _priorPause = collector("setpause", pause) + _priorMul = collector("setstepmul", mul) + _canQueryRunning = pcall(collector, "isrunning") + else + if pause ~= _pause then + collector("setpause", pause) + end + if mul ~= _mul then + collector("setstepmul", mul) + end + end + _pause, _mul = pause, mul + _seconds = math.min(MAX_SECONDS, nonnegative(caps.gcBudgetSeconds, performance.GC_BUDGET_SECONDS)) + _enabled = true +end + +function Budget.beginFrame(performance, collector) + _facadeStepped = false + Budget.configure(performance, collector) +end + +-- An existing public compatibility method retains ownership of its frame. +-- Its call, arguments and return behavior are not replaced by idle collection. +function Budget._facadeHandled() + _facadeStepped = true +end + +function Budget.idle(timer, deadline) + if not _enabled or _facadeStepped or _seconds <= 0 then + return 0 + end + local start = timer.getTime() + local stop = math.min(deadline, start + _seconds) + if stop <= start or (_canQueryRunning and not _collector("isrunning")) then + return 0 + end + local close = Profile.section(" frame pacing: gc budget") + local steps = 0 + while timer.getTime() < stop do + steps = steps + 1 + if _collector("step") then + break + end + end + close() + return steps +end + +return Budget diff --git a/src/core/Game.lua b/src/core/Game.lua index 0ebe75e81..33cfccb16 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -10,6 +10,9 @@ local Renderer = require("src.render.Renderer") local SaveData = require("src.core.SaveData") local StateStack = require("src.core.StateStack") local FrameProfile = require("src.core.FrameProfile") +local Performance = require("src.core.Performance") +local GCFrameBudget = require("src.core.GCFrameBudget") +local HotModules = require("src.core.HotModules") local TouchControls = require("src.core.TouchControls") local ModLoader = require("src.mods.Loader") local ModRuntime = require("src.mods.Runtime") @@ -586,7 +589,7 @@ function Game:updateCameraStick(dt) end function Game:update(dt) - require('src.pokemon.Gen4PoketchState').tick(self, dt) + HotModules.get('src.pokemon.Gen4PoketchState').tick(self, dt) local panel=self.secondScreenPointerOwner local top=self.stack and self.stack.top and self.stack:top() if panel and panel~=top and panel.updatePresentation then @@ -612,7 +615,7 @@ function Game:update(dt) local close = FrameProfile.section(" update: music") while self.audioAccum >= step do self.audioAccum = self.audioAccum - step - require("src.core.Music").update(Data) + HotModules.get("src.core.Music").update(Data) end close() end @@ -620,34 +623,34 @@ function Game:update(dt) -- frame dt (not the fixed logic step) for a smooth ~0.25s glide. do local close = FrameProfile.section(" update: tilt") - require("src.render.Tilt").update(dt) + HotModules.get("src.render.Tilt").update(dt) close() end -- mod render pipelines tween on the same real-frame clock, for the same -- reason: they are presentational, so fast-forward must not speed them up do local close = FrameProfile.section(" pipelines: update") - require("src.render.Pipelines").update(dt) + HotModules.get("src.render.Pipelines").update(dt) close() end do local close = FrameProfile.section(" update: discord") - pcall(function() require("src.core.DiscordPresence").update(dt) end) + if Performance.hotPathHoist then + local ok, Discord = HotModules.optional("src.core.DiscordPresence") + if ok and type(Discord) == "table" then pcall(Discord.update, dt) end + else + pcall(function() require("src.core.DiscordPresence").update(dt) end) + end close() end - -- Steady-state memory backstop: advance the incremental collector one - -- small step every rendered frame. The heavy GPU objects are now freed - -- explicitly (map eviction, battle exit, canvas/renderer swaps), so this - -- only has to keep ordinary Lua-heap garbage (per-frame tables/closures) - -- from drifting upward over a long session, and to spread collection out - -- so the default lazy schedule never batches it into a visible pause. - -- ONE INCREMENTAL GC STEP PER FRAME. Cheap on a small heap and not - -- obviously cheap under a mod that allocates a world's worth of geometry, - -- which is exactly the case being measured -- so it is named rather than - -- assumed. + -- Keep an already-loaded native GC delegation at its update boundary. + -- Ordinary engine collection moves to spare deadline time in love.run. if collectgarbage then local close = FrameProfile.section(" update: gc step") - collectgarbage("step", 1) + local facade = package.loaded["src.core.Game3"] + local handled = type(facade) == "table" and type(facade._stepGC) == "function" and facade._stepGC(self) + if handled then GCFrameBudget._facadeHandled() end + if not handled and not Performance.gcBudget then collectgarbage("step", 1) end close() end end @@ -916,7 +919,7 @@ Game.centerClassicZones = centerClassicZones function Game:draw() local closeDraw = FrameProfile.section("draw: whole frame") - local screenLayout=require('src.ui.SecondScreen') + local screenLayout=HotModules.get('src.ui.SecondScreen') Renderer.splitViewport=nil if screenLayout.combined(self) then local w,h=love.graphics.getDimensions() @@ -934,25 +937,25 @@ function Game:draw() -- Single-screen generations use the physical Android lower panel as a live -- save companion without changing the game's own screen mode. do - local okC, Companion = pcall(require, "src.ui.SaveCompanion") + local okC, Companion = HotModules.optional("src.ui.SaveCompanion") if okC and Companion and Companion.tick then pcall(Companion.tick, self) end end do - local okSS, SS = pcall(require, "src.ui.SecondScreen") + local okSS, SS = HotModules.optional("src.ui.SecondScreen") if okSS and SS.mode and (SS.mode(self) == "display" or SS.combined(self)) and not self.secondScreenDrawnThisFrame then - local version = require("src.core.GameVersion").info() + local version = HotModules.get("src.core.GameVersion").info() local family = version and version.family -- THE WATCH ONLY ONCE IT IS ON THE WRIST: Platinum's lower screen shows -- the Pokétch after `enablepoketch` (Jubilife) and nothing before; Black -- and White's is the C-Gear, not Platinum's watch (not built yet), so -- theirs stays blank too - local poketchOn = require("src.pokemon.Gen4PoketchState").owned(self.save) + local poketchOn = HotModules.get("src.pokemon.Gen4PoketchState").owned(self.save) local panelModule = family == "hgss" and "src.ui.HgssFieldPanel" or (family ~= "bw" and poketchOn and "src.ui.Gen4Poketch") or nil local okP, Poketch = false, nil - if panelModule then okP, Poketch = pcall(require, panelModule) end + if panelModule then okP, Poketch = HotModules.optional(panelModule) end if not panelModule then pcall(SS.draw, self, function() love.graphics.setColor(0, 0, 0, 1) @@ -991,7 +994,7 @@ function Game:draw() -- has to get out, and because the GPU readback wants the drawing finished. -- A no-op in every other mode and on every machine with one screen. do - local okSS, SS = pcall(require, "src.ui.SecondScreen") + local okSS, SS = HotModules.optional("src.ui.SecondScreen") if okSS and SS.flush then pcall(SS.flush, self) end end FrameProfile.frame() @@ -1004,6 +1007,14 @@ function Game:toggleFrameProfile() return FrameProfile.toggle() end +local function viewportSnapshot(viewport) + return { + width=viewport.width,height=viewport.height,gameX=viewport.gameX,gameY=viewport.gameY, + gameWidth=viewport.gameWidth,gameHeight=viewport.gameHeight,scale=viewport.scale, + dpiX=viewport.dpiX,dpiY=viewport.dpiY, + } +end + function Game:_draw() -- the UI canvas clears transparent when the overworld's world pass -- shows through beneath it; opaque full-screen states get the classic @@ -1116,8 +1127,8 @@ function Game:_draw() end end local baseState = self.stack.states[drawFrom] - local SuperSample = require("src.render.SuperSample") - if not worldDrawn and baseState and require("src.core.GameVersion").isNds() + local SuperSample = HotModules.get("src.render.SuperSample") + if not worldDrawn and baseState and HotModules.get("src.core.GameVersion").isNds() and SuperSample.singleScreen(self) and not SuperSample.active then SuperSample.draw(self, self.stack:top(), drawStates) else @@ -1183,11 +1194,18 @@ function Game:_draw() if worldDrawn and self.overworld.sgbWorldZones then worldZones = self.overworld:sgbWorldZones() end - local viewport = Renderer:endFrame(zones, worldZones) + local viewport, privateViewport + if Performance.hotPathHoist and Renderer.endFrame == Renderer._nativeEndFrame then + viewport = Renderer:_endFrameForEngine(zones, worldZones) + privateViewport = true + else + viewport = Renderer:endFrame(zones, worldZones) + end -- Persistent tool status is screen-space UI: draw it over the completed -- render pipeline with exact playfield/margin geometry, but below mobile -- controls. It never becomes an updating game state. if ModRuntime.wantsHook("render.hud") then + if privateViewport then viewport = viewportSnapshot(viewport) end ModRuntime.call("render.hud", function() end, self, viewport) end -- on-screen mobile controls: pure screen-space, over the finished frame diff --git a/src/core/HotModules.lua b/src/core/HotModules.lua new file mode 100644 index 000000000..316773c7b --- /dev/null +++ b/src/core/HotModules.lua @@ -0,0 +1,34 @@ +-- Lazy dependency upvalues shared by native update/draw sites. Public require +-- paths stay intact; replacing/clearing package.loaded remains observable. +local Performance = require("src.core.Performance") +local Hot = {} +local _modules = {} + +function Hot.get(name) + if Performance.hotPathHoist then + local module = _modules[name] + if module and package.loaded[name] == module then + return module + end + module = require(name) + _modules[name] = module + return module + end + return require(name) +end + +function Hot.optional(name) + if Performance.hotPathHoist then + local module = _modules[name] + if module and package.loaded[name] == module then + return true, module + end + end + local ok, module = pcall(require, name) + if ok and Performance.hotPathHoist then + _modules[name] = module + end + return ok, module +end + +return Hot diff --git a/src/core/Performance.lua b/src/core/Performance.lua index 861121ddd..c7bd61b1a 100644 --- a/src/core/Performance.lua +++ b/src/core/Performance.lua @@ -42,6 +42,17 @@ local Performance = {} +-- P2 is independently reversible. Tier rows can reduce the idle budget or +-- tune the collector without changing the persisted presentation options. +Performance.gcBudget = true +-- P3: cache hot-path dependencies and reuse engine-private presentation data. +Performance.hotPathHoist = true +-- P4: independent world-draw closure, list and protection optimization. +Performance.worldDrawHoist = true +Performance.GC_PAUSE = 120 +Performance.GC_STEP_MUL = 300 +Performance.GC_BUDGET_SECONDS = 0.0005 + -- Option-row order (auto first, then most to least capable). Performance.TIERS = { "auto", "high", "balanced", "low" } @@ -60,6 +71,11 @@ Performance.CAPS = { balanced = { tilt = false, gbcfx = false, survey = true, fpsMax = false }, low = { tilt = false, gbcfx = false, survey = false, fpsMax = 60 }, } +for _, caps in pairs(Performance.CAPS) do + caps.gcPause = Performance.GC_PAUSE + caps.gcStepMul = Performance.GC_STEP_MUL + caps.gcBudgetSeconds = Performance.GC_BUDGET_SECONDS +end -- Live resolved tier (never "auto"); Game:applyOptions sets it and the -- renderer / options row read it back. Defaults to the unclamped tier so diff --git a/src/core/game3/field_view.lua b/src/core/game3/field_view.lua new file mode 100644 index 000000000..f77e0dd8d --- /dev/null +++ b/src/core/game3/field_view.lua @@ -0,0 +1,236 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Source field presentation over the admitted native world. No second map, +-- actor simulation, effect clock or save is constructed by this interface. +local Session=require('src.compat.gen1.NativeSession') +local Flash=require('src.world.Gen3Flash') +local Scope=require('src.render.GraphicsScope') +local Map=require('src.core.game3.map') +local Native=require('src.core.game3.tileset_native') +local M={cameraPanX=0,cameraPanY=0,_nativeDirty=true} +local tiles=require('src.compat.gen1.FieldTiles').new(M) +local cohort,active,manualRadius,palette,spanCache +local function currentOwner() + local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + return host and host.currentMod or facade and facade._callbackOwner and facade._callbackOwner() +end +local function release() + tiles:release() + if palette then Native.resetSlotPalette(palette.ts,palette.slot)end + cohort,manualRadius,palette,spanCache=nil,nil,nil,nil + M.cameraPanX,M.cameraPanY=0,0 + M._nativeDirty=true;M._nativeLayout=nil;M._viewW,M._viewH=nil,nil +end +local function bind(value) + local game,world,player=Session.resolve(value) + if not game then release();return nil,world end + if game~=package.loaded['src.core.Game']then return nil,'field drawing belongs to the current native Game'end + if not cohort or cohort.game~=game or cohort.data~=game.data or cohort.save~=game.save + or cohort.loader~=game.mods or cohort.world~=world then + release();cohort={game=game,data=game.data,save=game.save,loader=game.mods,world=world,map=world.map,users={}} + elseif cohort.map~=world.map then + tiles:release();manualRadius=nil;cohort.map=world.map + if palette then Native.resetSlotPalette(palette.ts,palette.slot);palette=nil end + end + local who=currentOwner();if who then cohort.users[who]=true end + return game,world,player +end +local function record() + local game,why=bind();if not game then return nil,why end + local r=Flash.record(game.data);if not r then return nil,'imported Flash radii unavailable'end + return r +end +function M.flashRadii() + local r,why=record();if not r then return nil,why end + local out={};for i,value in ipairs(r.radii)do out[i-1]=value end;return out +end +function M.maxFlashLevel()local r,why=record();return r and r.maxLevel or nil,why end +function M.radiusForLevel(level) + local r,why=record();if not r then return nil,why end + level=tonumber(level)or 0 + return r.radii[level+1]or r.radii[1] +end +function M.setFlashLevel(level) + local game,why=bind();if not game then return false,why end + manualRadius=nil;return Flash.setLevel(game,level) +end +function M.getFlashLevel()local game=bind();return game and Flash.level(game)or 0 end +function M.setFlashRadius(radius) + local game,why=bind();if not game then return false,why end + radius=tonumber(radius) + if radius and(radius~=radius or math.abs(radius)==math.huge)then return false,'invalid Flash radius'end + manualRadius=radius;return true +end +M.setPyramidLightRadius=M.setFlashRadius +function M.animateFlashLevel(fromLevel,toLevel) + local game,why=bind();if not game then return false,why end + manualRadius=nil;Flash.setLevel(game,fromLevel);Flash.animateTo(game,toLevel) + -- The native update owns the one-pixel steps and its existing script wait. +end +function M.flashRadius() + local game=bind();if not game then return nil end + if manualRadius~=nil then return manualRadius end + if require('src.core.GameVersion').get()=='emerald'then + local pyramid=package.loaded['src.script.Gen3BattlePyramid'] + local radius=pyramid and pyramid.lightRadius(game) + if radius then return radius end + end + if Flash.level(game)==0 then return nil end + return tonumber(game.save.gen3FlashRadius)or Flash.radiusOf(game.data,Flash.level(game)) +end +function M.setCameraPanning(x,y) + local game,why=bind();if not game then return false,why end + x,y=tonumber(x)or 0,tonumber(y)or 0 + if x~=x or y~=y or math.abs(x)==math.huge or math.abs(y)==math.huge then return false,'invalid camera pan'end + M.cameraPanX,M.cameraPanY=x,y;return true +end +function M.defaultFlashLevel(value,mapId) + local game,why=bind(value);if not game then return nil,why end + local maps=Map.definitions(game);local def=maps and maps[mapId or Map.current] + local r=Flash.record(game.data);if not r then return nil,'imported Flash radii unavailable'end + if not def or tonumber(def.cave)==0 or def.cave==nil then return 0 end + return Flash.lit(game)and r.lit or r.dark +end +function M.setDefaultFlashLevel(value,mapId) + local level,why=M.defaultFlashLevel(value,mapId) + if level==nil then return false,why end + return M.setFlashLevel(level) +end +function M.setBgPaletteOverride(slot,words) + local game,why=bind();if not game then return false,why end + if palette and(words==nil or palette.slot~=slot)then + Native.resetSlotPalette(palette.ts,palette.slot);palette=nil + end + if words==nil then return true end + local def=Map.currentDef();local pair=def and(def.pair or def.midLayout.pair) + local ts=pair and Native.get(pair) + if not ts or not Native.setSlotPalette(ts,slot,words)then return false,'native palette unavailable'end + if palette and palette.ts~=ts then Native.resetSlotPalette(palette.ts,palette.slot)end + local copy={};for i=1,16 do copy[i]=words[i]or 0 end + palette={ts=ts,pair=pair,slot=slot,words=copy};return true +end +function M.flashSpans(radius,w,h,cx,cy) + return Flash.spans(radius,tonumber(w)or 240,tonumber(h)or 160,cx,cy) +end +function M.flashSpansFor(radius,w,h,cx,cy) + if not spanCache or spanCache.r~=radius or spanCache.w~=w or spanCache.h~=h or spanCache.cx~=cx or spanCache.cy~=cy then + spanCache={r=radius,w=w,h=h,cx=cx,cy=cy,spans=M.flashSpans(radius,w,h,cx,cy)} + end + return spanCache.spans +end +function M.invalidateCell()M._nativeDirty=true end +function M.invalidateLayoutCell()M._nativeDirty=true end +function M.trimNative(atlas,map,pair,visible) + local keep,busy={},{} + if pair then keep[pair]=true end + for key in pairs(visible or{})do keep[key]=true end + for key in pairs(tiles.atlases)do keep[key]=true end + for _,store in ipairs{tiles.under,tiles.over}do + for _,batch in pairs(store)do busy[batch:getTexture()]=true end + end + local x0,y0,x1,y1=map.warmRect() + for _,edge in ipairs(map.world or{})do + if not map.warmNear or map.warmNear(edge,x0,y0,x1,y1)then + local p=edge.def and(edge.def.pair or edge.def.midLayout.pair);if p then keep[p]=true end + end + end + return atlas.trim(keep,atlas.RESIDENT_MAX,busy) +end +function M.invalidate() + tiles:release();M._nativeDirty=true;M._nativeLayout=nil;spanCache=nil + Native.invalidate() + if palette then palette.ts=nil end +end +M.release=release +local function dimensions(value,default) + value=tonumber(value)or default + assert(value>0 and value= 12 @@ -1114,9 +1147,9 @@ function Renderer:endFrame(zones, worldZones) Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy, present) if not projected then if worldZones then - blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, 0, 0, ww, wh) + blit(self, dpiX, dpiY, self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, 0, 0, ww, wh) else - blit(self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, 0, 0, ww, wh) + blit(self, dpiX, dpiY, self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, 0, 0, ww, wh) end -- OBP-baked overworld sprites replay on top of the zone pass (GBC -- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass @@ -1233,7 +1266,7 @@ function Renderer:endFrame(zones, worldZones) -- it wants the whole window rather than a fixed box in the middle of one. -- The bag, the party menu, the mart, the Pokenav, the title and the battle -- all say so; the overworld does not, so the map is still never bled. - local okGame, GameMod = pcall(require, "src.core.Game") + local okGame, GameMod = HotModules.optional("src.core.Game") local fills = okGame and GameMod and GameMod.fillScaleInStack(GameMod.stack) or false -- ...AND NOT A BATTLE. @@ -1274,12 +1307,12 @@ function Renderer:endFrame(zones, worldZones) and not GameMod.edgeBleedAllowedInStack(GameMod.stack) then fills = false end - if fills and require("src.core.GameVersion").isGen3() then + if fills and HotModules.get("src.core.GameVersion").isGen3() then self:bleedEdges(self.canvas, Ux, Uy, uox, uoy, ww, wh) end local uiSource=self.uiOverride or self.canvas local uiFactor=uiSource:getWidth()/uiw - blit(uiSource, Ux/uiFactor, Uy/uiFactor, zones, Ux, Uy, uox, uoy, uox, uoy, uvpw, uvph) + blit(self, dpiX, dpiY, uiSource, Ux/uiFactor, Uy/uiFactor, zones, Ux, Uy, uox, uoy, uox, uoy, uvpw, uvph) else local rest = { { uox, uoy, uvpw, uvph } } local placed = {} @@ -1301,7 +1334,7 @@ function Renderer:endFrame(zones, worldZones) -- desktop it IS the window: love.window.getSafeArea is absent or returns -- the full surface, so rect() answers 0, 0, ww, wh and every number below -- is what it has always been. Which is exactly why only mobile saw this. - local sax, say, saw, sah = require("src.core.SafeArea").rect() + local sax, say, saw, sah = HotModules.get("src.core.SafeArea").rect() if self.splitViewport then saw=math.max(0,math.min(saw,ww-sax));sah=math.max(0,math.min(sah,wh-say)) end @@ -1329,13 +1362,13 @@ function Renderer:endFrame(zones, worldZones) rest = subtractRect(rest, uox + a.x * Ux, uoy + a.y * Uy, dw, dh) end for _, r in ipairs(rest) do - blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, r[1], r[2], r[3], r[4]) + blit(self, dpiX, dpiY, self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, r[1], r[2], r[3], r[4]) end for _, p in ipairs(placed) do -- shift the draw origin so canvas pixel (a.x, a.y) lands on (dx, dy). -- The zone scissors are computed from the same origin, so an SGB -- region travels with the element instead of staying in the letterbox. - blit(self.canvas, Ux, Uy, zones, Ux, Uy, + blit(self, dpiX, dpiY, self.canvas, Ux, Uy, zones, Ux, Uy, p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, p.dx, p.dy, p.dw, p.dh) end end @@ -1345,7 +1378,7 @@ function Renderer:endFrame(zones, worldZones) -- origin and scale it used, so a queued rect covers exactly the pixels it -- replaces -- and UNDER the wipe and veil below, which are screen-wide -- effects that on hardware nothing escapes. - require("src.render.NativeOverlay").flush(uox, uoy, Ux, Uy) + HotModules.get("src.render.NativeOverlay").flush(uox, uoy, Ux, Uy) -- The battle wipe covers the whole surface, letterbox included, so it goes -- over the finished composite rather than under the UI blit. On hardware @@ -1393,6 +1426,7 @@ function Renderer:endFrame(zones, worldZones) end if present then + local closePresent = FrameProfile.section(" renderer: present pipeline") love.graphics.setCanvas() -- Post-process pipelines run over the finished composite -- world, UI -- and all -- and before GBC FX, so a blur or colour grade is what the @@ -1410,19 +1444,25 @@ function Renderer:endFrame(zones, worldZones) love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(composed, 0, 0) end + closePresent() end self.worldActive = false self.uprightActive = false self.uiOverride = nil self.worldOverride = nil PaletteFX.setPass(nil) - return { - width = ww, height = wh, - gameX = ox, gameY = oy, - gameWidth = vpw, gameHeight = vph, - scale = Sp, - dpiX = dpiX, dpiY = dpiY, - } + return viewportResult(privateViewport, ww, wh, ox, oy, vpw, vph, Sp, dpiX, dpiY) +end + +-- Public callers (including wrappers installed by mods) always receive a new +-- record. Only the unwrapped native path may borrow the private result. +function Renderer:endFrame(zones, worldZones) + return endFrame(self, zones, worldZones, false) end +Renderer._nativeEndFrame = Renderer.endFrame +function Renderer:_endFrameForEngine(zones, worldZones) + return endFrame(self, zones, worldZones, Performance.hotPathHoist) +end + return Renderer diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index 5f7a007a5..2a5aa10b7 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -8,6 +8,7 @@ -- a single static SpriteBatch covering the map plus a border-block ring -- (the ring plays the role of the GB border blocks around small maps). +local FrameProfile = require("src.core.FrameProfile") local Assets = require("src.render.Assets") local Logger = require("src.core.Logger") local Probe = require("src.core.Probe") @@ -1809,6 +1810,7 @@ function TileRenderer:setWin(tx0, ty0, tx1, ty1) end function TileRenderer:ensureWindow(camX, camY, vw, vh) + local closeWindow = FrameProfile.section(" tiles: ensureWindow") local W, H = self.bodyTilesW, self.bodyTilesH vw = vw or W * 8 -- a nil view (headless draw) means the whole body vh = vh or H * 8 @@ -1860,6 +1862,7 @@ function TileRenderer:ensureWindow(camX, camY, vw, vh) end if win and tx0 >= win.tx0 and ty0 >= win.ty0 and tx1 <= win.tx1 and ty1 <= win.ty1 then + closeWindow() return -- still inside the last fill end -- refill with margin so the next few scrolled pixels stay covered @@ -1936,6 +1939,7 @@ function TileRenderer:ensureWindow(camX, camY, vw, vh) cells, drew, W, H, tostring(self.gen3.slots)) end self:setWin(tx0, ty0, tx1, ty1) + closeWindow() return end @@ -1944,6 +1948,7 @@ function TileRenderer:ensureWindow(camX, camY, vw, vh) -- player, and leave the reason in the log (gen3SheetsFor already logged it). if not self.image then self:setWin(tx0, ty0, tx1, ty1) + closeWindow() return end if not self.winBatch then @@ -1986,6 +1991,7 @@ function TileRenderer:ensureWindow(camX, camY, vw, vh) end end self:setWin(tx0, ty0, tx1, ty1) + closeWindow() end -- draw the static tile window, then its animated overdraw, at the camera offset diff --git a/src/world/Gen3Flash.lua b/src/world/Gen3Flash.lua index 0ee741730..079bcc32e 100644 --- a/src/world/Gen3Flash.lua +++ b/src/world/Gen3Flash.lua @@ -173,7 +173,7 @@ end -- The circle to cut, or nil when there is no darkness to draw. `radius` is -- nil at level 0 and at any level whose radius covers the whole view, so the -- caller never draws a window it cannot see the edge of. -function Gen3Flash.circle(game, viewW, viewH) +function Gen3Flash.circle(game, viewW, viewH, centerX, centerY) local r = record(game and game.data) if not r then return nil end -- THE BATTLE PYRAMID'S DARK is its own radius (frontier.pyramidLightRadius), @@ -183,8 +183,9 @@ function Gen3Flash.circle(game, viewW, viewH) return require("src.script.Gen3BattlePyramid").lightRadius(game) end) if ok and radius then - local w = (tonumber(viewW) or r.screen[1]) / 2 - local h = (tonumber(viewH) or r.screen[2]) / 2 + local width, height = tonumber(viewW) or r.screen[1], tonumber(viewH) or r.screen[2] + local cx, cy = centerX or width / 2, centerY or height / 2 + local w, h = math.max(math.abs(cx), math.abs(width-cx)), math.max(math.abs(cy), math.abs(height-cy)) if radius * radius >= w * w + h * h then return nil end return { radius = radius } end @@ -200,10 +201,59 @@ function Gen3Flash.circle(game, viewW, viewH) return { radius = 0 } end -- bigger than the corner of the view is the same as no darkness - local w = (tonumber(viewW) or r.screen[1]) / 2 - local h = (tonumber(viewH) or r.screen[2]) / 2 + local width, height = tonumber(viewW) or r.screen[1], tonumber(viewH) or r.screen[2] + local cx, cy = centerX or width / 2, centerY or height / 2 + local w, h = math.max(math.abs(cx), math.abs(width-cx)), math.max(math.abs(cy), math.abs(height-cy)) if radius * radius >= w * w + h * h then return nil end return { radius = radius } end +-- Integer window edges, including the scanline update order. A floating-point +-- circle at pixel centres has different edge pixels. Keep geometry independent +-- of the save and graphics backend; callers get fresh, mutable span records. +function Gen3Flash.spans(radius, width, height, centerX, centerY) + local w, h = math.floor(width or 240), math.floor(height or 160) + local cx, cy = math.floor(centerX or w/2), math.floor(centerY or h/2) + radius = math.floor(radius) + assert(radius==radius and radius=0 and y=y do + put(cy-y,cx-x,cx+x); put(cy+y,cx-x,cx+x) + put(cy-x,cx-y,cx+y); put(cy+x,cx-y,cx+y) + err=err-((y*2)-1); y=y+1 + if err<0 then err=err+2*(x-1); x=x-1 end + end + local result, row = {}, 0 + while row0 then love.graphics.rectangle('fill',0,s.y,math.min(s.left,w),s.height)end + if s.right 0) then return end + local _, smoke = self:_worldSsAnneArt(cam) + if not (smoke and self.ssAnneSmokeQuads) then return end + love.graphics.setColor(1, 1, 1, 1) + for _, puff in ipairs(fx.smoke) do + local age = puff.age or 0 + local frame + if age < 10 then frame = 1 + elseif age < 30 then frame = 2 + elseif age < 50 then frame = 3 + else frame = 4 end + local cx = puff.x + math.floor(age / 4) + love.graphics.draw(smoke, self.ssAnneSmokeQuads[frame], + math.floor(cx - 8), 78 - 8) + end +end + +-- the watering can's water: droplets arcing down onto the soil, and a +-- sparkle on the last few frames as it soaks in +function OverworldState:_worldFxWater(cam) + if not self.waterAnim then return end + local wa = self.waterAnim + local total = wa.total or 40 + local age = total - wa.frames + local dx = wa.x * 16 - cam.x + local dy = wa.y * 16 - cam.y + love.graphics.setColor(0.55, 0.80, 0.98, 0.95) + for i = 0, 5 do + -- each droplet falls on its own phase, so they read as a stream + local t = ((age * 3 + i * 7) % 24) / 24 + local px = dx + 2 + i * 2 + t * 10 + local py = dy - 6 + t * 20 + if py < dy + 16 then + love.graphics.rectangle("fill", px, py, 2, 3) + end + end + -- the soak: a brief bright ring once the water has been falling a while + if age > total * 0.55 then + local k = (age - total * 0.55) / (total * 0.45) + love.graphics.setColor(1, 1, 1, 0.8 * (1 - k)) + love.graphics.rectangle("fill", dx + 2, dy + 12, 12, 2) + end + love.graphics.setColor(1, 1, 1, 1) +end + +-- the cut tree splitting apart (AnimCut): top half slides right, +-- bottom half slides left, 1px per frame, flickering as they go +function OverworldState:_worldFxCutTree(cam) + if not self.cutAnim then return end + local fxDef = Game.data.field.overworldFx + local tree = fxDef and fxDef.cutTree + if not tree then return end + if self.cutTreeImg == nil then + local ok, img = pcall(love.graphics.newImage, tree.path) + self.cutTreeImg = ok and img or false + end + local img = self.cutTreeImg + if not img then return end + if not self.cutTreeQuads then + local w, h = img:getWidth(), img:getHeight() + self.cutTreeQuads = { + love.graphics.newQuad(0, 0, 16, 8, w, h), -- top half + love.graphics.newQuad(0, 8, 16, 8, w, h), -- bottom half + } + end + local ca = self.cutAnim + local off = (ca.total or 8) - ca.frames + local dx = ca.x * 16 - cam.x + local dy = ca.y * 16 - cam.y + local flicker = ca.frames % 2 == 0 + love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55) + love.graphics.draw(img, self.cutTreeQuads[1], dx + off, dy) + love.graphics.draw(img, self.cutTreeQuads[2], dx - off, dy + 8) + love.graphics.setColor(1, 1, 1, 1) +end + +-- the "!" bubble above a trainer who spotted the player +-- the tile-anchored sparkle: an OAM sprite in the cartridge, so it rides +-- the camera the way a body does rather than the background's shake +function OverworldState:_worldFxSparkle(cam) + love.graphics.setColor(1, 1, 1, 1) + self:drawSparkles(cam.x, cam.y) +end + +-- ...and ONE of them, for the two paths that cannot draw the set in screen +-- space: each sparkle sits on its own cell, so a projected scene has to +-- anchor each one separately. The upvalue is set immediately before each +-- call and cleared after, which is the same shape the emote path uses for +-- the one NPC it belongs to. +function OverworldState:_worldFxSparkleOne(cam, item) + local s = item + if not s then return end + local sprite = self:sparkleSprite() + if not sprite then return end + local frame = self:sparkleFrame(s.clock) + if not frame then return end + love.graphics.setColor(1, 1, 1, 1) + sprite:drawFixedFrame(s.px, s.py + 4, cam.x, cam.y, frame) +end + +-- THE POKE RADAR'S PATCHES, one at a time like the sparkles +function OverworldState:_worldFxRadarOne(cam, item) + local pt = item + if not pt then return end + require("src.world.Gen4Radar").drawPatch(pt, pt.x * 16 - cam.x, pt.y * 16 - cam.y, + love.timer and love.timer.getTime() * 60 or 0) +end + +function OverworldState:_worldFxRadar(cam) + for _, pt in ipairs((self.gen4Radar and self.gen4Radar.active and self.gen4Radar.patches) or {}) do + if pt.active then self:_worldFxRadarOne(cam, pt) end + end + radarOne = nil +end + +function OverworldState:_worldFxEmote(cam) + if not (self.emote and self.emote.npc) then return end + -- bubble = false is a silent hold (a Pikachu emotion that plays a + -- cry with no bubble still pauses the world for its beat) + if self.emote.bubble == false then return end + local npc = self.emote.npc + -- SINNOH'S OWN BUBBLE, ON TOP OF THE HEAD, IN THE GROUND'S PROJECTION. + -- + -- Reported from play: *"were missing the proper exclamation point above + -- trainers when they see you for battle and its not above their heads"*. + -- Platinum had neither art source below, so it fell to the hand-drawn box, + -- placed with flat 2D arithmetic that knows nothing of terrain height or + -- the tilt -- so it landed across the face. + -- + -- The cartridge (src/overlay005/ov5_021F5A10.c, see Gen4Emotes): a 16x16 + -- billboard from fldeff.narc whose plane rises 0..16 from an anchor 32 + -- units above the owner's drawn position -- the top of a 32-unit + -- character -- bouncing 6, 10, 12, 12, 10, 6, 0 and then holding. So the + -- bubble's bottom edge sits on the sprite's top edge, centred on it, and + -- it is drawn through exactly the transform the sprite is: the same + -- terrain rise, and `freeEntity` when a 3D camera owns the frame. + local g4emotes = Game.data and Game.data.gen4_emotes + local g4ground = self.map and self.map.renderer and self.map.renderer.gen4Ground + if g4emotes and g4ground and npc.sprite then + local Gen4Emotes = require("src.import.Gen4Emotes") + local kind = (self.emote.bubble == "double" or self.emote.bubble == 2) + and g4emotes.double and "double" or "exclamation" + -- BW's "?", note and "..." by name (Gen5MoveExec), when the cache has them + local named = self.emote.bubble + if type(named) == "string" and named ~= "double" and type(g4emotes[named]) == "table" then + kind = named + end + local record = g4emotes[kind] + self.gen4EmoteImages = self.gen4EmoteImages or {} + local img = self.gen4EmoteImages[kind] + if img == nil and record and record.rgba then + local okI, made = pcall(function() + local data = love.image.newImageData(record.width, record.height, + "rgba8", record.rgba) + local image = love.graphics.newImage(data) + image:setFilter("nearest", "nearest") + return image + end) + img = okI and made or false + self.gen4EmoteImages[kind] = img + end + if img then + local age = math.max(0, (self.emote.totalFrames or Gen4Emotes.FRAMES) + - (self.emote.frames or 0)) + -- HEARTGOLD / SOULSILVER: the cartridge's own anchor, 32 world units + -- over the owner through the camera now drawing, the player + -- included (src/world/Gen4EmoteDraw.lua); Platinum below, unchanged + local EmoteDraw = require("src.world.Gen4EmoteDraw") + if EmoteDraw.applies(self.map) then + EmoteDraw.draw(self, g4ground, npc, cam, record, img, age) + return + end + local bx, by, rise = OverworldState.gen4EmoteSpot(g4ground, npc, cam, + record, age) + local function draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, bx, by) + end + local free = g4ground.freeMode and g4ground:freeMode() + if free then + -- Depth-tested where the cartridge's billboard actually is -- the + -- middle of a bubble 32 units up -- not at its trainer's feet. + g4ground:freeEntity(npc.px or 0, npc.py or 0, cam.x, cam.y, rise, draw, + Gen4Emotes.LIFT + 8) + else + draw() + end + return + end + end + -- WHERE IT HANGS, which the cartridge states outright. + -- + -- Reported from play: the bubble "should appear above the trainers head". + -- It was four pixels right of them and six low, which on a 16x32 walker + -- put it across the face rather than over it. + -- + -- The icon's own sprite callback (0B4724) copies the owner's position + -- every frame and offsets it by exactly one thing: + -- + -- icon->x = owner->x + -- icon->y = owner->y - 16 + -- + -- and a GBA sprite's x/y is its CENTRE (the corner comes from + -- centerToCornerVec), so this is "the icon's centre sits sixteen pixels + -- above the owner's" -- which is a different top-left offset for a 16x16 + -- object than for a 16x32 one, and is why one number could never be right + -- for both. Stated as centres it falls out the same for either: the x + -- offsets cancel whatever the sprite's width, and the y is the owner's + -- half-height less the icon's eight plus the sixteen. + local half = math.floor(((npc.sprite and npc.sprite.tileH) or 16) / 2) + local ex = npc.px - cam.x + local ey = npc.py - cam.y - 12 - half + local bubble = Game.data.field.emotionBubbles + local drawn = false + + -- HOENN'S OWN BUBBLE, which is not on the Game Boy's sheet. + -- + -- The "!" over a trainer's head is three 16x16 icons hanging off a sprite + -- template that only a field-effect script reaches (see extractEmotes) -- + -- which is why every sweep of the field-effect tables missed it. A Gen 3 + -- dataset that has them draws them; one that does not falls through to + -- the Game Boy sheet and then to the box below, exactly as before. + local icons = (Game.data.constants or {}).gen3Emotes + if icons then + local role = GEN3_EMOTE_ROLES[self.emote.bubble or 1] or "exclamation" + local record = icons[role] + local path = record and record.image + if path then + self.gen3EmoteImages = self.gen3EmoteImages or {} + local held = self.gen3EmoteImages[path] + if held == nil then + local okImg, img = pcall(require("src.render.Assets").image, path) + held = (okImg and img) or false + self.gen3EmoteImages[path] = held + end + if held then + local age = math.max(0, (self.emote.totalFrames or 60) + - self.emote.frames) + local frame = 0 + local left = age + for _, step in ipairs(record.timeline or {}) do + local duration = tonumber(step[2]) or 1 + if left < duration then + frame = tonumber(step[1]) or 0 + break + end + left = left - duration + frame = tonumber(step[1]) or frame + end + -- SpriteCB_TrainerIcons starts data[3] at -5, accumulates it into + -- data[4], and increments that velocity until the offset returns 0. + local bounce, velocity = 0, -5 + for _ = 1, math.min(age + 1, 11) do + bounce = bounce + velocity + if bounce ~= 0 then velocity = velocity + 1 else velocity = 0 end + end + local fw, fh = tonumber(record.width) or 16, tonumber(record.height) or 16 + local iw, ih = held:getDimensions() + local q = nil + if (tonumber(record.frames) or 1) > 1 then + self.gen3EmoteQuads = self.gen3EmoteQuads or {} + local key = path .. ":" .. tostring(frame) + q = self.gen3EmoteQuads[key] + if not q then + q = love.graphics.newQuad(frame * fw, 0, fw, fh, iw, ih) + self.gen3EmoteQuads[key] = q + end + end + love.graphics.setColor(1, 1, 1, 1) + if q then love.graphics.draw(held, q, ex, ey + bounce) + else love.graphics.draw(held, ex, ey + bounce) end + drawn = true + end + end + end + if not drawn and bubble and bubble.path then + local ok, img = pcall(function() + self.emoteImg = self.emoteImg or obpEmoteImage(bubble.path) + return self.emoteImg + end) + -- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command + -- picks question/happy crops instead + local bi = self.emote.bubble or 1 + local rect = bubble.bubbles and bubble.bubbles[bi] + if ok and img and rect then + love.graphics.setColor(1, 1, 1, 1) + -- one Quad per bubble crop, cached: this draws every frame the "!" + -- (or the emote-command crops) is up, so a fresh Quad here churned + -- the GC. The bubble set is small and fixed, so the cache is bounded. + self.emoteQuads = self.emoteQuads or {} + local q = self.emoteQuads[bi] + if not q then + q = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h, + img:getDimensions()) + self.emoteQuads[bi] = q + end + love.graphics.draw(img, q, ex, ey) + drawn = true + end + end + if not drawn then + local Font = require("src.render.Font") + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", ex, ey, 10, 12) + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("line", ex + 0.5, ey + 0.5, 10, 12) + Font.draw("!", ex + 1, ey + 2) + love.graphics.setColor(1, 1, 1, 1) + end +end + +-- the FLY bird sweeping off with the player +function OverworldState:_worldFxBird(cam) + if not self.flyAnim then return end + -- FireRed has a dedicated five-frame 64x64 field-effect bird. Frames 1 + -- and 3 already contain Red/Leaf riding it during fly-out, so use the + -- cartridge sheet directly instead of substituting the selected mon's + -- battle front sprite. + local frlg = Game.data.constants and Game.data.constants.gen3FRLGFlyBird + if frlg and frlg.path then + if self.flyBirdImg == nil then + local ok, img = pcall(love.graphics.newImage, frlg.path) + self.flyBirdImg = ok and img or false + end + if self.flyBirdImg then + local gender = ((Game.save or {}).player or {}).gender == "girl" + and "girl" or "boy" + local frame = ((frlg.flyOut or {})[gender]) or (gender == "girl" and 3 or 1) + local fw = frlg.frameWidth or 64 + local fh = frlg.frameHeight or 64 + self.flyBirdQuads = self.flyBirdQuads or {} + if not self.flyBirdQuads[frame] then + local iw, ih = self.flyBirdImg:getDimensions() + self.flyBirdQuads[frame] = love.graphics.newQuad( + 0, frame * fh, fw, fh, iw, ih) + end + local t = math.max(0, math.min(48, 48 - self.flyAnim.frames)) + local reach = 16 + local dx, dy + if t <= reach then + local k = 1 - t / reach + dx, dy = -96 * k, -72 * k + else + local k = (t - reach) / (48 - reach) + dx, dy = -120 * k * k, -96 * k * k + end + local px = math.floor(self.player.px - cam.x + 8 - fw / 2 + dx) + local py = math.floor(self.player.py - cam.y + 8 - fh / 2 + dy) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(self.flyBirdImg, self.flyBirdQuads[frame], px, py) + require("src.render.PaletteFX").markTrueColor(px, py, fw, fh) + return + end + end + local birdId = FieldDefaults.fieldValue(Game.data, "playerSprites", "fly") + if not self.birdSprite and birdId and Game.data.sprites[birdId] then + local SR = require("src.render.SpriteRenderer") + self.birdSprite = SR.new(Game.data.sprites[birdId]) + end + if self.birdSprite then + local t = 48 - self.flyAnim.frames + local bx = self.player.px - t * 4 + local by = self.player.py - math.floor(t * 1.5) + love.graphics.setColor(1, 1, 1, 1) + self.birdSprite:draw(bx, by, cam.x, cam.y, "left", + math.floor(t / 4) % 2, false) + return + end + + -- Legacy fallback for datasets that predate the ROM-extracted FireRed + -- bird above. Keep it for old caches/other games, but current FireRed + -- imports should never reach this branch. + -- + -- Reported from play, twice: "ensure the animation of the flying type + -- pokemon swooping up my player plays after the fly hm transition" and + -- then "Theres still no fly swoop". The machinery above has been here the + -- whole time and has never had a sprite to draw: it wants + -- playerSprites.fly, and no Gen 3 cache carries one. + -- + -- IT WAS LOOKED FOR PROPERLY THIS TIME, and it is not where a sheet like + -- that would be. Emerald's fly bird is not an object-event graphics row + -- -- the three 64x64 rows in that table are Rayquaza twice and the cable + -- car, and every 32x32 row is a person or a legendary -- and it is not + -- hanging off any field-effect script either: not one of the sixty-seven + -- scripts loads tiles, and no native any of them calls reaches a sprite + -- template with frames that size. It is loaded by C, from a sheet only + -- the fly task names, and nothing the import can currently follow gets to + -- it. + -- + -- So the departure is drawn with something this port certainly does have + -- and the cartridge's own bird is not: the FRONT SPRITE OF THE POKEMON + -- THAT IS ACTUALLY CARRYING YOU -- the same one the field-move sweep just + -- showed. It stoops in from the upper left, meets the player, and lifts + -- away with them. Reconstructed, and said so; the moment the bird IS + -- found, playerSprites.fly makes the branch above win again and this is + -- never reached. + local mon = self.flyAnim.mon + if not mon then return end + if self.flyMonImg == nil then + local okPath, path = pcall(function() + return require("src.pokemon.Sprites").path( + Game.data, mon.species, "front", { mon = mon }) + end) + local img + if okPath and type(path) == "string" then + local okImg, loaded = pcall(love.graphics.newImage, path) + img = okImg and loaded or nil + end + self.flyMonImg = img or false + end + if not self.flyMonImg then return end + local total = 48 + local t = math.max(0, math.min(total, total - self.flyAnim.frames)) + -- the stoop: in over sixteen frames, away over the rest, and the two + -- meet on the player rather than anywhere else + local reach = 16 + local dx, dy + if t <= reach then + local k = 1 - t / reach + dx, dy = -96 * k, -72 * k + else + local k = (t - reach) / (total - reach) + dx, dy = -120 * k * k, -96 * k * k + end + local iw, ih = self.flyMonImg:getDimensions() + local px = math.floor(self.player.px - cam.x + 8 - iw / 2 + dx) + local py = math.floor(self.player.py - cam.y + 8 - ih / 2 + dy) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(self.flyMonImg, px, py) + require("src.render.PaletteFX").markTrueColor(px, py, iw, ih) +end + +-- fishing pose: the rod tile over the faced water (gfx/fishing.asm) +function OverworldState:_worldFxRod(cam) + if not self.fishing then return end + local fx = Game.data.field.overworldFx + local rod = fx and fx.fishingRod + if rod then + if self.rodImg == nil then + local ok, img = pcall(love.graphics.newImage, rod.path) + self.rodImg = ok and img or false + end + if self.rodImg then + local p = self.player + local oam = ROD_OAM[self.fishing.facing] or ROD_OAM.down + if not self.rodQuads then + -- one quad per 8x8 tile of the stacked sheet (ROD_OAM.tile) + local iw, ih = self.rodImg:getDimensions() + self.rodQuads = {} + for i = 0, math.floor(ih / 8) - 1 do + self.rodQuads[i] = love.graphics.newQuad(0, i * 8, 8, 8, iw, ih) + end + end + local quad = self.rodQuads[oam.tile] + -- the sprite's top-left is 4px above its cell (SpriteRenderer:draw) + local rx = p.px - cam.x + oam.dx + local ry = p.py - cam.y - 4 + oam.dy + love.graphics.setColor(1, 1, 1, 1) + if quad and oam.flip then + love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1) + elseif quad then + love.graphics.draw(self.rodImg, quad, rx, ry) + end + end + end +end + +function OverworldState:_worldRise(e, groundForRise, groundSin, cam) + if not groundForRise then return 0 end + local rise = groundForRise:rise(e.px + (e.shiftPx or 0) + 8, e.py + 8) + -- A SPRITE OFFSET (MapObject_SetSpritePosOffset: the surf mount's ride, + -- the Distortion World cast's flights) in the field view: up by the + -- camera's height lean, toward it by the ground's + local so = e.spriteOffset + if so and (so.lift or so.oz) then + rise = rise + (so.lift or 0) * (groundForRise.heightScale or 0) + - (so.oz or 0) * (groundForRise.groundScale or 1) + end + if groundSin >= 1 then return rise end + return (e.py - cam.y) * (1 - groundSin) + rise +end + +function OverworldState:_worldDrawEntity(e, cam, bgY, freeGround, groundForRise, groundSin, grassColors, spriteDepthGround, shadows, riseOf, field) + if not (self.flyAnim and self:hasFlyBird() and e == self.player) + and not e.hidden and not e.berryVisualEmpty and not plotIsEmpty(e) then + -- A FREE CAMERA PLACES ITS OWN CHARACTERS. + -- + -- Reported from play: *"isnt keeping the player the right size"*, with a + -- screenshot of a third-person Twinleaf in correct perspective and two + -- characters standing in it at flat 2D scale. They were drawn by the + -- line below, which places every sprite at `py - camY` -- the field + -- camera's 1:1 mapping, and simply not this camera's. + -- + -- `freeEntity` runs THIS SAME DRAW inside a transform, so nothing here + -- learns about projection and the entity keeps owning its own sprite, + -- frame and palette. + -- + -- In FIRST person the player is the eye, so drawing them puts their own + -- back across the middle of the view. + if freeGround then + if not (freeGround:freeMode() == "first" and e == self.player) then + local rise = riseOf(self, e, groundForRise, groundSin, cam) + -- ...AND WHICH WAY THEY FACE ON SCREEN. + -- + -- Reported from play: *"when i orbit the camera 180 degrees if i + -- walk left it looks like hes walking right"*. `screenToWorld` + -- turns the PRESS into a world direction, so the step is right; + -- the sprite then showed that world facing under a camera that is + -- no longer looking north, so the artwork was the one thing still + -- in the old frame. `worldToScreen` is its inverse, and the pair + -- are inverses because the camera sits between them. + -- + -- Set and restored around the draw rather than threaded through, + -- because `facing` is what every pose, animation and follower path + -- in the entity reads, and none of them should learn about cameras. + local view, saved = freeGround.view3d, e.facing + if view and view.worldToScreen then + e.facing = view:worldToScreen(saved) + end + freeGround:freeEntity((e.px or 0), (e.py or 0), cam.x, cam.y, rise, + function() e:draw(cam.x, cam.y + rise) end, + nil, require("src.world.Gen4SurfMount").poseFor(e, e == self.player and self.dwPose or nil)) + e.facing = saved + end + -- Deliberately without the flat shadow and the grass overdraw below: + -- both are drawn in screen space against a ground plane this camera + -- does not have, and a shadow in the wrong place is worse than none. + return + end + -- IN THE WORLD'S DEPTH on Sinnoh's cartridge camera + -- (Gen4Ground:beginSprites): the billboard origin's depth, so a prop in + -- front of a character hides them; nil leaves the draw as it was + local depth = spriteDepthGround and spriteDepthGround:billboardDepth( + (e.px or 0) + (e.shiftPx or 0), e.py or 0, + e.spriteOffset and e.spriteOffset.lift, e.spriteOffset and e.spriteOffset.oz) + -- UNDER the sprite, and lifted by the same terrain rise it is, so a + -- character on a slope keeps their feet on it. + if shadows then + if depth then + spriteDepthGround:withSpriteDepth(depth, function() + shadows:draw(self.map, e, cam.x, cam.y, e == self.player, riseOf(self, e, groundForRise, groundSin, cam)) + end, false) + else + shadows:draw(self.map, e, cam.x, cam.y, e == self.player, riseOf(self, e, groundForRise, groundSin, cam)) + end + end + if depth then + spriteDepthGround:withSpriteDepth(depth, function() e:draw(cam.x, cam.y + riseOf(self, e, groundForRise, groundSin, cam)) end) + else + e:draw(cam.x, cam.y + riseOf(self, e, groundForRise, groundSin, cam)) + end + if field and field.actorsOnly then return end + -- tall grass overdraws the sprite's feet (GB sprite priority); + -- the overdraw is BG tiles, so it rides the shake offset too + love.graphics.setColor(1, 1, 1, 1) + if self.map:isGrassCell(e.cellX, e.cellY) then + if not self:drawGen3Grass(e.cellX, e.cellY, cam.x, bgY, + self:gen3GrassFrame(e, false)) then + if field then field.tiles:cell(e.cellX,e.cellY,cam,bgY) + else self.map.renderer:drawCellBottom(e.cellX, e.cellY, cam.x, bgY)end + if grassColors then + self.map.renderer:markCellBottomRedraw(e.cellX, e.cellY, + cam.x, bgY, grassColors) + end + end + end + if e.targetX and self.map:isGrassCell(e.targetX, e.targetY) then + if not self:drawGen3Grass(e.targetX, e.targetY, cam.x, bgY, + self:gen3GrassFrame(e, true)) then + if field then field.tiles:cell(e.targetX,e.targetY,cam,bgY) + else self.map.renderer:drawCellBottom(e.targetX, e.targetY, cam.x, bgY)end + if grassColors then + self.map.renderer:markCellBottomRedraw(e.targetX, e.targetY, + cam.x, bgY, grassColors) + end + end + end + end +end + +function OverworldState:_worldAim(renderer, ox, oy, feetX, feetZ) + local g4 = renderer and renderer.gen4Ground + if g4 then + if ox then g4.focusX, g4.focusZ = feetX - ox, feetZ - oy + else g4.focusX, g4.focusZ = nil, nil end + end +end + +-- Private cell columns never cross the public rippleCells seam. +local nativeRippleCells = OverworldState.rippleCells +function OverworldState:_worldAddCoverCell(pool, cx, cy) + local key = cx * 4096 + cy + if pool.seen[key] then return end + pool.seen[key] = true + local n = pool.n + 1 + pool.n, pool.x[n], pool.y[n] = n, cx, cy +end + +function OverworldState:_worldReflectionCover(reflectors, count, renderer, cam, bgY) + local pool = self._worldCover + if not pool then + pool = {x={}, y={}, seen={}, n=0} + self._worldCover = pool + elseif pool.busy then + pool = {x={}, y={}, seen={}, n=0} + end + pool.busy = true + local oldN = pool.n + pool.n = 0 + for key in pairs(pool.seen) do pool.seen[key] = nil end + for i = 1, count do + local cx0, cy0, cx1, cy1 = self:reflectionRect(reflectors[i]) + if cx0 then + for cy = cy0, cy1 do + for cx = cx0, cx1 do self:_worldAddCoverCell(pool, cx, cy) end + end + end + end + for _, r in ipairs(self.ripples or {}) do + for cy = math.floor(r.py / 16), math.floor((r.py + 15) / 16) do + for cx = math.floor(r.px / 16), math.floor((r.px + 15) / 16) do + self:_worldAddCoverCell(pool, cx, cy) + end + end + end + for i = pool.n + 1, oldN do pool.x[i], pool.y[i] = nil, nil end + for i = 1, pool.n do + renderer:drawReflectionCover(pool.x[i], pool.y[i], cam.x, bgY) + end + pool.busy = nil +end + +function OverworldState:_worldEntityLoop(drawEntity, cam, bgY, freeGround, groundForRise, + groundSin, grassColors, spriteDepthGround, shadows, riseOf) + local onTop + for _, e in ipairs(self.entities) do + if self:gen3AboveTopLayer(e) then + onTop = onTop or {} + onTop[#onTop + 1] = e + else + drawEntity(self, e, cam, bgY, freeGround, groundForRise, groundSin, + grassColors, spriteDepthGround, shadows, riseOf) + end + end + return onTop +end + +local function drawWorldBody(self, cadenceConsumed) -- Dark-map BG shade shift, armed for the whole frame before anything draws. -- home/fade.asm's LoadGBPal writes ONE rBGP for the screen, so terrain, the -- characters standing on it and any dialog over them darken together (#322); @@ -16866,7 +17685,7 @@ function OverworldState:drawWorld() -- TileRenderer.tick uses wall-clock 60Hz steps so display refresh rate -- does not speed or slow the cycle (issue #4). local TR = tileRenderer() - TR.tick() + if not cadenceConsumed then TR.tick()end -- let the renderer know whether a spinner puzzle is currently sliding -- the player, so it can flicker the arrow tiles between the blur and -- static graphic (engine/overworld/spinners.asm LoadSpinnerArrowTiles) @@ -16917,6 +17736,7 @@ function OverworldState:drawWorld() -- path below on the vanilla flat/tilt draw local override if not pipelineId then + local closeTiles = FrameProfile.section(" world: tiles") self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh) self.map.renderer:draw(cam.x, bgY, vw, vh) for _, nb in ipairs(self.neighbors) do @@ -16926,6 +17746,7 @@ function OverworldState:drawWorld() if self.g3MirageTower then require("src.world.Gen3MirageTower").drawTower(self, cam) end + closeTiles() end -- per-billboard SGB palette source; only needed (and only paid for) when -- tilting. nil headless / on stale palettes -> billboards go uncolorized. @@ -16943,638 +17764,23 @@ function OverworldState:drawWorld() -- in their historical order. (Bodies are byte-identical to the pre-tilt -- inline code, so the flat draw sequence is unchanged.) - -- the Pokémon Center heal machine (PokeCenterOAMData): the monitor - -- tile over the machine's screen and one ball per healed mon in two - -- mirrored columns, all blinking during the jingle flash. The GB - -- draws it at fixed screen coords with the player's cell BG-aligned - -- at (64,64); anchoring those coords to where the player stood keeps - -- the overlay on the machine at any zoom. - local function fxHeal() - if not self.healAnim then return end - local ha = self.healAnim - -- HOENN'S MACHINE IS NOT AN OAM TABLE. - -- - -- Everything below this block is the Game Boy's: two 8x8 tiles out of - -- field.overworldFx.healMachine at coordinates a table in the ROM gives. - -- Emerald has no such table -- its machine is a field effect whose two - -- sprites are created by machine code, and where they go is four - -- immediates INSIDE that code. extractHealMachine reads them, and the - -- record it writes carries the positions already measured from the corner - -- of the player's own cell, so nothing here has to know where the - -- cartridge's camera puts anybody. - if GameVersion.isGen4() then return end - if self:drawGen3Heal(ha, cam) then return end - local fxDef = Game.data.field.overworldFx - if self.healMachineImg == nil and fxDef and fxDef.healMachine then - local ok, img = pcall(love.graphics.newImage, fxDef.healMachine.path) - self.healMachineImg = ok and img or false - end - local img = self.healMachineImg - if img then - if not self.healMachineQuads then - local w, h = img:getWidth(), img:getHeight() - self.healMachineQuads = { - love.graphics.newQuad(0, 0, 8, 8, w, h), -- monitor ($7c) - love.graphics.newQuad(0, 8, 8, 8, w, h), -- ball ($7d) - } - end - -- The machine carries its OWN four colours. HealMachineAnim.LoadPalettes - -- (04:$6434) copies them over OBJ palette 6 before the animation runs -- - -- which is why every row of its OAM table names palette 6 -- and they are - -- white / light orange / red / black: a Poke Ball. Nothing was applying - -- any palette at all, so the sheet drew in the DMG greys it is decoded - -- in and a Pokemon Center healed with grey balls. - local def = fxDef and fxDef.healMachine - local objPal = def and def.gen2ObjPal - local basePal = (objPal and objPal[1] and objPal) or PaletteFX.GRAYS - -- the jingle flash recolors the machine sprites in place - -- (FlashSprite8Times XORs rOBP1; the sprites never disappear): - -- ha.visible == false is the flashed half of each beat, drawn with - -- the light/dark shades swapped instead of skipped - local shader - if (not ha.visible) or basePal ~= PaletteFX.GRAYS then - shader = PaletteFX.shader() - if shader then - PaletteFX.sendColors(shader, ha.visible and basePal - or PaletteFX.permute(basePal, HEAL_FLASH_MAP)) - love.graphics.setShader(shader) - end - end - -- TileRenderer windows with -floor(cam), so the overlay must use the - -- same snap or a fractional camera (odd fill/tilt view sizes) parks - -- the balls a pixel off the machine tiles - local ox = ha.px - 64 - math.floor(cam.x) - local oy = ha.py - 64 - math.floor(cam.y) - -- Gen2 puts these sprites somewhere else entirely. Its - -- HealMachineAnim.PC_ElmsLab_OAM (04:$63BC) has the monitor at x 26/30 - -- and the balls at x 24/32; Gen1's PokeCenterOAMData has the balls at - -- 40/48. The extractor reads that OAM table straight off the cartridge - -- into field.overworldFx.healMachine, so use it where it exists and keep - -- the Gen1 constants as the fallback -- drawn at Gen1's offsets the - -- whole overlay sat a full 16px right of the Gen2 machine. - local monitor = (def and def.monitor) or { { 44, 20 } } - local balls = (def and def.balls) or HEAL_BALL_XY - love.graphics.setColor(1, 1, 1, 1) - for _, m in ipairs(monitor) do - love.graphics.draw(img, self.healMachineQuads[1], ox + m[1], oy + m[2]) - end - for i = 1, math.min(ha.lit, #balls) do - local b = balls[i] - if b[3] then -- right column: OAM_XFLIP - love.graphics.draw(img, self.healMachineQuads[2], - ox + b[1] + 8, oy + b[2], 0, -1, 1) - else - love.graphics.draw(img, self.healMachineQuads[2], - ox + b[1], oy + b[2]) - end - end - if shader then love.graphics.setShader() end - end - end - - -- the Cut/boulder dust puff: the smoke tile drawn 2x2 over the cell, - -- flickering (AnimateBoulderDust XORs the OBJ palette every step) - local function fxDust() - if not self.dustAnim then return end - local fxDef = Game.data.field.overworldFx - local smoke = fxDef and fxDef.smoke - if smoke then - if self.smokeImg == nil then - local ok, img = pcall(love.graphics.newImage, smoke.path) - self.smokeImg = ok and img or false - end - if self.smokeImg then - local da = self.dustAnim - local dx = da.x * 16 - cam.x - local dy = da.y * 16 - cam.y - local flicker = math.floor(da.frames / 4) % 2 == 0 - love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55) - for i = 0, 1 do - for j = 0, 1 do - love.graphics.draw(self.smokeImg, dx + i * 8, dy + j * 8) - end - end - love.graphics.setColor(1, 1, 1, 1) - end - end - end - - -- FireRed's S.S. Anne departure uses two ordinary OBJ sprites that are not - -- object events: a looping 16x32 wake behind the ship and 16x16 smoke puffs - -- from its funnel. Special 401 owns their exact lifetime/positions; this - -- pass only turns that state into the extracted ROM frames. - local function ssAnneArt() - local def = Game.data.constants and Game.data.constants.gen3SSAnneFx - if not def then return nil end - if self.ssAnneWakeImg == nil then - local ok, img = pcall(Assets.image, def.wake) - self.ssAnneWakeImg = ok and img or false - end - if self.ssAnneSmokeImg == nil then - local ok, img = pcall(Assets.image, def.smoke) - self.ssAnneSmokeImg = ok and img or false - end - if self.ssAnneWakeImg and not self.ssAnneWakeQuads then - local w, h = self.ssAnneWakeImg:getDimensions() - self.ssAnneWakeQuads = { - love.graphics.newQuad(0, 0, 16, 32, w, h), - love.graphics.newQuad(16, 0, 16, 32, w, h), - } - end - if self.ssAnneSmokeImg and not self.ssAnneSmokeQuads then - local w, h = self.ssAnneSmokeImg:getDimensions() - self.ssAnneSmokeQuads = {} - for i = 0, 3 do - self.ssAnneSmokeQuads[i + 1] = love.graphics.newQuad(i * 16, 0, 16, 16, w, h) - end - end - return self.ssAnneWakeImg, self.ssAnneSmokeImg - end - - local function fxSSAnneWake() - local fx = self.ssAnneDepartureFx - if not (fx and fx.boat) then return end - local wake = ssAnneArt() - if not (wake and self.ssAnneWakeQuads) then return end - local age = math.min(fx.wakeAge or 0, 132) - local cx = fx.boat.px + (fx.boat.shiftPx or 0) + 8 - cam.x - + 80 + math.floor(age / 6) - if cx < -18 then return end - local frame = math.floor(age / 12) % 2 + 1 - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(wake, self.ssAnneWakeQuads[frame], - math.floor(cx - 8), 109 - 16) - end - - local function fxSSAnneSmoke() - local fx = self.ssAnneDepartureFx - if not (fx and fx.smoke and #fx.smoke > 0) then return end - local _, smoke = ssAnneArt() - if not (smoke and self.ssAnneSmokeQuads) then return end - love.graphics.setColor(1, 1, 1, 1) - for _, puff in ipairs(fx.smoke) do - local age = puff.age or 0 - local frame - if age < 10 then frame = 1 - elseif age < 30 then frame = 2 - elseif age < 50 then frame = 3 - else frame = 4 end - local cx = puff.x + math.floor(age / 4) - love.graphics.draw(smoke, self.ssAnneSmokeQuads[frame], - math.floor(cx - 8), 78 - 8) - end - end - - -- the watering can's water: droplets arcing down onto the soil, and a - -- sparkle on the last few frames as it soaks in - local function fxWater() - if not self.waterAnim then return end - local wa = self.waterAnim - local total = wa.total or 40 - local age = total - wa.frames - local dx = wa.x * 16 - cam.x - local dy = wa.y * 16 - cam.y - love.graphics.setColor(0.55, 0.80, 0.98, 0.95) - for i = 0, 5 do - -- each droplet falls on its own phase, so they read as a stream - local t = ((age * 3 + i * 7) % 24) / 24 - local px = dx + 2 + i * 2 + t * 10 - local py = dy - 6 + t * 20 - if py < dy + 16 then - love.graphics.rectangle("fill", px, py, 2, 3) - end - end - -- the soak: a brief bright ring once the water has been falling a while - if age > total * 0.55 then - local k = (age - total * 0.55) / (total * 0.45) - love.graphics.setColor(1, 1, 1, 0.8 * (1 - k)) - love.graphics.rectangle("fill", dx + 2, dy + 12, 12, 2) - end - love.graphics.setColor(1, 1, 1, 1) - end - - -- the cut tree splitting apart (AnimCut): top half slides right, - -- bottom half slides left, 1px per frame, flickering as they go - local function fxCutTree() - if not self.cutAnim then return end - local fxDef = Game.data.field.overworldFx - local tree = fxDef and fxDef.cutTree - if not tree then return end - if self.cutTreeImg == nil then - local ok, img = pcall(love.graphics.newImage, tree.path) - self.cutTreeImg = ok and img or false - end - local img = self.cutTreeImg - if not img then return end - if not self.cutTreeQuads then - local w, h = img:getWidth(), img:getHeight() - self.cutTreeQuads = { - love.graphics.newQuad(0, 0, 16, 8, w, h), -- top half - love.graphics.newQuad(0, 8, 16, 8, w, h), -- bottom half - } - end - local ca = self.cutAnim - local off = (ca.total or 8) - ca.frames - local dx = ca.x * 16 - cam.x - local dy = ca.y * 16 - cam.y - local flicker = ca.frames % 2 == 0 - love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55) - love.graphics.draw(img, self.cutTreeQuads[1], dx + off, dy) - love.graphics.draw(img, self.cutTreeQuads[2], dx - off, dy + 8) - love.graphics.setColor(1, 1, 1, 1) - end - - -- the "!" bubble above a trainer who spotted the player - -- the tile-anchored sparkle: an OAM sprite in the cartridge, so it rides - -- the camera the way a body does rather than the background's shake - local function fxSparkle() - love.graphics.setColor(1, 1, 1, 1) - self:drawSparkles(cam.x, cam.y) - end - - -- ...and ONE of them, for the two paths that cannot draw the set in screen - -- space: each sparkle sits on its own cell, so a projected scene has to - -- anchor each one separately. The upvalue is set immediately before each - -- call and cleared after, which is the same shape the emote path uses for - -- the one NPC it belongs to. - local sparkleOne = nil - local function fxSparkleOne() - local s = sparkleOne - if not s then return end - local sprite = self:sparkleSprite() - if not sprite then return end - local frame = self:sparkleFrame(s.clock) - if not frame then return end - love.graphics.setColor(1, 1, 1, 1) - sprite:drawFixedFrame(s.px, s.py + 4, cam.x, cam.y, frame) - end - - -- THE POKE RADAR'S PATCHES, one at a time like the sparkles - local radarOne = nil - local function fxRadarOne() - local pt = radarOne - if not pt then return end - require("src.world.Gen4Radar").drawPatch(pt, pt.x * 16 - cam.x, pt.y * 16 - cam.y, - love.timer and love.timer.getTime() * 60 or 0) - end - local function fxRadar() - for _, pt in ipairs((self.gen4Radar and self.gen4Radar.active and self.gen4Radar.patches) or {}) do - if pt.active then radarOne = pt; fxRadarOne() end - end - radarOne = nil - end - - local function fxEmote() - if not (self.emote and self.emote.npc) then return end - -- bubble = false is a silent hold (a Pikachu emotion that plays a - -- cry with no bubble still pauses the world for its beat) - if self.emote.bubble == false then return end - local npc = self.emote.npc - -- SINNOH'S OWN BUBBLE, ON TOP OF THE HEAD, IN THE GROUND'S PROJECTION. - -- - -- Reported from play: *"were missing the proper exclamation point above - -- trainers when they see you for battle and its not above their heads"*. - -- Platinum had neither art source below, so it fell to the hand-drawn box, - -- placed with flat 2D arithmetic that knows nothing of terrain height or - -- the tilt -- so it landed across the face. - -- - -- The cartridge (src/overlay005/ov5_021F5A10.c, see Gen4Emotes): a 16x16 - -- billboard from fldeff.narc whose plane rises 0..16 from an anchor 32 - -- units above the owner's drawn position -- the top of a 32-unit - -- character -- bouncing 6, 10, 12, 12, 10, 6, 0 and then holding. So the - -- bubble's bottom edge sits on the sprite's top edge, centred on it, and - -- it is drawn through exactly the transform the sprite is: the same - -- terrain rise, and `freeEntity` when a 3D camera owns the frame. - local g4emotes = Game.data and Game.data.gen4_emotes - local g4ground = self.map and self.map.renderer and self.map.renderer.gen4Ground - if g4emotes and g4ground and npc.sprite then - local Gen4Emotes = require("src.import.Gen4Emotes") - local kind = (self.emote.bubble == "double" or self.emote.bubble == 2) - and g4emotes.double and "double" or "exclamation" - -- BW's "?", note and "..." by name (Gen5MoveExec), when the cache has them - local named = self.emote.bubble - if type(named) == "string" and named ~= "double" and type(g4emotes[named]) == "table" then - kind = named - end - local record = g4emotes[kind] - self.gen4EmoteImages = self.gen4EmoteImages or {} - local img = self.gen4EmoteImages[kind] - if img == nil and record and record.rgba then - local okI, made = pcall(function() - local data = love.image.newImageData(record.width, record.height, - "rgba8", record.rgba) - local image = love.graphics.newImage(data) - image:setFilter("nearest", "nearest") - return image - end) - img = okI and made or false - self.gen4EmoteImages[kind] = img - end - if img then - local age = math.max(0, (self.emote.totalFrames or Gen4Emotes.FRAMES) - - (self.emote.frames or 0)) - -- HEARTGOLD / SOULSILVER: the cartridge's own anchor, 32 world units - -- over the owner through the camera now drawing, the player - -- included (src/world/Gen4EmoteDraw.lua); Platinum below, unchanged - local EmoteDraw = require("src.world.Gen4EmoteDraw") - if EmoteDraw.applies(self.map) then - EmoteDraw.draw(self, g4ground, npc, cam, record, img, age) - return - end - local bx, by, rise = OverworldState.gen4EmoteSpot(g4ground, npc, cam, - record, age) - local function draw() - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(img, bx, by) - end - local free = g4ground.freeMode and g4ground:freeMode() - if free then - -- Depth-tested where the cartridge's billboard actually is -- the - -- middle of a bubble 32 units up -- not at its trainer's feet. - g4ground:freeEntity(npc.px or 0, npc.py or 0, cam.x, cam.y, rise, draw, - Gen4Emotes.LIFT + 8) - else - draw() - end - return - end - end - -- WHERE IT HANGS, which the cartridge states outright. - -- - -- Reported from play: the bubble "should appear above the trainers head". - -- It was four pixels right of them and six low, which on a 16x32 walker - -- put it across the face rather than over it. - -- - -- The icon's own sprite callback (0B4724) copies the owner's position - -- every frame and offsets it by exactly one thing: - -- - -- icon->x = owner->x - -- icon->y = owner->y - 16 - -- - -- and a GBA sprite's x/y is its CENTRE (the corner comes from - -- centerToCornerVec), so this is "the icon's centre sits sixteen pixels - -- above the owner's" -- which is a different top-left offset for a 16x16 - -- object than for a 16x32 one, and is why one number could never be right - -- for both. Stated as centres it falls out the same for either: the x - -- offsets cancel whatever the sprite's width, and the y is the owner's - -- half-height less the icon's eight plus the sixteen. - local half = math.floor(((npc.sprite and npc.sprite.tileH) or 16) / 2) - local ex = npc.px - cam.x - local ey = npc.py - cam.y - 12 - half - local bubble = Game.data.field.emotionBubbles - local drawn = false - - -- HOENN'S OWN BUBBLE, which is not on the Game Boy's sheet. - -- - -- The "!" over a trainer's head is three 16x16 icons hanging off a sprite - -- template that only a field-effect script reaches (see extractEmotes) -- - -- which is why every sweep of the field-effect tables missed it. A Gen 3 - -- dataset that has them draws them; one that does not falls through to - -- the Game Boy sheet and then to the box below, exactly as before. - local icons = (Game.data.constants or {}).gen3Emotes - if icons then - local role = GEN3_EMOTE_ROLES[self.emote.bubble or 1] or "exclamation" - local record = icons[role] - local path = record and record.image - if path then - self.gen3EmoteImages = self.gen3EmoteImages or {} - local held = self.gen3EmoteImages[path] - if held == nil then - local okImg, img = pcall(require("src.render.Assets").image, path) - held = (okImg and img) or false - self.gen3EmoteImages[path] = held - end - if held then - local age = math.max(0, (self.emote.totalFrames or 60) - - self.emote.frames) - local frame = 0 - local left = age - for _, step in ipairs(record.timeline or {}) do - local duration = tonumber(step[2]) or 1 - if left < duration then - frame = tonumber(step[1]) or 0 - break - end - left = left - duration - frame = tonumber(step[1]) or frame - end - -- SpriteCB_TrainerIcons starts data[3] at -5, accumulates it into - -- data[4], and increments that velocity until the offset returns 0. - local bounce, velocity = 0, -5 - for _ = 1, math.min(age + 1, 11) do - bounce = bounce + velocity - if bounce ~= 0 then velocity = velocity + 1 else velocity = 0 end - end - local fw, fh = tonumber(record.width) or 16, tonumber(record.height) or 16 - local iw, ih = held:getDimensions() - local q = nil - if (tonumber(record.frames) or 1) > 1 then - self.gen3EmoteQuads = self.gen3EmoteQuads or {} - local key = path .. ":" .. tostring(frame) - q = self.gen3EmoteQuads[key] - if not q then - q = love.graphics.newQuad(frame * fw, 0, fw, fh, iw, ih) - self.gen3EmoteQuads[key] = q - end - end - love.graphics.setColor(1, 1, 1, 1) - if q then love.graphics.draw(held, q, ex, ey + bounce) - else love.graphics.draw(held, ex, ey + bounce) end - drawn = true - end - end - end - if not drawn and bubble and bubble.path then - local ok, img = pcall(function() - self.emoteImg = self.emoteImg or obpEmoteImage(bubble.path) - return self.emoteImg - end) - -- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command - -- picks question/happy crops instead - local bi = self.emote.bubble or 1 - local rect = bubble.bubbles and bubble.bubbles[bi] - if ok and img and rect then - love.graphics.setColor(1, 1, 1, 1) - -- one Quad per bubble crop, cached: this draws every frame the "!" - -- (or the emote-command crops) is up, so a fresh Quad here churned - -- the GC. The bubble set is small and fixed, so the cache is bounded. - self.emoteQuads = self.emoteQuads or {} - local q = self.emoteQuads[bi] - if not q then - q = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h, - img:getDimensions()) - self.emoteQuads[bi] = q - end - love.graphics.draw(img, q, ex, ey) - drawn = true - end - end - if not drawn then - local Font = require("src.render.Font") - love.graphics.setColor(1, 1, 1, 1) - love.graphics.rectangle("fill", ex, ey, 10, 12) - love.graphics.setColor(0, 0, 0, 1) - love.graphics.rectangle("line", ex + 0.5, ey + 0.5, 10, 12) - Font.draw("!", ex + 1, ey + 2) - love.graphics.setColor(1, 1, 1, 1) - end - end - - -- the FLY bird sweeping off with the player - local function fxBird() - if not self.flyAnim then return end - -- FireRed has a dedicated five-frame 64x64 field-effect bird. Frames 1 - -- and 3 already contain Red/Leaf riding it during fly-out, so use the - -- cartridge sheet directly instead of substituting the selected mon's - -- battle front sprite. - local frlg = Game.data.constants and Game.data.constants.gen3FRLGFlyBird - if frlg and frlg.path then - if self.flyBirdImg == nil then - local ok, img = pcall(love.graphics.newImage, frlg.path) - self.flyBirdImg = ok and img or false - end - if self.flyBirdImg then - local gender = ((Game.save or {}).player or {}).gender == "girl" - and "girl" or "boy" - local frame = ((frlg.flyOut or {})[gender]) or (gender == "girl" and 3 or 1) - local fw = frlg.frameWidth or 64 - local fh = frlg.frameHeight or 64 - self.flyBirdQuads = self.flyBirdQuads or {} - if not self.flyBirdQuads[frame] then - local iw, ih = self.flyBirdImg:getDimensions() - self.flyBirdQuads[frame] = love.graphics.newQuad( - 0, frame * fh, fw, fh, iw, ih) - end - local t = math.max(0, math.min(48, 48 - self.flyAnim.frames)) - local reach = 16 - local dx, dy - if t <= reach then - local k = 1 - t / reach - dx, dy = -96 * k, -72 * k - else - local k = (t - reach) / (48 - reach) - dx, dy = -120 * k * k, -96 * k * k - end - local px = math.floor(self.player.px - cam.x + 8 - fw / 2 + dx) - local py = math.floor(self.player.py - cam.y + 8 - fh / 2 + dy) - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(self.flyBirdImg, self.flyBirdQuads[frame], px, py) - require("src.render.PaletteFX").markTrueColor(px, py, fw, fh) - return - end - end - local birdId = FieldDefaults.fieldValue(Game.data, "playerSprites", "fly") - if not self.birdSprite and birdId and Game.data.sprites[birdId] then - local SR = require("src.render.SpriteRenderer") - self.birdSprite = SR.new(Game.data.sprites[birdId]) - end - if self.birdSprite then - local t = 48 - self.flyAnim.frames - local bx = self.player.px - t * 4 - local by = self.player.py - math.floor(t * 1.5) - love.graphics.setColor(1, 1, 1, 1) - self.birdSprite:draw(bx, by, cam.x, cam.y, "left", - math.floor(t / 4) % 2, false) - return - end - - -- Legacy fallback for datasets that predate the ROM-extracted FireRed - -- bird above. Keep it for old caches/other games, but current FireRed - -- imports should never reach this branch. - -- - -- Reported from play, twice: "ensure the animation of the flying type - -- pokemon swooping up my player plays after the fly hm transition" and - -- then "Theres still no fly swoop". The machinery above has been here the - -- whole time and has never had a sprite to draw: it wants - -- playerSprites.fly, and no Gen 3 cache carries one. - -- - -- IT WAS LOOKED FOR PROPERLY THIS TIME, and it is not where a sheet like - -- that would be. Emerald's fly bird is not an object-event graphics row - -- -- the three 64x64 rows in that table are Rayquaza twice and the cable - -- car, and every 32x32 row is a person or a legendary -- and it is not - -- hanging off any field-effect script either: not one of the sixty-seven - -- scripts loads tiles, and no native any of them calls reaches a sprite - -- template with frames that size. It is loaded by C, from a sheet only - -- the fly task names, and nothing the import can currently follow gets to - -- it. - -- - -- So the departure is drawn with something this port certainly does have - -- and the cartridge's own bird is not: the FRONT SPRITE OF THE POKEMON - -- THAT IS ACTUALLY CARRYING YOU -- the same one the field-move sweep just - -- showed. It stoops in from the upper left, meets the player, and lifts - -- away with them. Reconstructed, and said so; the moment the bird IS - -- found, playerSprites.fly makes the branch above win again and this is - -- never reached. - local mon = self.flyAnim.mon - if not mon then return end - if self.flyMonImg == nil then - local okPath, path = pcall(function() - return require("src.pokemon.Sprites").path( - Game.data, mon.species, "front", { mon = mon }) - end) - local img - if okPath and type(path) == "string" then - local okImg, loaded = pcall(love.graphics.newImage, path) - img = okImg and loaded or nil - end - self.flyMonImg = img or false - end - if not self.flyMonImg then return end - local total = 48 - local t = math.max(0, math.min(total, total - self.flyAnim.frames)) - -- the stoop: in over sixteen frames, away over the rest, and the two - -- meet on the player rather than anywhere else - local reach = 16 - local dx, dy - if t <= reach then - local k = 1 - t / reach - dx, dy = -96 * k, -72 * k - else - local k = (t - reach) / (total - reach) - dx, dy = -120 * k * k, -96 * k * k - end - local iw, ih = self.flyMonImg:getDimensions() - local px = math.floor(self.player.px - cam.x + 8 - iw / 2 + dx) - local py = math.floor(self.player.py - cam.y + 8 - ih / 2 + dy) - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(self.flyMonImg, px, py) - require("src.render.PaletteFX").markTrueColor(px, py, iw, ih) - end - - -- fishing pose: the rod tile over the faced water (gfx/fishing.asm) - local function fxRod() - if not self.fishing then return end - local fx = Game.data.field.overworldFx - local rod = fx and fx.fishingRod - if rod then - if self.rodImg == nil then - local ok, img = pcall(love.graphics.newImage, rod.path) - self.rodImg = ok and img or false - end - if self.rodImg then - local p = self.player - local oam = ROD_OAM[self.fishing.facing] or ROD_OAM.down - if not self.rodQuads then - -- one quad per 8x8 tile of the stacked sheet (ROD_OAM.tile) - local iw, ih = self.rodImg:getDimensions() - self.rodQuads = {} - for i = 0, math.floor(ih / 8) - 1 do - self.rodQuads[i] = love.graphics.newQuad(0, i * 8, 8, 8, iw, ih) - end - end - local quad = self.rodQuads[oam.tile] - -- the sprite's top-left is 4px above its cell (SpriteRenderer:draw) - local rx = p.px - cam.x + oam.dx - local ry = p.py - cam.y - 4 + oam.dy - love.graphics.setColor(1, 1, 1, 1) - if quad and oam.flip then - love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1) - elseif quad then - love.graphics.draw(self.rodImg, quad, rx, ry) - end - end - end - end + local sparkleOne, radarOne + local boundFx = not Performance.worldDrawHoist or pipelineId or tilt + local fxHeal = boundFx and bindWorldFx(self._worldFxHeal, self, cam) or self._worldFxHeal + local fxDust = boundFx and bindWorldFx(self._worldFxDust, self, cam) or self._worldFxDust + local fxSSAnneWake = boundFx and bindWorldFx(self._worldFxSSAnneWake, self, cam) or self._worldFxSSAnneWake + local fxSSAnneSmoke = boundFx and bindWorldFx(self._worldFxSSAnneSmoke, self, cam) or self._worldFxSSAnneSmoke + local fxWater = boundFx and bindWorldFx(self._worldFxWater, self, cam) or self._worldFxWater + local fxCutTree = boundFx and bindWorldFx(self._worldFxCutTree, self, cam) or self._worldFxCutTree + local fxSparkle = boundFx and bindWorldFx(self._worldFxSparkle, self, cam) or self._worldFxSparkle + local fxSparkleOne + if boundFx then fxSparkleOne = function() return self:_worldFxSparkleOne(cam, sparkleOne) end end + local fxRadarOne + if boundFx then fxRadarOne = function() return self:_worldFxRadarOne(cam, radarOne) end end + local fxRadar = boundFx and bindWorldFx(self._worldFxRadar, self, cam) or self._worldFxRadar + local fxEmote = bindWorldFx(self._worldFxEmote, self, cam) + local fxBird = boundFx and bindWorldFx(self._worldFxBird, self, cam) or self._worldFxBird + local fxRod = boundFx and bindWorldFx(self._worldFxRod, self, cam) or self._worldFxRod if pipelineId then -- === PIPELINE PATH: a mod owns the world pass. ====================== @@ -17640,6 +17846,7 @@ function OverworldState:drawWorld() -- Deliberately unscaled by depth, like :billboard: an effect keeps its -- crisp authored size and only its anchor moves. ctx.drawFx = function(project, scale) + local closeFx = FrameProfile.section(" world: FX") scale = scale or pscale local colors = ctx.spriteColors() local function at(drawFn, wx, wy) @@ -17699,8 +17906,8 @@ function OverworldState:drawWorld() -- The S.S. Anne effects are authored in screen OAM coordinates for this -- fixed-camera cutscene. Keep them as an upright overlay for a custom -- world pipeline; the flat/tilt paths place the wake behind the ship. - fxSSAnneWake() - fxSSAnneSmoke() + fxSSAnneWake(self, cam) + fxSSAnneSmoke(self, cam) for _, sp in ipairs(self.sparkles or {}) do sparkleOne = sp at(fxSparkleOne, sp.px + 8, sp.py + 16) @@ -17710,6 +17917,7 @@ function OverworldState:drawWorld() if pt.active then radarOne = pt; at(fxRadarOne, pt.x * 16 + 8, pt.y * 16 + 16) end end radarOne = nil + closeFx() end override = Pipelines.drawWorld(pipelineId, ctx) -- world post-processes (a miniature-diorama blur, a colour grade) fold @@ -17723,11 +17931,13 @@ function OverworldState:drawWorld() -- was retired). The ground pass was skipped on its behalf above, so -- draw it now and fall through to the flat path below rather than -- compositing an empty canvas. + local closeTiles = FrameProfile.section(" world: tiles") self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh) self.map.renderer:draw(cam.x, bgY, vw, vh) for _, nb in ipairs(self.neighbors) do nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh) end + closeTiles() end end @@ -17754,19 +17964,9 @@ function OverworldState:drawWorld() and groundForRise:freeMode()) and groundForRise or nil local groundSin = groundForRise and groundForRise.scale and select(1, groundForRise:scale()) or 1 - local function riseOf(e) - if not groundForRise then return 0 end - local rise = groundForRise:rise(e.px + (e.shiftPx or 0) + 8, e.py + 8) - -- A SPRITE OFFSET (MapObject_SetSpritePosOffset: the surf mount's ride, - -- the Distortion World cast's flights) in the field view: up by the - -- camera's height lean, toward it by the ground's - local so = e.spriteOffset - if so and (so.lift or so.oz) then - rise = rise + (so.lift or 0) * (groundForRise.heightScale or 0) - - (so.oz or 0) * (groundForRise.groundScale or 1) - end - if groundSin >= 1 then return rise end - return (e.py - cam.y) * (1 - groundSin) + rise + local riseOf = self._worldRise + if not Performance.worldDrawHoist then + riseOf = function(_self, e) return self:_worldRise(e, groundForRise, groundSin, cam) end end if override then @@ -17820,6 +18020,7 @@ function OverworldState:drawWorld() -- The COLLISION GRID IS UNTOUCHED by any of this. It is in map pixels and -- stays there; only the picture is projected, which is why a compressed -- world still walks the same. + local closeGhosts = FrameProfile.section(" world: entities") for _, g in ipairs(self.ghosts) do -- A GHOST GOES THROUGH THE FREE CAMERA TOO. -- @@ -17845,6 +18046,7 @@ function OverworldState:drawWorld() end if not drawn then g.npc:draw(cam.x - g.ox, cam.y - g.oy) end end + closeGhosts() -- HIDDEN MEANS HIDDEN, for the player as much as for anybody. -- -- Two Gen 3 things write this and neither had a reader: the @@ -17856,7 +18058,6 @@ function OverworldState:drawWorld() -- ...AND A PLOT'S ANSWER CHANGES WHILE YOU ARE STANDING THERE. A tree -- is planted, grows and is picked without the map reloading, so its -- emptiness is asked at draw time rather than remembered at spawn. - local plotEmpty = plotIsEmpty -- the ground whose depth the entity pass is drawing into, while it is local spriteDepthGround = nil -- SINNOH'S CHARACTERS STAND ON SOMETHING. Built once per frame rather @@ -17868,98 +18069,11 @@ function OverworldState:drawWorld() shadows = require("src.render.Gen4Shadows").forMap(Game.data) or false self.gen4Shadows = shadows end - local function drawEntity(e) - if not (self.flyAnim and self:hasFlyBird() and e == self.player) - and not e.hidden and not e.berryVisualEmpty and not plotEmpty(e) then - -- A FREE CAMERA PLACES ITS OWN CHARACTERS. - -- - -- Reported from play: *"isnt keeping the player the right size"*, with a - -- screenshot of a third-person Twinleaf in correct perspective and two - -- characters standing in it at flat 2D scale. They were drawn by the - -- line below, which places every sprite at `py - camY` -- the field - -- camera's 1:1 mapping, and simply not this camera's. - -- - -- `freeEntity` runs THIS SAME DRAW inside a transform, so nothing here - -- learns about projection and the entity keeps owning its own sprite, - -- frame and palette. - -- - -- In FIRST person the player is the eye, so drawing them puts their own - -- back across the middle of the view. - if freeGround then - if not (freeGround:freeMode() == "first" and e == self.player) then - local rise = riseOf(e) - -- ...AND WHICH WAY THEY FACE ON SCREEN. - -- - -- Reported from play: *"when i orbit the camera 180 degrees if i - -- walk left it looks like hes walking right"*. `screenToWorld` - -- turns the PRESS into a world direction, so the step is right; - -- the sprite then showed that world facing under a camera that is - -- no longer looking north, so the artwork was the one thing still - -- in the old frame. `worldToScreen` is its inverse, and the pair - -- are inverses because the camera sits between them. - -- - -- Set and restored around the draw rather than threaded through, - -- because `facing` is what every pose, animation and follower path - -- in the entity reads, and none of them should learn about cameras. - local view, saved = freeGround.view3d, e.facing - if view and view.worldToScreen then - e.facing = view:worldToScreen(saved) - end - freeGround:freeEntity((e.px or 0), (e.py or 0), cam.x, cam.y, rise, - function() e:draw(cam.x, cam.y + rise) end, - nil, require("src.world.Gen4SurfMount").poseFor(e, e == self.player and self.dwPose or nil)) - e.facing = saved - end - -- Deliberately without the flat shadow and the grass overdraw below: - -- both are drawn in screen space against a ground plane this camera - -- does not have, and a shadow in the wrong place is worse than none. - return - end - -- IN THE WORLD'S DEPTH on Sinnoh's cartridge camera - -- (Gen4Ground:beginSprites): the billboard origin's depth, so a prop in - -- front of a character hides them; nil leaves the draw as it was - local depth = spriteDepthGround and spriteDepthGround:billboardDepth( - (e.px or 0) + (e.shiftPx or 0), e.py or 0, - e.spriteOffset and e.spriteOffset.lift, e.spriteOffset and e.spriteOffset.oz) - -- UNDER the sprite, and lifted by the same terrain rise it is, so a - -- character on a slope keeps their feet on it. - if shadows then - if depth then - spriteDepthGround:withSpriteDepth(depth, function() - shadows:draw(self.map, e, cam.x, cam.y, e == self.player, riseOf(e)) - end, false) - else - shadows:draw(self.map, e, cam.x, cam.y, e == self.player, riseOf(e)) - end - end - if depth then - spriteDepthGround:withSpriteDepth(depth, function() e:draw(cam.x, cam.y + riseOf(e)) end) - else - e:draw(cam.x, cam.y + riseOf(e)) - end - -- tall grass overdraws the sprite's feet (GB sprite priority); - -- the overdraw is BG tiles, so it rides the shake offset too - love.graphics.setColor(1, 1, 1, 1) - if self.map:isGrassCell(e.cellX, e.cellY) then - if not self:drawGen3Grass(e.cellX, e.cellY, cam.x, bgY, - self:gen3GrassFrame(e, false)) then - self.map.renderer:drawCellBottom(e.cellX, e.cellY, cam.x, bgY) - if grassColors then - self.map.renderer:markCellBottomRedraw(e.cellX, e.cellY, - cam.x, bgY, grassColors) - end - end - end - if e.targetX and self.map:isGrassCell(e.targetX, e.targetY) then - if not self:drawGen3Grass(e.targetX, e.targetY, cam.x, bgY, - self:gen3GrassFrame(e, true)) then - self.map.renderer:drawCellBottom(e.targetX, e.targetY, cam.x, bgY) - if grassColors then - self.map.renderer:markCellBottomRedraw(e.targetX, e.targetY, - cam.x, bgY, grassColors) - end - end - end + local drawEntity = self._worldDrawEntity + if not Performance.worldDrawHoist then + drawEntity = function(_self, e) + return self:_worldDrawEntity(e, cam, bgY, freeGround, groundForRise, groundSin, + grassColors, spriteDepthGround, shadows, riseOf) end end @@ -18014,16 +18128,25 @@ function OverworldState:drawWorld() -- rectangle each, which is precisely what a scissor is. It is set per -- cell and intersected with whatever clip the caller already had, so a -- reflection is painted once per water cell it falls across. - local reflectors = {} + local reflectors = Performance.worldDrawHoist and self._worldReflectors or nil + if not reflectors or reflectors.busy then + reflectors = {n=0} + if Performance.worldDrawHoist and not self._worldReflectors then self._worldReflectors = reflectors end + end + reflectors.busy = true + local oldReflectors, reflectorCount = reflectors.n, 0 for _, e in ipairs(self.entities) do if not e.hidden and e.drawReflection then e.reflects = self:reflectionSearch(e) if e.reflects then e.reflectStill = self:stillReflection(e.reflects) - reflectors[#reflectors + 1] = e + reflectorCount = reflectorCount + 1 + reflectors[reflectorCount] = e end end end + for i = reflectorCount + 1, oldReflectors do reflectors[i] = nil end + reflectors.n = reflectorCount self.reflectProbe = (self.reflectProbe or 0) + 1 if self.reflectProbe % 90 == 1 then local p, set = self.player, Game and Game.data and Game.data.constants @@ -18113,6 +18236,9 @@ function OverworldState:drawWorld() love.graphics.setColor(1, 1, 1, 1) local renderer = self.map.renderer if renderer.drawReflectionCover then + if Performance.worldDrawHoist and self.rippleCells == nativeRippleCells then + self:_worldReflectionCover(reflectors, reflectorCount, renderer, cam, bgY) + else local seen, cells = {}, {} for _, e in ipairs(reflectors) do local cx0, cy0, cx1, cy1 = self:reflectionRect(e) @@ -18132,15 +18258,17 @@ function OverworldState:drawWorld() for _, cell in ipairs(cells) do renderer:drawReflectionCover(cell[1], cell[2], cam.x, bgY) end + end end end + reflectors.busy = nil -- The rotating gates sit under the entity pass; the S.S. Anne wake has -- priority below the ship and therefore belongs immediately before it. self:drawGen3Gates(cam) -- OBJ priority 2 and a later sprite id put the wake behind the ship at the -- same priority on hardware. Draw it immediately before the entity pass. - fxSSAnneWake() + fxSSAnneWake(self, cam) local onTop = nil -- SINNOH'S CARTRIDGE CAMERA: the characters go into the world's own depth -- buffer (Gen4Ground:beginSprites) so props in front of them hide them @@ -18148,22 +18276,34 @@ function OverworldState:drawWorld() and groundForRise:beginSprites() then spriteDepthGround = groundForRise end + local closeEntities = FrameProfile.section(" world: entities") + if Performance.worldDrawHoist then + local okE, result = pcall(self._worldEntityLoop, self, drawEntity, cam, bgY, freeGround, + groundForRise, groundSin, grassColors, spriteDepthGround, shadows, riseOf) + if not okE then + if spriteDepthGround then spriteDepthGround:endSprites(); spriteDepthGround = nil end + error(result, 0) + end + onTop = result + else for _, e in ipairs(self.entities) do if self:gen3AboveTopLayer(e) then onTop = onTop or {} onTop[#onTop + 1] = e else - local okE, errE = pcall(drawEntity, e) + local okE, errE = pcall(drawEntity, self, e, cam, bgY, freeGround, groundForRise, groundSin, grassColors, spriteDepthGround, shadows, riseOf) if not okE then if spriteDepthGround then spriteDepthGround:endSprites(); spriteDepthGround = nil end error(errE, 0) end end end + end if spriteDepthGround then spriteDepthGround:endSprites() spriteDepthGround = nil end + closeEntities() -- The Gen 3 top layer goes on AFTER the entity pass and before the -- field effects: it is the half of every metatile the player walks -- behind -- treetops, upper storeys, the far rail of a bridge. On a @@ -18175,11 +18315,10 @@ function OverworldState:drawWorld() -- not the player at every zoom. Cleared after, so a stale focus can never -- outlive the frame it was true for. local feetX, feetZ = (self.player.px or 0) + 8, (self.player.py or 0) + 8 - local function aim(renderer, ox, oy) - local g4 = renderer and renderer.gen4Ground - if g4 then - if ox then g4.focusX, g4.focusZ = feetX - ox, feetZ - oy - else g4.focusX, g4.focusZ = nil, nil end + local aim = self._worldAim + if not Performance.worldDrawHoist then + aim = function(_self, renderer, ox, oy) + return self:_worldAim(renderer, ox, oy, feetX, feetZ) end end -- THE "!" GOES IN WHILE A FREE CAMERA'S 3D PASS IS STILL OPEN. @@ -18195,24 +18334,29 @@ function OverworldState:drawWorld() self:eachEmote(fxEmote) emoteEarly = true end - aim(self.map.renderer, 0, 0) + local closeTiles = FrameProfile.section(" world: tiles") + aim(self, self.map.renderer, 0, 0, feetX, feetZ) self.map.renderer:drawAbove(cam.x, bgY, vw, vh) - aim(self.map.renderer) + aim(self, self.map.renderer, nil, nil, feetX, feetZ) for _, nb in ipairs(self.neighbors) do if nb.map.renderer.drawAbove then - aim(nb.map.renderer, nb.ox, nb.oy) + aim(self, nb.map.renderer, nb.ox, nb.oy, feetX, feetZ) nb.map.renderer:drawAbove(cam.x - nb.ox, bgY - nb.oy, vw, vh) - aim(nb.map.renderer) + aim(self, nb.map.renderer, nil, nil, feetX, feetZ) end end - for _, e in ipairs(onTop or {}) do drawEntity(e) end + closeTiles() + closeEntities = FrameProfile.section(" world: entities") + for _, e in ipairs(onTop or {}) do drawEntity(self, e, cam, bgY, freeGround, groundForRise, groundSin, grassColors, spriteDepthGround, shadows, riseOf) end + closeEntities() - fxHeal() - fxDust() - fxCutTree() - fxWater() + local closeFx = FrameProfile.section(" world: FX") + fxHeal(self, cam) + fxDust(self, cam) + fxCutTree(self, cam) + fxWater(self, cam) if not emoteEarly then self:eachEmote(fxEmote) end - fxRadar() + fxRadar(self, cam) -- BLACK / WHITE: the phenomenon spot and the rod's lure (Gen5Phenomena) if GameVersion.isGen5() then local g5ground = self.map and self.map.renderer and self.map.renderer.gen4Ground @@ -18222,11 +18366,12 @@ function OverworldState:drawWorld() end -- HGSS: the Dowsing MCHN's reading (src/world/HgssDowsing.lua) if self.hgssDowsing then require("src.world.HgssDowsing").draw(self, vw, vh) end - fxSparkle() - fxBird() - fxRod() + fxSparkle(self, cam) + fxBird(self, cam) + fxRod(self, cam) -- Smoke keeps the template's priority 0, above the ship and map layers. - fxSSAnneSmoke() + fxSSAnneSmoke(self, cam) + closeFx() else -- === TILT PATH: ground-hugging FX stay on the projected ground, all -- standing things billboard upright over it in a separate pass. ====== @@ -18236,17 +18381,19 @@ function OverworldState:drawWorld() -- draws them last, over the sprites, in the same canvas; here the two -- layers are separate and composited ground-under-upright, so drawing -- them now into the still-active ground canvas is order-equivalent. - fxHeal() - fxDust() - fxCutTree() - fxWater() + local closeFx = FrameProfile.section(" world: FX") + fxHeal(self, cam) + fxDust(self, cam) + fxCutTree(self, cam) + fxWater(self, cam) -- ...and the gates with them: they are behind every upright sprite (see -- drawGen3Gates), so the ground canvas is exactly where they belong self:drawGen3Gates(cam) + closeFx() Game.renderer:beginUprightPass() - fxSSAnneWake() + fxSSAnneWake(self, cam) -- One y-sorted list of ALL upright billboards -- sprites (player, NPCs, -- ghosts) -- keyed on baseline world y (the foot / base row). Farther @@ -18263,6 +18410,7 @@ function OverworldState:drawWorld() end table.sort(items, function(a, b) return a.y < b.y end) + local closeEntities = FrameProfile.section(" world: entities") for _, it in ipairs(items) do if it.kind == "ghost" then -- ghosts billboard just like real entities (foot offset folds in the @@ -18278,7 +18426,7 @@ function OverworldState:drawWorld() local fy = e.py - cam.y + 16 local colors = zoneColorsAt(zones, fx, fy) self:billboard(fx, fy, vw, vh, colors, false, - function() e:draw(cam.x, cam.y + riseOf(e)) end) + function() e:draw(cam.x, cam.y + riseOf(self, e, groundForRise, groundSin, cam)) end) -- tall-grass feet overdraw glued to the sprite: same anchor + depth -- so it keeps hiding the feet, color-0-keyed palette so its white -- gaps still show the sprite through (drawCellBottomRaw lets the @@ -18298,6 +18446,8 @@ function OverworldState:drawWorld() end end + closeEntities() + closeFx = FrameProfile.section(" world: FX") -- Standing world FX: each billboards at the ground foot of the -- character it belongs to, so it stays upright over the tilted ground. -- emote bubble -> the spotting NPC's foot (rides above its head) @@ -18319,7 +18469,7 @@ function OverworldState:drawWorld() local fy = self.player.py - cam.y + 16 self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxRod) end - fxSSAnneSmoke() + fxSSAnneSmoke(self, cam) for _, sp in ipairs(self.sparkles or {}) do sparkleOne = sp local fx = sp.px - cam.x + 8 @@ -18339,6 +18489,7 @@ function OverworldState:drawWorld() radarOne = nil Game.renderer:endUprightPass() + closeFx() end -- LAST IN THE WORLD PASS, over everything the cave contains: the black @@ -18346,6 +18497,7 @@ function OverworldState:drawWorld() -- drawUI because it is anchored to the PLAYER, not to the screen -- the -- cartridge can put it at a fixed (120,80) because its camera never lets -- the player leave the middle, and this port's does at a map edge. + local closeFx = FrameProfile.section(" world: FX") self:drawGen3Flash(cam, vw, vh) -- the Mirage Tower's falling fossil and ceiling crumbs: OAM priority 0 if self.g3MirageTower or self.g3MirageCrumbles then @@ -18357,6 +18509,97 @@ function OverworldState:drawWorld() -- in the world pass rather than the UI pass for the same reason the flash -- is: it is anchored to a CELL, not to the screen. self:drawGen3Decorate(cam) + closeFx() +end + +-- A requested source field callback owns precisely the world presentation. +-- The ordinary path has no extra protected call or compatibility allocation. +function OverworldState:drawWorld() + local field=package.loaded["src.core.game3.field_view"] + if field and GameVersion.isGen3()then return field._nativeDraw(Game,self,drawWorldBody)end + return drawWorldBody(self) +end + +-- Same native actors and effect bodies as the ordinary world pass, with an +-- owned source atlas and explicit capture viewport. It cannot step the world. +function OverworldState:_drawFieldView(field) + local cam,vw,vh=field.camera,field.width,field.height + local bgY=cam.y+(self.bgShakeY or 0) + self.viewW,self.viewH=vw,vh + PaletteFX.markTrueColor(0,0,vw,vh) + if not field.actorsOnly then + love.graphics.setColor(0,0,0,1);love.graphics.rectangle("fill",0,0,vw,vh) + field.tiles:draw(false,cam,bgY) + if self.g3MirageTower then require("src.world.Gen3MirageTower").drawTower(self,cam)end + end + local function actor(e,ox,oy,ghost) + if not e or e.hidden then return end + ox,oy=ox or 0,oy or 0 + local ac={x=cam.x-ox,y=cam.y-oy} + local billboard=field.billboard + if billboard then + local x,y=(e.px or 0)-ac.x+8,(e.py or 0)-ac.y+16 + local sx,sy=Tilt.groundPoint(x,y,vw,vh) + love.graphics.push();love.graphics.translate(sx-x,sy-y) + end + if ghost then e:draw(ac.x,ac.y) + else self:_worldDrawEntity(e,ac,bgY,nil,nil,1,nil,nil,nil,self._worldRise,field)end + if billboard then love.graphics.pop()end + end + table.sort(self.ghosts,byGhostY);table.sort(self.entities,byEntityY) + if not field.skipActors then + for _,g in ipairs(self.ghosts)do actor(g.npc,g.ox,g.oy,true)end + end + if not field.actorsOnly then + local reflectors={} + if not field.skipActors then + for _,e in ipairs(self.entities)do + if not e.hidden and e.drawReflection then + e.reflects=self:reflectionSearch(e) + if e.reflects then e.reflectStill=self:stillReflection(e.reflects);reflectors[#reflectors+1]=e end + end + end + end + for _,e in ipairs(reflectors)do e:drawReflection(cam.x,cam.y)end + self:drawRipples(cam.x,bgY) + local cover={drawReflectionCover=function(_,cx,cy) + field.tiles:reflectionCover(cx,cy,cam,bgY) + end} + self:_worldReflectionCover(reflectors,#reflectors,cover,cam,bgY) + self:drawGen3Gates(cam);self:_worldFxSSAnneWake(cam) + end + local above={} + if not field.skipActors then + for _,e in ipairs(self.entities)do + if self:gen3AboveTopLayer(e)then above[#above+1]=e else actor(e)end + end + end + -- A retained capture callback cannot draw into an unrelated later frame. + local open=true + local function upper() + if not open or field.actorsOnly then return false end + field.tiles:draw(true,cam,bgY);return true + end + if not field.skipActors and field.endUnderActors then + local ok,why=pcall(field.endUnderActors,upper) + if not ok then open=false;error(why,0)end + end + local okUpper,whyUpper=pcall(upper);open=false + if not okUpper then error(whyUpper,0)end + for _,e in ipairs(above)do actor(e)end + if not field.actorsOnly then + self:_worldFxHeal(cam);self:_worldFxDust(cam);self:_worldFxCutTree(cam);self:_worldFxWater(cam) + self:eachEmote(function()self:_worldFxEmote(cam)end) + self:_worldFxRadar(cam);self:_worldFxSparkle(cam);self:_worldFxBird(cam);self:_worldFxRod(cam) + self:_worldFxSSAnneSmoke(cam) + end + require("src.core.game3.field_view")._drawFlash(self,field) + if not field.actorsOnly then + if self.g3MirageTower or self.g3MirageCrumbles then require("src.world.Gen3MirageTower").drawAbove(self,cam)end + if self.g3Orb then require("src.world.Gen3ScreenFx").draw(self,cam,vw,vh)end + self:drawGen3Decorate(cam) + end + love.graphics.setColor(1,1,1,1) end -- The cursor the DECORATE row hands the player: the decoration's own @@ -18378,51 +18621,15 @@ function OverworldState:drawGen3Decorate(cam) love.graphics.setColor(1, 1, 1, 1) end --- THE HOLE, drawn the way the cartridge cuts it: one span a scanline. --- --- SetFlashScanlineEffectWindowBoundaries fills a per-line table of left/right --- edges and the hardware window does the rest, so a row-at-a-time fill here is --- not an approximation of Emerald's circle, it IS Emerald's circle -- and it --- needs no stencil buffer, which keeps the field draw working on every backend --- the port runs on. Two rectangles a row, plus one above and one below. +-- The player's cell centre remains in world pixels at every viewport size. +-- Integer scanline geometry is shared with mod-owned field presentations. function OverworldState:drawGen3Flash(cam, vw, vh) - if not GameVersion.isGen3() then return end + if not GameVersion.isGen3() or not self.player then return end local Gen3Flash = require("src.world.Gen3Flash") - local circle = Gen3Flash.circle(Game, vw, vh) - if not (circle and self.player) then return end - local radius = tonumber(circle.radius) or 0 - love.graphics.setColor(0, 0, 0, 1) - if radius <= 0 then - -- the table's darkest level closes the window completely; the cartridge - -- never selects it, but a script may - love.graphics.rectangle("fill", 0, 0, vw, vh) - love.graphics.setColor(1, 1, 1, 1) - return - end - -- the middle of the player's own cell, which is (120,80) whenever the view - -- is the GBA's own 240x160 -- the two numbers the cartridge hard-codes - local cx = self.player.px - cam.x + 8 - local cy = self.player.py - cam.y + 8 - local top = math.max(0, math.floor(cy - radius)) - local bottom = math.min(vh, math.ceil(cy + radius)) - if top > 0 then love.graphics.rectangle("fill", 0, 0, vw, top) end - if bottom < vh then - love.graphics.rectangle("fill", 0, bottom, vw, vh - bottom) - end - for y = top, bottom - 1 do - local dy = y + 0.5 - cy - local half = math.sqrt(math.max(0, radius * radius - dy * dy)) - local left = cx - half - local right = cx + half - if left > 0 then - love.graphics.rectangle("fill", 0, y, math.min(left, vw), 1) - end - if right < vw then - love.graphics.rectangle("fill", math.max(right, 0), y, - vw - math.max(right, 0), 1) - end - end - love.graphics.setColor(1, 1, 1, 1) + local cx = math.floor(self.player.px - cam.x + 8) + local cy = math.floor(self.player.py - cam.y + 8) + local circle = Gen3Flash.circle(Game, vw, vh, cx, cy) + if circle then Gen3Flash.drawMask(circle.radius, vw, vh, cx, cy) end end -- Emerald's healing machine: the monitor over the machine's screen and one diff --git a/tests/engine/frame_pacing.lua b/tests/engine/frame_pacing.lua new file mode 100644 index 000000000..27a96b39e --- /dev/null +++ b/tests/engine/frame_pacing.lua @@ -0,0 +1,153 @@ +local T = require("tests.harness") +local Cap = require("src.core.FrameCap") +local function clock(start) + local now, sleeps, total = start or 0, 0, 0 + return { + getTime = function() + return now + end, + sleep = function(dt) + now = now + dt + sleeps = sleeps + 1 + total = total + dt + end, + advance = function(dt) + now = now + dt + end, + sleeps = function() + return sleeps, total + end, + } +end +local function window(sync, rate) + local calls = 0 + return { + getVSync = function() + return sync + end, + getMode = function() + calls = calls + 1 + return 320, 240, { refreshrate = rate } + end, + modeCalls = function() + return calls + end, + set = function(s, r) + sync, rate = s, r + end, + } +end +Cap.current = 60 +T.eq(Cap.vsyncPacing, true, "P1 switch defaults on") +for _, sync in ipairs({ 1, -1, 2 }) do + local timer, w = clock(), window(sync, 60) + local nextFrame = 0 + for _ = 1, 60 do + timer.advance(1 / 60) + nextFrame = Cap.pace(timer, w, true, nextFrame) + end + T.eq(timer.sleeps(), 0, "active VSync at the cap receives no additional sleep") + T.eq(w.modeCalls(), 1, "display flags are not allocated each frame") +end +for _, case in ipairs({ + { sync = 0, rate = 60, presented = true }, + { sync = 1, rate = 144, presented = true }, + { sync = 1, rate = 0, presented = true }, + { sync = 1, rate = 60, presented = false }, +}) do + local timer, w = clock(), window(case.sync, case.rate) + local deadline = Cap.pace(timer, w, case.presented, 0) + T.check(timer.sleeps() > 0, "software cap remains for disabled/unknown/faster/unpresented cases") + T.check(math.abs(deadline - 1 / 60) < 1e-9, "software deadline remains one frame ahead") +end +local timer, w = clock(), window(1, 60) +Cap.vsyncPacing = false +Cap.pace(timer, w, true, 0) +T.check(timer.sleeps() > 0, "P1 switch restores software pacing") +T.eq(w.modeCalls(), 0, "disabled P1 does not query display flags") +Cap.vsyncPacing = true +timer, w = clock(), window(1, 60) +local deadline = Cap.pace(timer, w, true, 0) +w.set(0, 60) +deadline = Cap.pace(timer, w, true, deadline) +T.check(math.abs(deadline - 1 / 60) < 1e-9, "turning VSync off resumes the carried software deadline") +timer.advance(1) +deadline = Cap.pace(timer, w, true, deadline) +T.eq(deadline, timer.getTime(), "stalls re-anchor instead of accumulating catch-up frames") +deadline = Cap.pace(timer, w, true, deadline) +T.eq(deadline, timer.getTime(), "software pacing resumes after a stall") +timer, w = clock(), window(1, 60) +Cap.pace(timer, w, true, 0) +w.set(1, 144) +timer.advance(1.01) +deadline = Cap.pace(timer, w, true, timer.getTime()) +T.check(timer.sleeps() > 0, "display rate changes refresh the software-cap decision") +T.eq(w.modeCalls(), 2, "display sample refresh is bounded") +timer = clock() +Cap.pace(timer, nil, true, 0) +T.check(timer.sleeps() > 0, "headless provider absence retains the software cap") + +-- Execute the actual run-loop body with native present/timer boundaries +-- substituted, so a correct helper wired to the wrong loop still fails. +local file = assert(io.open("main.lua", "r")) +local source = file:read("*a") +file:close() +local start = assert(source:find("function love.run()", 1, true)) +for _, active in ipairs({ true, false }) do + local fake = clock() + fake.step = function() + return 1 / 60 + end + local presents = 0 + local noop = function() end + local env = { + love = { + timer = fake, + window = window(1, 60), + graphics = { + isActive = function() + return active + end, + origin = noop, + clear = noop, + getBackgroundColor = function() + return 0, 0, 0, 1 + end, + present = function() + presents = presents + 1 + fake.advance(1 / 60 - 0.001) + end, + }, + }, + BootTrace = { mark = noop, frame = noop }, + Frames = { tick = noop }, + FrameProfile = { + section = function() + return noop + end, + }, + WindowFrame = { + draw = function(fn) + fn() + end, + }, + GraphicsStack = { drain = noop }, + pacingEnabled = function() + return true + end, + lastLogFlush = 0, + } + setmetatable(env, { __index = _G }) + local chunk + if setfenv then + chunk = assert(loadstring(source:sub(start), "@main.lua:love.run")) + setfenv(chunk, env) + else + chunk = assert(load(source:sub(start), "@main.lua:love.run", "t", env)) + end + chunk() + env.love.run()() + T.eq(presents, active and 1 or 0, "actual main loop presents only active graphics") + T.eq(fake.sleeps() > 0, not active, "actual main loop bypasses sleep only after native presentation") +end +T.finish() diff --git a/tests/engine/gc_frame_budget.lua b/tests/engine/gc_frame_budget.lua new file mode 100644 index 000000000..974ba084e --- /dev/null +++ b/tests/engine/gc_frame_budget.lua @@ -0,0 +1,126 @@ +local T = require("tests.harness") +local Performance = require("src.core.Performance") +local Budget = require("src.core.GCFrameBudget") +local Cap = require("src.core.FrameCap") + +local function setup(cost) + local state = { now = 0, pause = 200, mul = 200, steps = 0, running = true, stepArgs = {}, sleeps = 0 } + local timer = { + getTime = function() + return state.now + end, + sleep = function(dt) + state.now = state.now + dt + state.sleeps = state.sleeps + 1 + end, + } + local function collector(command, ...) + local value = (...) + if command == "setpause" then + local old = state.pause + state.pause = value + return old + end + if command == "setstepmul" then + local old = state.mul + state.mul = value + return old + end + if command == "isrunning" then + return state.running + end + assert(command == "step", "unexpected collector command") + state.steps = state.steps + 1 + state.stepArgs[#state.stepArgs + 1] = select("#", ...) + state.now = state.now + (cost or 0.0001) + return state.completeAt and state.steps >= state.completeAt or false + end + return state, timer, collector +end +T.eq(Performance.gcBudget, true, "P2 defaults on") +T.eq(Performance.GC_PAUSE, 120, "requested collector pause") +T.eq(Performance.GC_STEP_MUL, 300, "requested collector step multiplier") +for _, tier in ipairs({ "high", "balanced", "low" }) do + T.eq(Performance.CAPS[tier].gcBudgetSeconds, 0.0005, "tier carries the default idle budget") +end +local state, timer, gc = setup() +Performance.tier = "high" +Budget.beginFrame(Performance, gc) +T.eq(state.pause, 120, "frame applies requested pause") +T.eq(state.mul, 300, "frame applies requested step multiplier") +local steps = Budget.idle(timer, 0.01) +T.eq(steps, 5, "idle work stops at the half-millisecond budget") +for _, n in ipairs(state.stepArgs) do + T.eq(n, 0, "incremental step has no fixed kilobyte argument") +end +local before = state.steps +T.eq(Budget.idle(timer, state.now), 0, "no collector work starts at an exhausted deadline") +T.eq(state.steps, before, "expired deadline leaves collector untouched") +state, timer, gc = setup() +Budget.beginFrame(Performance, gc) +T.eq(Budget.idle(timer, 0.00025), 3, "shorter deadline bounds started steps") +T.check(state.now <= 0.00035, "indivisible work can overshoot by at most one tested step") +state, timer, gc = setup() +state.completeAt = 2 +Budget.beginFrame(Performance, gc) +T.eq(Budget.idle(timer, 0.01), 2, "completed cycle does not immediately start another cycle") +state.running = false +T.eq(Budget.idle(timer, 0.01), 0, "a stopped collector is not restarted") +state.running = true +Budget._facadeHandled() +T.eq(Budget.idle(timer, 0.01), 0, "existing public GC delegation retains its frame") +Budget.beginFrame(Performance, gc) +T.check(Budget.idle(timer, 0.01) > 0, "next frame resets compatibility delegation ownership") +Performance.gcBudget = false +Budget.beginFrame(Performance, gc) +T.eq(state.pause, 200, "disable restores previous collector pause") +T.eq(state.mul, 200, "disable restores previous step multiplier") +T.eq(Budget.idle(timer, 0.01), 0, "disabled P2 does no idle collector work") +Performance.gcBudget = true +local caps = Performance.CAPS.low +local oldPause, oldMul, oldSeconds = caps.gcPause, caps.gcStepMul, caps.gcBudgetSeconds +caps.gcPause, caps.gcStepMul, caps.gcBudgetSeconds = 140, 250, 0.0002 +Performance.tier = "low" +state, timer, gc = setup() +Budget.beginFrame(Performance, gc) +T.eq(state.pause, 140, "tier-specific pause is respected") +T.eq(state.mul, 250, "tier-specific step multiplier is respected") +T.eq(Budget.idle(timer, 0.01), 2, "tier can reduce the idle budget") +caps.gcBudgetSeconds = 0.01 +Budget.beginFrame(Performance, gc) +before = state.steps +Budget.idle(timer, state.now + 0.1) +T.check(state.steps - before <= 6, "tier override cannot exceed the half-millisecond ceiling") +caps.gcPause, caps.gcStepMul, caps.gcBudgetSeconds = oldPause, oldMul, oldSeconds +Performance.tier = "high" +state, timer, gc = setup() +Budget.beginFrame(Performance, gc) +Cap.current, Cap.vsyncPacing = 60, false +local deadline = Cap.pace(timer, nil, false, 0, Budget.idle) +T.eq(state.steps, 5, "actual software pacing performs bounded GC before sleeping") +T.check(state.sleeps > 0, "software pacing sleeps after collection") +T.check(math.abs(deadline - 1 / 60) < 1e-9, "collection does not move the software deadline") +state, timer, gc = setup() +Budget.beginFrame(Performance, gc) +Cap.vsyncPacing = true +local window = { + getVSync = function() + return 1 + end, + getMode = function() + return 320, 180, { refreshrate = 60 } + end, +} +state.now = 1 / 60 - 0.0003 +Cap.pace(timer, window, true, 0, Budget.idle) +T.check(state.steps > 0 and state.steps <= 4, "VSync uses only actual remaining deadline time") +T.eq(state.sleeps, 0, "VSync receives no extra sleep after collection") +state, timer, gc = setup() +Budget.beginFrame(Performance, gc) +state.now = 1 / 60 + 0.001 +Cap.pace(timer, window, true, 0, Budget.idle) +T.eq(state.steps, 0, "late VSync present receives no invented idle budget") +Performance.gcBudget = false +Budget.configure(Performance, gc) +Performance.gcBudget = true +T.finish() diff --git a/tests/engine/hot_modules.lua b/tests/engine/hot_modules.lua new file mode 100644 index 000000000..a234303cf --- /dev/null +++ b/tests/engine/hot_modules.lua @@ -0,0 +1,52 @@ +local T = require("tests.harness") +local Performance = require("src.core.Performance") +local Hot = require("src.core.HotModules") +local name = "tests.hot_module_probe" +local originalRequire = require +local calls, loads = 0, 0 +package.preload[name] = function() + loads = loads + 1 + return { generation = loads } +end +_G.require = function(path, ...) + calls = calls + 1 + return originalRequire(path, ...) +end +Performance.hotPathHoist = true +local first = Hot.get(name) +T.eq(loads, 1, "cold dependency is loaded once") +T.eq(Hot.get(name), first, "warm module retains identity") +T.eq(calls, 1, "warm path avoids require dispatch") +first.liveField = "changed" +T.eq(Hot.get(name).liveField, "changed", "module field mutations remain live") +local replacement = { generation = 20 } +package.loaded[name] = replacement +T.eq(Hot.get(name), replacement, "explicit package.loaded replacement is observed") +package.loaded[name] = nil +T.eq(Hot.get(name).generation, 2, "clearing package.loaded reloads the dependency") +Performance.hotPathHoist = false +local before = calls +Hot.get(name) +Hot.get(name) +T.eq(calls - before, 2, "P3 disabled restores normal require dispatch") +Performance.hotPathHoist = true +package.loaded[name] = nil +package.preload[name] = function() + error("deliberate optional load failure") +end +local ok = Hot.optional(name) +T.eq(ok, false, "optional dependency errors remain contained") +package.loaded[name] = nil +package.preload[name] = function() + return replacement +end +local value +ok, value = Hot.optional(name) +T.check(ok and value == replacement, "failed loads are not memoized as successful modules") +before = calls +ok, value = Hot.optional(name) +T.check(ok and value == replacement, "optional warm module retains its result") +T.eq(calls, before, "optional warm path avoids pcall/require dispatch") +_G.require = originalRequire +package.loaded[name], package.preload[name] = nil, nil +T.finish() diff --git a/tests/engine/hot_renderer_records.lua b/tests/engine/hot_renderer_records.lua new file mode 100644 index 000000000..4d4fd3eff --- /dev/null +++ b/tests/engine/hot_renderer_records.lua @@ -0,0 +1,121 @@ +local T = require("tests.harness") +love = require("tests.love_stub") +local Performance = require("src.core.Performance") +local Version = require("src.core.GameVersion") +Version.set("red") +local Renderer = require("src.render.Renderer") +local Game = require("src.core.Game") +local Runtime = require("src.mods.Runtime") +local Hooks = require("src.mods.Hooks") +local Stack = require("src.core.StateStack") +local w, h = 640, 480 +love.graphics.getDimensions = function() + return w, h +end +love.graphics.getPixelDimensions = function() + return w, h +end +Renderer:init() +local function present(private) + Renderer:beginFrame() + if private then + return Renderer:_endFrameForEngine() + end + return Renderer:endFrame() +end +for _, enabled in ipairs({ false, true }) do + Performance.hotPathHoist = enabled + w, h = 640, 480 + local first = present() + local oldUI = Renderer.uiPresentation + w, h = 800, 600 + local second = present() + T.check(first ~= second, "public endFrame returns detached records with P3 " .. tostring(enabled)) + T.eq(first.width, 640, "retained public viewport keeps its original width") + T.eq(second.width, 800, "next public viewport has current width") + T.eq(oldUI == Renderer.uiPresentation, enabled, "existing renderer-owned UI view follows the switch") +end +Performance.hotPathHoist = true +local first = present(true) +T.eq(present(true), first, "unobserved native result can reuse private storage") +local foreign = { custom = true } +Renderer.uiPresentation = foreign +present() +T.check(Renderer.uiPresentation ~= foreign, "engine does not reuse a replacement owned by a mod") +T.eq(foreign.x, nil, "replacement supplied by a mod is not mutated") +local realBlit = Renderer.blitCanvas +local forwarded +Renderer.blitCanvas = function(self, ...) + forwarded = { self, ... } + return "forwarded" +end +local canvas, zones = {}, {} +T.eq( + Renderer:_blitAtDpi(1.5, 2, canvas, 3, 4, zones, 5, 6, 7, 8, 9, 10, 11, 12), + "forwarded", + "hoisted blit preserves the public return" +) +T.eq(forwarded[1], Renderer, "hoisted blit preserves the public receiver") +T.eq(forwarded[2], canvas, "hoisted blit preserves canvas identity") +T.eq(forwarded[5], zones, "hoisted blit preserves zone identity") +T.eq(forwarded[14], 1.5, "hoisted blit keeps horizontal DPI in the original position") +T.eq(forwarded[15], 2, "hoisted blit keeps vertical DPI in the original position") +Renderer.blitCanvas = realBlit + +local hooks = Hooks.new() +Runtime.hooks = hooks +local state = { draw = function() end } +local stack = setmetatable({ states = { state } }, { __index = Stack }) +local fake = setmetatable( + { stack = stack, overworld = {}, data = require("tests.modkit.fixtures").fresh(), save = { options = {} } }, + { __index = Game } +) +local received = {} +hooks:wrap("render.hud", function(nextFn, game, viewport) + T.eq(game, fake, "HUD receives the original game") + received[#received + 1] = viewport + return nextFn(game, viewport) +end, 0, "retention") +w, h = 640, 480 +fake:_draw() +w, h = 800, 600 +fake:_draw() +T.check(received[1] ~= received[2], "HUD never receives recycled private results") +T.eq(received[1].width, 640, "retained HUD viewport remains unchanged") +T.eq(received[2].width, 800, "next HUD viewport receives current geometry") +local original = Renderer.endFrame +local wrapped, offered = 0, nil +Renderer.endFrame = function(self, ...) + wrapped = wrapped + 1 + offered = original(self, ...) + offered.modField = "kept" + return offered +end +fake:_draw() +T.eq(wrapped, 1, "existing public endFrame wrapper is not bypassed") +T.eq(received[#received], offered, "wrapper record is forwarded without replacing its identity") +T.eq(received[#received].modField, "kept", "wrapper-added fields remain available") +Renderer.endFrame = original +hooks:removeOwner("retention") +received = {} +local installed = false +hooks:wrap("render.compose", function(nextFn, ...) + if not installed then + installed = true + hooks:wrap("render.hud", function(nextHud, game, viewport) + received[#received + 1] = viewport + return nextHud(game, viewport) + end, 0, "late-hud") + end + return nextFn(...) +end, 0, "late-compose") +w, h = 640, 480 +fake:_draw() +w, h = 800, 600 +fake:_draw() +T.check(received[1] ~= received[2], "a HUD registered during compose also receives detached records") +T.eq(received[1].width, 640, "late registration does not leak mutable native metrics") +hooks:removeOwner("late-hud") +hooks:removeOwner("late-compose") +-- Window UI is absent from this engine base; the fork exercises that path. +T.finish() diff --git a/tests/engine/native_field_view.lua b/tests/engine/native_field_view.lua new file mode 100644 index 000000000..2a03227b5 --- /dev/null +++ b/tests/engine/native_field_view.lua @@ -0,0 +1,82 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +require('tools.native-atlas.cpu-gpu').install(love) +local Field=require('src.core.game3.field_view') +local Native=require('src.core.game3.tileset_native') +local Host=require('src.mods.Runtime') +local Game3=require('src.core.Game3') +local Map=require('src.core.game3.map') +local body=Field.draw +T.eq(body(),false,'no invented field when no native player is admitted') +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local f=require('tools.field-view-runtime.fixture').new(edition) + local game,world=f.game,f.world + local retained,callbacks,frames=nil,0,0 + Host.currentMod='field_owner' + local facade=assert(Game3.forNative(game)) + local ok,why=body(facade,240,160,{endUnderActors=function(upper)retained=upper;T.eq(upper(),true);callbacks=callbacks+1 end}) + assert(ok,why);T.eq(ok,true) + T.eq(callbacks,1);T.eq(retained(),false,'upper-layer callback expires at the render boundary') + T.eq(Field._viewW,240);T.eq(Field._viewH,160) + T.check(Field._nativeBatches[f.native.pair]~=nil,'real owned bottom batches cover the native map') + T.check(Field._nativeOverBatches[f.native.neighborPair]~=nil,'connected map retains its own source atlas') + T.eq(world.map.def.width,3,'padded compatibility width cannot resize the native room') + T.eq(Map.currentDef().midLayout.width,4);T.eq(Map.currentDef().midLayout.trueWidth,3) + local batches=Field._nativeBatches;local first=batches[f.native.pair] + T.eq(body(facade,240,160),true);T.eq(Field._nativeBatches,batches);T.eq(batches[f.native.pair],first,'stationary frames reuse GPU batches') + local count=0 + for _,e in ipairs(world.entities)do e.draw=function()count=count+1 end end + body(facade,240,160,{skipActors=true});T.eq(count,0,'terrain capture excludes native actors explicitly') + body(facade,240,160,{actorsOnly=true});T.eq(count,3,'actor capture draws the actual native entities') + T.eq(Field.setCameraPanning(7,-9),true) + local x,y + world.player.draw=function(_,cx,cy)x,y=cx,cy end + body(facade,320,200,{actorsOnly=true}) + T.eq(x,math.floor(world.player.px+8-160)+7);T.eq(y,math.floor(world.player.py+8-100)-9) + T.eq(Field.setFlashLevel(4),4);T.eq(game.save.gen3FlashLevel,4);T.eq(Field.flashRadius(),24) + T.eq(Field.setFlashLevel(200),0);T.eq(Field.flashRadius(),nil) + Field.animateFlashLevel(4,1);T.eq(game.save.gen3FlashLevel,1);T.eq(Field.flashRadius(),24) + local before=game.save.gen3FlashRadius;body(facade,240,160,{actorsOnly=true});T.eq(game.save.gen3FlashRadius,before,'draw does not advance the native Flash task') + require('src.world.Gen3Flash').tick(game);T.eq(Field.flashRadius(),25) + Field.setFlashRadius(37);T.eq(Field.flashRadius(),37);Field.setFlashRadius(nil) + local words={};for i=1,16 do words[i]=31 end + local bank=game.data.map_tilesets.P.palettes[1][2][1] + T.eq(Field.setBgPaletteOverride(0,words),true) + local ts=Native.get(f.native.pair);T.check(ts.patchedSlots[0],'actual source atlas palette is patched') + T.eq(game.data.map_tilesets.P.palettes[1][2][1],bank,'borrowed native colours are untouched') + T.eq(Field.setBgPaletteOverride(0,nil),true);T.eq(ts.patchedSlots[0],nil) + Field.setBgPaletteOverride(0,words);words[1]=0 + require('src.render.Assets').invalidate() + T.eq(body(facade,240,160),true,'native asset reload reconstructs field batches') + ts=assert(Native.get(f.native.pair)) + T.eq(ts._model.paletteOverrides[0][1][1],255,'active palette override survives reload with detached input words') + Field.setBgPaletteOverride(0,nil) + local nativeTick=require('src.render.TileRenderer').tick;local ticks=0 + require('src.render.TileRenderer').tick=function(...)ticks=ticks+1;return nativeTick(...)end + Field.draw=function(view,w,h,opts)frames=frames+1;T.eq(view,facade);return body(view,w,h,opts)end + world:drawWorld();T.eq(frames,1,'actual world draw enters the source replacement');T.eq(ticks,1,'one native tile cadence per presentation') + Field.draw=function()return false,'synthetic field decline'end + world:drawWorld();T.eq(ticks,2,'declining the source renderer does not tick again in native fallback') + Field.draw=function(view,w,h)world:drawWorld();return body(view,w,h)end + world:drawWorld();T.eq(ticks,3,'nested native presentation consumes the same native tile cadence') + require('src.render.TileRenderer').tick=nativeTick + Field.draw=function()love.graphics.push();love.graphics.setColor(.2,.3,.4,.5);error('field draw failure')end + local push,pop=love.graphics.push,love.graphics.pop;local canvas=love.graphics.getCanvas() + T.raises(function()world:drawWorld()end,'field draw failure','callback errors remain visible') + T.eq(love.graphics.push,push);T.eq(love.graphics.pop,pop) + T.eq(love.graphics.getCanvas(),canvas,'failed callback restores the caller target') + Field.draw=body + Host.currentMod=nil + local held=Field._nativeBatches + require('src.core.game3.asset_stream').cancelOwner('other_owner');T.eq(Field._nativeBatches,held,'another mod cannot retire the field owner') + require('src.core.game3.asset_stream').cancelOwner('field_owner');T.check(Field._nativeBatches~=held,'owning mod teardown retires the batches') + T.eq(body(facade,240,160),true) + Field.setFlashRadius(37);world.map=f.native.neighbor + T.eq(Field.flashRadius(),25,'map replacement retires the temporary radius and observes the native Flash task') + world.map=f.native.map + local live=Field._nativeBatches;game.save={options={},flags={}} + T.eq(Field.setCameraPanning(0,0),true);T.check(Field._nativeBatches~=live,'save replacement retires the prior rendering cohort') + f:release();Game3.release() +end +T.finish('native field draw admission, source atlas ownership, options and camera') diff --git a/tests/engine/native_flash_window.lua b/tests/engine/native_flash_window.lua new file mode 100644 index 000000000..94a9ad60b --- /dev/null +++ b/tests/engine/native_flash_window.lua @@ -0,0 +1,19 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local T=require('tests.harness') +love=require('tests.love_stub') +local Flash=require('src.world.Gen3Flash') +T.same(Flash.spans(0,7,5,3,2),{{y=0,height=2,left=0,right=0},{y=2,height=1,left=3,right=3},{y=3,height=2,left=0,right=0}}) +T.same(Flash.spans(1,7,5,3,2),{{y=0,height=1,left=0,right=0},{y=1,height=3,left=2,right=4},{y=4,height=1,left=0,right=0}}) +T.same(Flash.spans(2,7,5,3,2),{{y=0,height=5,left=1,right=5}},'window follows integer scanline update order') +local calls={};local rectangle=love.graphics.rectangle +love.graphics.rectangle=function(...)calls[#calls+1]={...}end +Flash.drawMask(24,240,160,120,80);local first=calls +local published=Flash.spans(24,240,160,120,80);published[1].left=239 +calls={};Flash.drawMask(24,240,160,120,80) +T.same(calls,first,'mutable public spans cannot corrupt the native geometry cache') +love.graphics.rectangle=rectangle +local game={save={gen3FlashLevel=1},data={constants={gen3Flash={maxLevel=1,radii={200,200},screen={240,160}}}}} +require('src.core.GameVersion').set('firered') +T.eq(Flash.circle(game,240,160,120,80),nil,'a centred window covering the view needs no mask') +T.eq(Flash.circle(game,240,160,300,-20).radius,200,'camera pan cannot discard a still-visible mask') +T.finish('native integer Flash geometry and camera bounds') diff --git a/tests/engine/world_draw_hot.lua b/tests/engine/world_draw_hot.lua new file mode 100644 index 000000000..a80c4a2dc --- /dev/null +++ b/tests/engine/world_draw_hot.lua @@ -0,0 +1,135 @@ +local T = require("tests.harness") +love = require("tests.love_stub") +local Version = require("src.core.GameVersion") +local Performance = require("src.core.Performance") +local World = require("src.world.OverworldController") +local Fixture = require("tests.world_draw.fixture") +local originalPcall = pcall +for _, version in ipairs({ "red", "gold", "emerald", "platinum" }) do + Version.set(version) + local f = Fixture.new(32) + local world = f.world + Performance.worldDrawHoist = false + world:drawWorld() + f.reset() + local beforeCalls = 0 + _G.pcall = function(...) + beforeCalls = beforeCalls + 1 + return originalPcall(...) + end + world:drawWorld() + _G.pcall = originalPcall + local before = f.trace() + f.reset() + Performance.worldDrawHoist = true + local afterCalls = 0 + _G.pcall = function(...) + afterCalls = afterCalls + 1 + return originalPcall(...) + end + world:drawWorld() + _G.pcall = originalPcall + T.same(f.trace(), before, version .. " keeps tile/reflection/entity/top-layer/FX draw order and coordinates") + T.check(beforeCalls > afterCalls, version .. " reduces protected calls for the entity pass") + T.eq(afterCalls, 1, version .. " has one protected ordinary entity loop") + local list, cover = world._worldReflectors, world._worldCover + world:drawWorld() + T.eq(world._worldReflectors, list, "reflector list is private and reused") + T.eq(world._worldCover, cover, "parallel cover columns are private and reused") + world.entities = { world.player } + world.ripples = {} + world:drawWorld() + T.eq(list.n, 0, "shrinking frame clears reflector count") + T.eq(list[1], nil, "shrinking frame releases stale entity references") +end +Version.set("red") +local f = Fixture.new(3) +local world = f.world +local retained = {} +world.rippleCells = function(self, out, seen) + retained[#retained + 1] = out + T.check(out ~= self._worldCover, "public replacement never receives a private pool") + return World.rippleCells(self, out, seen) +end +world:drawWorld() +world:drawWorld() +T.check(retained[1] ~= retained[2], "public ripple wrapper keeps fresh cell records") +T.check(retained[1][1] ~= retained[2][1], "public cell pairs remain detached") +local ended = 0 +world.map.renderer.gen4Ground = { + rise = function() + return 0 + end, + billboardDepth = function() + return nil + end, + beginSprites = function() + return true + end, + endSprites = function() + ended = ended + 1 + end, +} +world.player.draw = function() + error("synthetic entity failure") +end +local ok, err = pcall(World.drawWorld, world) +T.eq(ok, false, "entity errors are propagated") +T.check(tostring(err):find("synthetic entity failure", 1, true) ~= nil, "original entity error remains visible") +T.eq(ended, 1, "sprite depth pass closes exactly once on failure") +-- Nested cover drawing must not overwrite the outer borrowed columns. +local nested = false +local visited = {} +world.rippleCells = World.rippleCells +world.map.renderer.drawReflectionCover = function(_, cx, cy) + visited[#visited + 1] = cx .. ":" .. cy + if not nested then + nested = true + world:_worldReflectionCover({}, 0, { drawReflectionCover = function() end }, world.camera, 0) + end +end +world:_worldReflectionCover({ world.entities[2] }, 1, world.map.renderer, world.camera, 0) +T.same( + visited, + { "2:3", "3:3", "2:4", "3:4", "0:1", "1:1", "0:2", "1:2" }, + "nested cover work cannot clobber the outer columns" +) +-- A pipeline may retain its no-argument FX callbacks after a later frame. +local P = require("src.render.Pipelines") +local saved = + { worldPipeline = P.worldPipeline, drawWorld = P.drawWorld, worldPresent = P.worldPresent, level = P.level } +local frames = {} +P.worldPipeline = function() + return "synthetic", {} +end +P.drawWorld = function(_, ctx) + frames[#frames + 1] = ctx + return {} +end +P.worldPresent = function(canvas) + return canvas +end +P.level = function() + return 1 +end +local scene = Fixture.new(2) +scene.world:drawWorld() +scene.world.camera = { x = 67, y = 5 } +scene.world:drawWorld() +T.check(frames[1] ~= frames[2] and frames[1].fx ~= frames[2].fx, "public pipeline frames and FX tables remain detached") +T.check(frames[1].fx.water ~= frames[2].fx.water, "public FX callbacks keep their own frame binding") +local positions = {} +local rectangle = love.graphics.rectangle +love.graphics.rectangle = function(_, x) + positions[#positions + 1] = x +end +frames[1].fx.water() +local oldX = positions[1] +positions = {} +frames[2].fx.water() +T.eq(oldX - positions[1], 64, "retained zero-argument callback keeps the original camera") +love.graphics.rectangle = rectangle +for name, value in pairs(saved) do + P[name] = value +end +T.finish("world draw hot paths") diff --git a/tests/love_stub.lua b/tests/love_stub.lua index b7fc25fa9..c3b191a09 100644 --- a/tests/love_stub.lua +++ b/tests/love_stub.lua @@ -53,6 +53,10 @@ stub.graphics = { function batch:add(quad, x, y) table.insert(self.sprites, { quad, x, y }) end function batch:clear() self.sprites = {} end function batch:setTexture(tex) self.texture = tex end + function batch:getTexture() return self.texture or self.image end + function batch:getBufferSize() return size end + function batch:getCount() return #self.sprites end + function batch:release() self.released = true end return batch end, draw = noop, rectangle = noop, clear = noop, diff --git a/tests/world_draw/fixture.lua b/tests/world_draw/fixture.lua new file mode 100644 index 000000000..d7f8f773c --- /dev/null +++ b/tests/world_draw/fixture.lua @@ -0,0 +1,118 @@ +-- Owned synthetic draw scene: no ROM, profile, asset extraction or game progress. +local F = {} +function F.new(count, options) + local World = options and options.worldModule or require("src.world.OverworldController") + local Game = require("src.core.Game") + -- The normal enter lifecycle supplies this shared engine reference. The + -- isolated rendering fixture has no map loader or save lifecycle to enter. + for index = 1, 100 do + local name = debug.getupvalue(World.drawWorld, index) + if not name then + break + end + if name == "Game" then + debug.setupvalue(World.drawWorld, index, Game) + break + end + end + Game.data = { field = { overworldFx = {} }, constants = {}, sprites = {} } + Game.save = { options = {} } + local trace = {} + local function mark(name, ...) + if options and options.quiet then + return + end + local row = { name, ... } + for i = 1, #row do + row[i] = tostring(row[i]) + end + trace[#trace + 1] = table.concat(row, ":") + end + local renderer = {} + for _, name in ipairs({ "drawBorderFill", "draw", "drawMapOnly", "drawAbove", "drawReflectionCover" }) do + renderer[name] = function(_, ...) + mark(name, ...) + end + end + local world = setmetatable({ + camera = { x = 3, y = 5 }, + map = { + id = "SYNTHETIC", + renderer = renderer, + isGrassCell = function() + return false + end, + }, + neighbors = {}, + ghosts = {}, + entities = {}, + gen4Shadows = false, + waterAnim = { x = 1, y = 1, frames = 12, total = 40 }, + ripples = { { px = 15, py = 31, clock = 0 } }, + }, { __index = World }) + for i = 1, count or 3 do + local e = { + px = i * 16, + py = i * 16, + cellX = i, + cellY = i, + facing = "down", + above = i % 7 == 0, + draw = function(self, cx, cy) + mark("entity", self.cellX, cx, cy) + end, + drawReflection = function(self, cx, cy) + mark("reflection", self.cellX, cx, cy) + end, + } + world.entities[i] = e + end + world.player = world.entities[1] + world.reflectionSearch = function(_, e) + return e.cellX % 2 == 0 and { { e.cellX, e.cellY + 1 } } or nil + end + world.stillReflection = function() + return false + end + world.reflectionRect = function(_, e) + return e.cellX, e.cellY + 1, e.cellX + 1, e.cellY + 2 + end + world.gen3AboveTopLayer = function(_, e) + return e.above + end + world.drawRipples = function(_, cx, cy) + mark("ripples", cx, cy) + end + world.drawGen3Gates = function() + mark("gates") + end + world.drawGen3Flash = function() + mark("flash") + end + world.drawGen3Decorate = function() + mark("decoration") + end + world.placeFreeCamera = function() end + world.reflectProbe, world.reflectProbe2 = 1, 1 + Game.renderer = { + worldViewSize = function() + return 160, 144 + end, + fitScale = function() + return 1 + end, + setWorldOverride = function() end, + } + return { + world = world, + game = Game, + trace = function() + return trace + end, + reset = function() + trace = {} + end, + mark = mark, + } +end +return F diff --git a/tools/check_frame_pacing.py b/tools/check_frame_pacing.py new file mode 100644 index 000000000..a46da9de5 --- /dev/null +++ b/tools/check_frame_pacing.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Run owned-window VSync pacing measurements through the local QA guard.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import sys +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--love', type=Path, required=True) + parser.add_argument('--report', type=Path, required=True) + parser.add_argument('--qa-tools', type=Path) + workload = parser.add_mutually_exclusive_group() + workload.add_argument('--gc', action='store_true', help='compare fixed-step GC with the P2 idle budget') + workload.add_argument('--hot-path', action='store_true', help='compare actual native update/draw dependency dispatch') + workload.add_argument('--world-draw', action='store_true', help='compare native world entity/FX dispatch over synthetic content') + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(args.qa_tools or root / 'tools')) + try: + from qa_storage import qa_source, qa_cache, qa_profile, qa_run + except ImportError: + parser.error('QA storage guard is required; provide --qa-tools') + qa_source(root) + qa_source(args.love.resolve()) + files = ('main.lua', 'src/core/FrameCap.lua', 'src/core/FrameProfile.lua', + 'tools/frame-pacing-check/main.lua', 'tools/frame-pacing-check/conf.lua') + if args.gc: + files += ('src/core/Performance.lua', 'src/core/GCFrameBudget.lua', 'src/core/Game.lua', + 'tools/frame-pacing-check/gc.lua') + if args.hot_path: + files += ('src/core/Game.lua', 'src/core/HotModules.lua', 'src/core/Performance.lua', + 'src/render/Renderer.lua', 'src/render/PaletteFX.lua', 'tools/frame-pacing-check/hot.lua') + if args.world_draw: + files += ('src/core/Game.lua', 'src/core/Performance.lua', 'src/core/HotModules.lua', + 'src/world/OverworldController.lua', 'src/render/Renderer.lua', + 'src/render/PaletteFX.lua', 'src/render/Pipelines.lua', + 'tests/world_draw/fixture.lua', 'tools/frame-pacing-check/world.lua') + hashes = {name: hashlib.sha256((root / name).read_bytes()).hexdigest() for name in files} + source = qa_run(['git', 'rev-parse', 'HEAD'], cwd=root, text=True, + capture_output=True, check=True).stdout.strip() + qa_cache(args.report.parent) + args.report.parent.mkdir(parents=True, exist_ok=True) + identity = 'oasc-gen2recomp-qa-frame-pacing-' + str(time.time_ns()) + qa_profile(Path.home() / 'Library/Application Support/LOVE' / identity) + result = qa_run([str(args.love.resolve()), str(root / 'tools/frame-pacing-check')], + cwd=root, env=dict(os.environ, POKEPORT_IDENTITY=identity, + FRAME_PACING_REPO=str(root), FRAME_PACING_QA_GUARDED='1', + FRAME_PACING_GC='1' if args.gc else '0', + FRAME_PACING_HOT='1' if args.hot_path else '0', + FRAME_PACING_WORLD='1' if args.world_draw else '0'), + capture_output=True, text=True, timeout=90) + args.report.with_suffix('.log').write_text(result.stdout + result.stderr) + prefix = 'P4_NATIVE_RESULT ' if args.world_draw else 'P3_NATIVE_RESULT ' if args.hot_path else 'P2_NATIVE_RESULT ' if args.gc else 'P1_NATIVE_RESULT ' + records = [line.removeprefix(prefix) for line in result.stdout.splitlines() if line.startswith(prefix)] + if result.returncode or len(records) != 1: + raise RuntimeError('native pacing probe failed; see ' + str(args.report.with_suffix('.log'))) + report = json.loads(records[0]) + if any(hashlib.sha256((root / name).read_bytes()).hexdigest() != digest for name, digest in hashes.items()): + raise RuntimeError('Measured source changed during the pacing probe') + if qa_run(['git', 'rev-parse', 'HEAD'], cwd=root, text=True, + capture_output=True, check=True).stdout.strip() != source: + raise RuntimeError('Measured commit changed during the pacing probe') + report['source'] = source + report['sha256'] = hashes + report['minimumFreeGiB'] = 20 + report['playerProfilesUsed'] = False + args.report.write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps(report, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/tools/field-view-runtime/fixture.lua b/tools/field-view-runtime/fixture.lua new file mode 100644 index 000000000..60bdb79a4 --- /dev/null +++ b/tools/field-view-runtime/fixture.lua @@ -0,0 +1,38 @@ +-- Actual native map records, bank decoder, world renderer and source facade; +-- all artwork and actors are synthetic and independent of any ROM or profile. +local F={} +function F.new(edition) + local Version=require('src.core.GameVersion');Version.set(edition) + local original=package.loaded['src.core.Game'] + local scene=require('tests.world_draw.fixture').new(3) + local native=require('tools.native-atlas/session-fixture').new(edition) + package.loaded['src.core.Game']=scene.game + local game,world=scene.game,scene.world + game.data=native.game.data;game.data.field={overworldFx={}} + for _,bank in pairs(game.data.map_tilesets)do + bank.sourceBehaviorMask=(edition=='firered'or edition=='leafgreen')and 511 or 255 + end + game.data.constants.gen3Flash={maxLevel=4,radii={200,72,56,40,24},screen={240,160},lit=0,dark=4} + game.save={options={},flags={},gen3FlashLevel=0};game.mods={exports={}} + world.map=native.map;world.isOverworld=true + world.neighbors={{map=native.neighbor,ox=48,oy=-16}} + world.camera={x=0,y=0};world.player=world.entities[1] + world.enter=function()end -- this synthetic map/actor admission is already assembled + world.player.px,world.player.py=16,16;world.player.spinning=nil + world.map.renderer=require('src.render.TileRenderer').new(world.map,game.data) + native.neighbor.renderer=require('src.render.TileRenderer').new(native.neighbor,game.data) + local Stack=require('src.core.StateStack') + game.stack=setmetatable({},{__index=Stack});game.stack:init() + if game.stack.setWorldOwner then game.stack:setWorldOwner({})end + game.stack:push(world) + game.overworld=world + world.drawGen3Flash=require('src.world.OverworldController').drawGen3Flash + local f={game=game,world=world,native=native,scene=scene,original=original} + function f:release() + require('src.core.game3.field_view').release();require('src.core.game3.map').release() + world.map.renderer:release();native.neighbor.renderer:release();native.views:release() + require('src.core.game3.tileset_native').release();package.loaded['src.core.Game']=original + end + return f +end +return F diff --git a/tools/field-view-runtime/host/conf.lua b/tools/field-view-runtime/host/conf.lua new file mode 100644 index 000000000..363802762 --- /dev/null +++ b/tools/field-view-runtime/host/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + t.identity=assert(os.getenv('POKEPORT_IDENTITY')) + t.window.width,t.window.height=480,320;t.window.vsync=0;t.window.highdpi=false + t.modules.audio,t.modules.joystick,t.modules.physics=false,false,false + t.graphics=t.graphics or{} + t.graphics.renderers={assert(os.getenv('POKEPORT_GRAPHICS_BACKEND'))} +end diff --git a/tools/field-view-runtime/host/main.lua b/tools/field-view-runtime/host/main.lua new file mode 100644 index 000000000..798eea130 --- /dev/null +++ b/tools/field-view-runtime/host/main.lua @@ -0,0 +1,155 @@ +io.stdout:setvbuf('no') +local root=assert(os.getenv('FIELD_VIEW_ENGINE')) +package.path=root..'/?.lua;'..root..'/?/init.lua;'..package.path +function love.errorhandler(err)io.stderr:write(debug.traceback(tostring(err),2)..'\n');return function()return 1 end end +function love.load() + local g=love.graphics + local T=require('tests.harness').suite('native field presentation GPU') + local Field=require('src.core.game3.field_view');local base=Field.draw + local Native=require('src.core.game3.tileset_native') + local Flash=require('src.world.Gen3Flash') + local Game3=require('src.core.Game3') + local readbacks,comparisons=0,0 + local renderer,version,vendor,device=g.getRendererInfo() + local backend=assert(os.getenv('POKEPORT_GRAPHICS_BACKEND')) + T.check(renderer:lower():find(backend,1,true)~=nil,'requested actual graphics backend') + T.eq(love.filesystem.getIdentity(),os.getenv('POKEPORT_IDENTITY'),'owned QA profile') + local function canvas(w,h)return g.newCanvas(w,h,{format='rgba8',dpiscale=1,msaa=0})end + local function read(texture) + local w,h=texture:getDimensions();local copy=canvas(w,h) + g.push('all');g.origin();g.setCanvas(copy);g.setShader();g.setScissor();g.clear(0,0,0,0) + g.setBlendMode('replace','premultiplied');g.setColor(1,1,1,1);g.draw(texture,0,0);g.pop() + local pixels=g.readbackTexture and g.readbackTexture(copy)or copy:newImageData() + local bytes=pixels:getString();pixels:release();copy:release();readbacks=readbacks+1;return bytes + end + local function draw(target,fn) + g.push('all');g.origin();g.setCanvas(target);g.setShader();g.setScissor();g.clear(0,0,0,0) + g.setBlendMode('alpha');g.setColor(1,1,1,1);fn();g.pop();return read(target) + end + local function samePixels(actual,expected,label) + T.check(actual==expected,label);comparisons=comparisons+1 + if actual~=expected then + for i=1,math.min(#actual,#expected)do if actual:byte(i)~=expected:byte(i)then print('FIRST_PIXEL_BYTE_DIFFERENCE',i,actual:byte(i),expected:byte(i));break end end + end + end + local function expectedTerrain(f,w,h,panX,panY) + -- Independent room/connection geometry: three true cells, not the padded + -- four-cell storage stride, plus the two authored eastern strips. + local a=assert(Native.get(f.native.pair));local b=assert(Native.get(f.native.neighborPair)) + local split=f.native.split;local mids={0,split,1,2,split+1,split+2} + local camX=math.floor(f.world.player.px+8-w/2)+panX + local camY=math.floor(f.world.player.py+8-h/2)+panY + local atlas={} + for _,ts in ipairs{a,b}do atlas[ts]={ts.imageData:getString(),ts.overImageData:getString()}end + local rows={} + for y=0,h-1 do local row={} + for x=0,w-1 do + local wx,wy=x+camX,y+camY;local cx,cy=math.floor(wx/16),math.floor(wy/16) + local ts,mid=a,nil + if cx>=0 and cx<3 and cy>=0 and cy<2 then mid=mids[cy*3+cx+1] + elseif cx>=3 and cx<6 then + for _,offset in ipairs{3,-1}do + local ny=cy-offset + if ny>=0 and ny<2 then ts,mid=b,mids[ny*3+cx-3+1];break end + end + end + mid=mid or(cy%2==0 and 1 or 2) + local slot=assert(ts.midToSlot[mid]);local px,py=wx%16,wy%16 + local at=(((math.floor(slot/ts.cols)*16+py)*(ts.cols*16))+(slot%ts.cols)*16+px)*4+1 + local u,o=atlas[ts][1]:sub(at,at+3),atlas[ts][2]:sub(at,at+3) + row[x+1]=o:byte(4)>0 and o or u + end + rows[y+1]=table.concat(row) + end + return table.concat(rows) + end + for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local f=require('tools.field-view-runtime.fixture').new(edition) + local world,game=f.world,f.game + world.waterAnim,world.ripples=nil,{} + local facade=assert(Game3.forNative(game)) + local borrowed=world.map.renderer.gen3.bottom;local borrowedBytes=read(borrowed) + for _,size in ipairs{{240,160},{320,200},{480,320}}do + local w,h=size[1],size[2];local target=canvas(w,h) + for _,pan in ipairs{{0,0},{7,-9},{-37,23}}do + Field.setCameraPanning(pan[1],pan[2]) + local bytes=draw(target,function()assert(base(facade,w,h,{skipActors=true}))end) + samePixels(bytes,expectedTerrain(f,w,h,pan[1],pan[2]),'native cells, borders, connected pairs and upper layer match CPU texels') + end + Field.setCameraPanning(0,0) + local prior + local bytes=draw(target,function()assert(base(facade,w,h,{skipActors=true,endUnderActors=function(fn)prior=fn end}))end) + T.eq(prior,nil,'skipActors also excludes the actor-layer callback') + local again=draw(target,function()assert(base(facade,w,h,{skipActors=true}))end) + samePixels(bytes,again,'repeated field capture is stable') + target:release() + end + local target=canvas(240,160) + for _,e in ipairs(world.entities)do e.hidden=true end + world.player.hidden=false + world.player.draw=function(_,cx,cy) + g.setColor(1,0,0,1);g.rectangle('fill',world.player.px-cx+4,world.player.py-cy+2,8,12) + g.setColor(0,1,0,1);g.rectangle('fill',world.player.px-cx+4,world.player.py-cy+2,2,2) + end + local bytes=draw(target,function()assert(base(facade,240,160,{actorsOnly=true}))end) + local function pixel(x,y)return bytes:sub((y*240+x)*4+1,(y*240+x)*4+4)end + T.eq(pixel(116,74),string.char(0,255,0,255),'native actor keeps its asymmetric top-left marker') + T.eq(pixel(119,80),string.char(255,0,0,255),'native actor body keeps its source anchor') + T.eq(pixel(0,0),string.rep('\0',4),'actor-only capture excludes terrain and native FX') + local words={};for i=1,16 do words[i]=31 end + local patched=draw(target,function()assert(Field.setBgPaletteOverride(0,words));assert(base(facade,240,160,{skipActors=true}))end) + samePixels(patched,expectedTerrain(f,240,160,0,0),'palette upload reaches the actual field batches') + local old=Field._nativeBatch + require('src.render.Assets').invalidate() + local reloaded=draw(target,function()assert(base(facade,240,160,{skipActors=true}))end) + samePixels(reloaded,patched,'asset reload preserves the active palette presentation') + T.check(not pcall(old.getCount,old),'asset reload retires the old GPU batches') + Field.setBgPaletteOverride(0,nil) + local ts=assert(Native.get(f.native.pair));local batch=Field._nativeBatch + local bytes=string.rep(string.char(255,0,255,255),256) + local Upload=require('src.compat.gen1.AtlasUpload') + Upload.patch(ts.imageData,ts.cols,ts.midToSlot[1],bytes,true) + Native.markDirty(ts,false,ts.midToSlot[1]);Native.flush(ts,true,false,3,true) + local uploaded=draw(target,function()assert(base(facade,240,160,{skipActors=true}))end) + samePixels(uploaded,expectedTerrain(f,240,160,0,0),'rotated animation upload is sampled by queued batches') + T.eq(Field._nativeBatch,batch,'texture rotation retains stationary cell geometry') + T.eq(batch:getTexture(),ts.image,'stationary batch is bound to the current upload buffer') + Field.invalidate() + local frame=0 + game.renderer={worldViewSize=function()return 240,160 end} + Field.draw=function(view,w,h)frame=frame+1;T.eq(view,facade);return base(view,w,h,{skipActors=true})end + local replaced=draw(target,function()world:drawWorld()end) + T.eq(frame,1,'actual native world phase invokes the source wrapper') + samePixels(replaced,expectedTerrain(f,240,160,0,0),'native fallback is the same source atlas presentation') + local failedTarget=canvas(16,16) + Field.draw=function()g.push();g.setCanvas(failedTarget);g.setColor(.2,.3,.4,.5);error('GPU field failure')end + g.push('all');g.setCanvas(target);local depth=g.getStackDepth();local current=g.getCanvas() + T.raises(function()world:drawWorld()end,'GPU field failure') + T.eq(g.getStackDepth(),depth,'native callback failure restores graphics stack depth') + T.eq(g.getCanvas(),current,'native callback failure restores actual GPU target');g.pop() + Field.draw=base;failedTarget:release() + -- Integer masks are qualified separately against pinned source scanlines. + -- These GPU checks prove that every black/transparent pixel follows them. + for _,case in ipairs{{0,120,80},{24,120,80},{37,-9,17},{72,137,61},{200,300,-20}}do + local radius,cx,cy=unpack(case) + local actual=draw(target,function()Flash.drawMask(radius,240,160,cx,cy)end) + local expected={};local spans=Flash.spans(radius,240,160,cx,cy) + for _,s in ipairs(spans)do for y=s.y,s.y+s.height-1 do + local row={};for x=0,239 do row[#row+1]=(x=s.right)and'\0\0\0\255'or'\0\0\0\0'end + expected[y+1]=table.concat(row) + end end + samePixels(actual,table.concat(expected),'GPU black window follows integer scanlines under pan') + end + local batch=Field._nativeBatch;Field.release() + T.check(not pcall(batch.getCount,batch),'field retirement releases its owned GPU batch') + samePixels(read(borrowed),borrowedBytes,'borrowed native atlas survives source rendering and teardown') + target:release();f:release();Game3.release() + end + T.finish() + local report={passed=true,checks=T.checks,readbacks=readbacks,comparisons=comparisons, + backend=backend,renderer=renderer,version=version,vendor=vendor,device=device, + playerProfilesUsed=false,gameplayProven=false, + scope='Actual native world draw boundary and source atlas composition over synthetic native maps/actors; all five GBA editions, three view sizes, camera pan, integer masks and GPU ownership.'} + local file=assert(io.open(assert(os.getenv('FIELD_VIEW_REPORT')),'w'));file:write(require('src.link.Json').encode(report));file:close() + print('NATIVE_FIELD_VIEW_GPU_PASS',readbacks,comparisons);love.event.quit(0) +end diff --git a/tools/frame-pacing-check/conf.lua b/tools/frame-pacing-check/conf.lua new file mode 100644 index 000000000..79551e6d7 --- /dev/null +++ b/tools/frame-pacing-check/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + assert(os.getenv("FRAME_PACING_QA_GUARDED") == "1", "use tools/check_frame_pacing.py") + t.identity = assert(os.getenv("POKEPORT_IDENTITY")) + t.window.title = "Frame pacing QA" + t.window.width, t.window.height, t.window.vsync = 320, 180, 1 + t.modules.audio = false +end diff --git a/tools/frame-pacing-check/gc.lua b/tools/frame-pacing-check/gc.lua new file mode 100644 index 000000000..80067fa1e --- /dev/null +++ b/tools/frame-pacing-check/gc.lua @@ -0,0 +1,124 @@ +local Cap = require("src.core.FrameCap") +local Performance = require("src.core.Performance") +local Budget = require("src.core.GCFrameBudget") +local Profile = require("src.core.FrameProfile") +local Json = require("src.link.Json") +local phase, frame, deadline, started, cpuStarted = 1, 0, 0, 0, 0 +local WINDOW, WARMUP = 120, 120 +local results, _trash = {}, nil +local witness = { key = "retained native data", value = 2712 } +local legacySteps, idleSteps, idleTotal, idleMax, heapPeak = 0, 0, 0, 0, 0 +local timer = { getTime = love.timer.getTime, sleep = love.timer.sleep } +local _, _, mode = love.window.getMode() +Cap.current = tonumber(mode.refreshrate) or 60 +if Cap.current <= 0 then + Cap.current = 60 +end +Cap.vsyncPacing = true +local originalPause = collectgarbage("setpause", 120) +collectgarbage("setpause", originalPause) +local originalMul = collectgarbage("setstepmul", 300) +collectgarbage("setstepmul", originalMul) + +local function resetStats() + legacySteps, idleSteps, idleTotal, idleMax, heapPeak = 0, 0, 0, 0, 0 +end +local function idle(clock, target) + local start = clock.getTime() + local steps = Budget.idle(clock, target) + if steps > 0 then + local duration = clock.getTime() - start + idleSteps, idleTotal = idleSteps + steps, idleTotal + duration + idleMax = math.max(idleMax, duration) + end +end +local function startPhase() + Profile.setEnabled(false) + Performance.gcBudget = phase == 2 + Budget.configure(Performance) + collectgarbage("collect") + frame = 0 + resetStats() + deadline = timer.getTime() +end +local function finishPhase() + assert(witness.key == "retained native data" and witness.value == 2712) + assert(_trash[512][2] == 512) + results[phase] = { + enabled = Performance.gcBudget, + frames = WINDOW, + seconds = timer.getTime() - started, + processCpuMsPerFrame = (os.clock() - cpuStarted) / WINDOW * 1000, + legacySteps = legacySteps, + idleSteps = idleSteps, + idleMeanMs = idleTotal / WINDOW * 1000, + idleMaxMs = idleMax * 1000, + heapPeakKiB = heapPeak, + } + if phase == 2 then + Performance.gcBudget = false + Budget.configure(Performance) + local pause = collectgarbage("setpause", originalPause) + local mul = collectgarbage("setstepmul", originalMul) + assert(pause == originalPause and mul == originalMul, "disable did not restore GC tuning") + print("P2_NATIVE_RESULT " .. Json.encode({ + os = love.system.getOS(), + arch = jit and jit.arch or "unknown", + renderer = love.graphics.getRendererInfo(), + refresh = mode.refreshrate, + vsync = love.window.getVSync(), + cap = Cap.current, + originalPause = originalPause, + originalStepMul = originalMul, + phases = results, + })) + return true + end + phase = 2 + startPhase() +end +function love.run() + startPhase() + return function() + love.event.pump() + for name in love.event.poll() do + if name == "quit" then + return 1 + end + end + if frame == WARMUP then + resetStats() + Profile.setEnabled(true) + started, cpuStarted = timer.getTime(), os.clock() + end + Budget.beginFrame(Performance) + local close = Profile.section("update: allocation workload") + local rows = {} + for index = 1, 512 do + rows[index] = { frame, index, a = index * 2, b = index * 3 } + end + _trash = rows + close() + if not Performance.gcBudget then + close = Profile.section(" update: gc step") + collectgarbage("step", 1) + legacySteps = legacySteps + 1 + close() + end + love.graphics.clear(0.03, 0.04, 0.06) + close = Profile.section("present (GPU wait / vsync)") + love.graphics.present() + close() + close = Profile.section("frame pacing: sleep") + deadline = Cap.pace(timer, love.window, true, deadline, idle) + close() + frame = frame + 1 + if frame > WARMUP then + heapPeak = math.max(heapPeak, collectgarbage("count")) + Profile.frame() + end + if frame == WARMUP + WINDOW and finishPhase() then + return 0 + end + end +end diff --git a/tools/frame-pacing-check/hot.lua b/tools/frame-pacing-check/hot.lua new file mode 100644 index 000000000..8c8fdec9a --- /dev/null +++ b/tools/frame-pacing-check/hot.lua @@ -0,0 +1,114 @@ +local Version = require("src.core.GameVersion") +Version.set("red") +local Game = require("src.core.Game") +local Renderer = require("src.render.Renderer") +local Stack = require("src.core.StateStack") +local Performance = require("src.core.Performance") +local Cap = require("src.core.FrameCap") +local Profile = require("src.core.FrameProfile") +local Json = require("src.link.Json") +local graphics = love.graphics +Game.data = require("tests.modkit.fixtures").fresh() +Game.save = { version = "red", options = {}, player = {}, modData = {} } +Game.overworld = {} +Game.stack = setmetatable( + { + states = { + { + draw = function() + graphics.setColor(0.2, 0.4, 0.6, 1) + graphics.rectangle("fill", 16, 16, 96, 80) + graphics.setColor(1, 1, 1, 1) + end, + }, + }, + }, + { __index = Stack } +) +Renderer:init() +local phase, frame, deadline, started, cpuStarted = 1, 0, 0, 0, 0 +local WINDOW, WARMUP = 120, 120 +local drawTotal, drawMax, requireCalls, requires = 0, 0, 0, {} +local results = {} +local _, _, mode = love.window.getMode() +Cap.current = tonumber(mode.refreshrate) or 60 +if Cap.current <= 0 then + Cap.current = 60 +end +Cap.vsyncPacing = true + +local function countRequires() + local original = require + requireCalls, requires = 0, {} + _G.require = function(name, ...) + requireCalls = requireCalls + 1 + requires[name] = (requires[name] or 0) + 1 + return original(name, ...) + end + local ok, err = pcall(Game.draw, Game) + _G.require = original + assert(ok, err) +end +local function startPhase() + Profile.setEnabled(false) + Performance.hotPathHoist = phase == 2 + frame, drawTotal, drawMax = 0, 0, 0 + deadline = love.timer.getTime() +end +local function finishPhase() + results[phase] = { + enabled = Performance.hotPathHoist, + frames = WINDOW, + seconds = love.timer.getTime() - started, + drawMeanMs = drawTotal / WINDOW * 1000, + drawMaxMs = drawMax * 1000, + processCpuMsPerFrame = (os.clock() - cpuStarted) / WINDOW * 1000, + requireCalls = requireCalls, + requires = requires, + } + if phase == 2 then + print("P3_NATIVE_RESULT " .. Json.encode({ + os = love.system.getOS(), + arch = jit and jit.arch or "unknown", + renderer = graphics.getRendererInfo(), + refresh = mode.refreshrate, + vsync = love.window.getVSync(), + cap = Cap.current, + phases = results, + scope = "Native Game:draw and real canvas composition over owned synthetic content, no world/battle/mod scene", + })) + return true + end + phase = 2 + startPhase() +end +function love.run() + startPhase() + return function() + love.event.pump() + for name in love.event.poll() do + if name == "quit" then + return 1 + end + end + if frame == WARMUP then + countRequires() + Profile.setEnabled(true) + started, cpuStarted = love.timer.getTime(), os.clock() + end + local start = love.timer.getTime() + Game:draw() + if frame >= WARMUP then + local elapsed = love.timer.getTime() - start + drawTotal, drawMax = drawTotal + elapsed, math.max(drawMax, elapsed) + end + local close = Profile.section("present (GPU wait / vsync)") + graphics.present() + close() + deadline = Cap.pace(love.timer, love.window, true, deadline) + frame = frame + 1 + if frame == WARMUP + WINDOW and finishPhase() then + return 0 + end + end +end diff --git a/tools/frame-pacing-check/main.lua b/tools/frame-pacing-check/main.lua new file mode 100644 index 000000000..8f689eaf0 --- /dev/null +++ b/tools/frame-pacing-check/main.lua @@ -0,0 +1,110 @@ +local root = assert(os.getenv("FRAME_PACING_REPO")) +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +function love.errorhandler(message) + print(debug.traceback(tostring(message),2)) + return function()return 1 end +end +if os.getenv("FRAME_PACING_GC") == "1" then + require("tools.frame-pacing-check.gc") + return +end +if os.getenv("FRAME_PACING_HOT") == "1" then + require("tools.frame-pacing-check.hot") + return +end +if os.getenv("FRAME_PACING_WORLD") == "1" then + require("tools.frame-pacing-check.world") + return +end +local Cap = require("src.core.FrameCap") +local Profile = require("src.core.FrameProfile") +local Json = require("src.link.Json") +local phase, frame, deadline, last, started, cpuStarted = 1, 0, 0, 0, 0, 0 +local sleeps, intervals, results = 0, {}, {} +local WINDOW, WARMUP = 120, 120 +local timer = { + getTime = love.timer.getTime, + sleep = function(dt) + sleeps = sleeps + 1 + love.timer.sleep(dt) + end, +} +local _, _, mode = love.window.getMode() +local refresh = tonumber(mode.refreshrate) or 0 +Cap.current = refresh > 0 and refresh or 60 +local function startPhase() + Profile.setEnabled(false) + Cap.vsyncPacing = phase == 2 + frame, sleeps, intervals = 0, 0, {} + deadline = timer.getTime() +end +local function finishPhase() + local long = 0 + for _, interval in ipairs(intervals) do + if refresh > 0 and interval > 1.5 / refresh then + long = long + 1 + end + end + table.sort(intervals) + results[phase] = { + enabled = Cap.vsyncPacing, + frames = WINDOW, + seconds = timer.getTime() - started, + medianMs = intervals[math.floor(#intervals / 2)] * 1000, + p95Ms = intervals[math.ceil(#intervals * 0.95)] * 1000, + maxMs = intervals[#intervals] * 1000, + framesOver1_5Periods = long, + extraSleepCalls = sleeps, + processCpuMsPerFrame = (os.clock() - cpuStarted) / WINDOW * 1000, + } + if phase == 2 then + if refresh > 0 and love.window.getVSync() ~= 0 then + assert(sleeps == 0, "VSync phase still slept") + end + print("P1_NATIVE_RESULT " .. Json.encode({ + refresh = refresh, + vsync = love.window.getVSync(), + cap = Cap.current, + os = love.system.getOS(), + arch = jit and jit.arch or "unknown", + renderer = love.graphics.getRendererInfo(), + phases = results, + })) + return true + end + phase = 2 + startPhase() +end +function love.run() + startPhase() + return function() + love.event.pump() + for name in love.event.poll() do + if name == "quit" then + return 1 + end + end + if frame == WARMUP then + sleeps = 0 + Profile.setEnabled(true) + started, last = timer.getTime(), timer.getTime() + cpuStarted = os.clock() + end + love.graphics.clear(0.03, 0.04, 0.06) + local close = Profile.section("present (GPU wait / vsync)") + love.graphics.present() + close() + close = Profile.section("frame pacing: sleep") + deadline = Cap.pace(timer, love.window, true, deadline) + close() + frame = frame + 1 + if frame > WARMUP then + local now = timer.getTime() + intervals[#intervals + 1], last = now - last, now + Profile.frame() + end + if frame == WARMUP + WINDOW and finishPhase() then + return 0 + end + end +end diff --git a/tools/frame-pacing-check/world.lua b/tools/frame-pacing-check/world.lua new file mode 100644 index 000000000..6b4bd3ff5 --- /dev/null +++ b/tools/frame-pacing-check/world.lua @@ -0,0 +1,138 @@ +local Version = require("src.core.GameVersion") +Version.set("red") +local Renderer = require("src.render.Renderer") +local Performance = require("src.core.Performance") +local Cap = require("src.core.FrameCap") +local Profile = require("src.core.FrameProfile") +local Json = require("src.link.Json") +local scene = require("tests.world_draw.fixture").new(64, { quiet = true }) +local world, graphics = scene.world, love.graphics +scene.game.renderer = Renderer +Renderer:init() +world.map.renderer.drawBorderFill = function(_, cx, cy, w, h) + graphics.setColor(0.2, 0.4, 0.5, 1) + graphics.rectangle("fill", 0, 0, w, h) +end +world.map.renderer.draw = function() + graphics.setColor(0.3, 0.5, 0.4, 1) + for y = 0, 8 do + for x = 0, 9 do + graphics.rectangle("fill", x * 16, y * 16, 14, 14) + end + end +end +world.map.renderer.drawReflectionCover = function(_, cx, cy, camX, camY) + graphics.setColor(0.2, 0.4, 0.5, 0.3) + graphics.rectangle("fill", cx * 16 - camX, cy * 16 - camY, 16, 16) +end +for i, e in ipairs(world.entities) do + e.px, e.py = (i % 10) * 16, math.floor(i / 10) * 16 + e.cellX, e.cellY = math.floor(e.px / 16), math.floor(e.py / 16) + e.draw = function(actor, cx, cy) + graphics.setColor(0.8, 0.3, 0.2, 1) + graphics.rectangle("fill", actor.px - cx, actor.py - cy, 8, 12) + end + e.drawReflection = function(actor, cx, cy) + graphics.setColor(0.8, 0.3, 0.2, 0.5) + graphics.rectangle("fill", actor.px - cx, actor.py - cy + 16, 8, 12) + end +end +local phase, frame, deadline, started, cpuStarted = 1, 0, 0, 0, 0 +local WINDOW, WARMUP = 120, 120 +local drawTotal, drawMax, protectedCalls, heapStart = 0, 0, 0, 0 +local results = {} +local _, _, mode = love.window.getMode() +Cap.current = tonumber(mode.refreshrate) or 60 +if Cap.current <= 0 then + Cap.current = 60 +end +Cap.vsyncPacing = true +local function draw() + local close = Profile.section("draw: whole frame") + Renderer:beginFrame(true) + Renderer:beginWorldPass() + world:drawWorld() + Renderer:endWorldPass() + Renderer:endFrame() + close() + Profile.frame() +end +local function startPhase() + Profile.setEnabled(false) + Performance.worldDrawHoist = phase == 2 + collectgarbage("restart") + frame, drawTotal, drawMax = 0, 0, 0 + deadline = love.timer.getTime() +end +local function finishPhase() + results[phase] = { + enabled = Performance.worldDrawHoist, + frames = WINDOW, + seconds = love.timer.getTime() - started, + drawMeanMs = drawTotal / WINDOW * 1000, + drawMaxMs = drawMax * 1000, + processCpuMsPerFrame = (os.clock() - cpuStarted) / WINDOW * 1000, + protectedCalls = protectedCalls, + heapGrowthKiB = collectgarbage("count") - heapStart, + } + collectgarbage("restart") + if phase == 2 then + print( + "P4_NATIVE_RESULT " + .. Json.encode({ + os = love.system.getOS(), + arch = jit and jit.arch or "unknown", + renderer = graphics.getRendererInfo(), + refresh = mode.refreshrate, + vsync = love.window.getVSync(), + cap = Cap.current, + entities = #world.entities, + phases = results, + scope = "Native drawWorld/entity/reflection/FX loops with synthetic 64-actor rectangles; no ROM assets or mod scene; GC stopped during each measured window", + }) + ) + return true + end + phase = 2 + startPhase() +end +function love.run() + startPhase() + return function() + love.event.pump() + for name in love.event.poll() do + if name == "quit" then + return 1 + end + end + if frame == WARMUP then + local original = pcall + protectedCalls = 0 + _G.pcall = function(...) + protectedCalls = protectedCalls + 1 + return original(...) + end + draw() + _G.pcall = original + collectgarbage("collect") + collectgarbage("stop") + Profile.setEnabled(true) + heapStart = collectgarbage("count") + started, cpuStarted = love.timer.getTime(), os.clock() + end + local before = love.timer.getTime() + draw() + if frame >= WARMUP then + local elapsed = love.timer.getTime() - before + drawTotal, drawMax = drawTotal + elapsed, math.max(drawMax, elapsed) + end + local close = Profile.section("present (GPU wait / vsync)") + graphics.present() + close() + deadline = Cap.pace(love.timer, love.window, true, deadline) + frame = frame + 1 + if frame == WARMUP + WINDOW and finishPhase() then + return 0 + end + end +end