diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 82acc20..2b9222d 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -2309,11 +2309,10 @@ sampled coordinates. ablations only if the phase timings do not isolate the cost. Commit the baseline percentiles and exact canonical runtime coordinates here. A real unit-heavy story dungeon is deferred until it is practical to reach reproducibly. -- [ ] **P2 - fidelity-neutral CPU wins.** Remove normal-path diagnostic allocation and refine the ambiguous - presentation/affine counters, then avoid recomposition between discrete spritesheet cell changes and - benchmark translation, affine, and safe opaque raster fast paths. Land only changes with measured wins - and focused differential raster/presentation coverage. -- [ ] **P3 - GPU retained-renderer prototype.** Upload decoded/color-key texture variants once and mirror +- [x] **P2 - fidelity-neutral CPU wins.** P2a-P2f are implemented and measured. DEBUGMAP now meets its p95 + frame budget with near-zero steady allocation; the SC0000 exit capture remains 65.18/71.76 ms p50/p95 in + the severe full-screen/additive band, so P2 closes and triggers P3 rather than more CPU special cases. +- [ ] **P3 - GPU retained-renderer prototype (triggered).** Upload decoded/color-key texture variants once and mirror ordinary retained objects into GPU-native Godot drawing while preserving handle z-order, atlas regions, transforms, opacity/tint, and blend mode. Begin with dungeon sprites and ordinary translated textures; compare selected frames against the software oracle behind a backend switch. @@ -2421,19 +2420,19 @@ few layers but extreme full-screen/additive pixel work, while DEBUGMAP has hundr many sampled through the affine path, and discrete animation that currently requests a recomposition every Godot frame. Execute the following in order, retaining the software compositor as the pixel oracle. -1. **P2a - remove diagnostic-only allocation from normal rendering.** Construct per-object outcome strings +1. [x] **P2a - remove diagnostic-only allocation from normal rendering.** Construct per-object outcome strings and final `z` decision strings only when `--gfx-log` or `--timeline-log` actually supplied a decisions dictionary. Preserve byte-for-byte diagnostic output when enabled. Re-run DEBUGMAP and require a large reduction from its approximately 3.59 MB median allocation per recomposition without worsening raster time; if less than 70% disappears, use an allocation trace to identify the remaining owners before doing speculative collection tuning. -2. **P2b - measure the two ambiguous dirty/raster categories.** Extend the low-overhead counters only as +2. [x] **P2b - measure the two ambiguous dirty/raster categories.** Extend the low-overhead counters only as needed to distinguish VM-requested, continuous-channel, and discrete-cell presentation, and to split pure fractional translation from scale/rotation/general affine work. The current log proves that the broad categories matter but cannot tell whether DEBUGMAP's roughly 526 apparent affine layers are camera-induced fractional translations or genuine scale/rotation. Do not change sampling semantics on that assumption alone. -3. **P2c - reproduce the native shared dirty/cell cadence.** Native does not give each visible sprite a +3. [x] **P2c - reproduce the native shared dirty/cell cadence.** Native does not give each visible sprite a host-frame redraw timer. One shared current/previous millisecond frame-time pair feeds every channel; op `0x231` compares the source cell selected at those two samples and raises graphics dirty only when it changes. Retained mutations and genuinely continuous channels still redraw as required, while an @@ -2444,21 +2443,28 @@ Godot frame. Execute the following in order, retaining the software compositor a deadlines. Cover clone-before/after-first-sample, reconfiguration, differing periods, wraparound, and exact boundary cases. The DEBUGMAP acceptance metric is recompositions per second and total delivered frame time; the separately reported p50/p95 cost of frames that do recompose must remain visible. -4. **P2d - turn translation-like affine work into a verified fast path.** If P2b confirms pure unit-matrix - fractional translations, implement the nearest-neighbor-equivalent translated raster path and prove it +4. [x] **P2d - specialize the measured axis-aligned scale work.** P2b disproved the fractional-translation + hypothesis: the new DEBUGMAP capture reports zero fractional and zero general-affine layers, with every + non-integer layer classified as axis-aligned scale. Implement the nearest-neighbor-equivalent scale path and prove it byte-for-byte against the existing inverse-mapped oracle across positive/negative coordinates, half-pixel boundaries, clipping, opacity/tint, and every blend mode. This is the highest-potential DEBUGMAP per-compose CPU win because the existing translated loop is much cheaper than a general matrix inverse and transform per destination pixel. -5. **P2e - specialize the remaining hot pixel loops.** In descending evidence value, benchmark: - incremental inverse coordinates across an affine scanline; axis-aligned scale specialization; and - opaque/full-opacity/unmodulated translated row copies or alpha-run copies where source transparency - permits. Use randomized differential raster tests against the current implementation plus the SC0000 +5. [x] **P2e - specialize the remaining hot pixel loops.** The measured axis-aligned scale specialization is + complete. Benchmark opaque/full-opacity/unmodulated translated and scaled pixel loops next, using direct + copies for fully opaque source texels and the existing blend arithmetic at transparent edges. General + incremental inverse coordinates are low priority because the measured FIELD workload has no general- + affine layers. Use randomized differential raster tests against the current implementation plus the SC0000 and DEBUGMAP captures. Address the dynamic color-key full-frame clone separately because it explains an allocation/source-prep spike but neither primary raster plateau. Do not spend time on snapshot, clear, `Image.SetData`, texture upload, VM dispatch, or parallel rasterization while their measured contribution is small or a retained GPU renderer is the cleaner boundary. -6. **P2 exit gate and P3 trigger.** After the safe CPU batch, repeat equivalent SC0000 and DEBUGMAP runs. +6. [x] **P2f - remove the measured per-frame collection owners.** Replace `Transform2DMath.Build`'s heap + 4x4 matrices with an equivalent value-type affine-3D representation, and let the compositor reuse a + caller-owned, handle-sorted `RenderObject` snapshot buffer. Preserve the allocating snapshot overload for + callers which retain independent samples. Require exact matrix/sort differential coverage and direct + zero-allocation assertions after warm-up; confirm the real path with the allocation-phase CSV columns. +7. **P2 exit gate and P3 trigger.** After the safe CPU batch, repeat equivalent SC0000 and DEBUGMAP runs. Report both end-to-end cadence and recomposed-frame p50/p95/p99. If either workload's required recompositions remain above 16.67 ms p95, begin P3 rather than accumulating increasingly complex CPU special cases. The expected P3 prototype mirrors ordinary translated/atlas dungeon objects into Godot @@ -2469,6 +2475,217 @@ Godot frame. Execute the following in order, retaining the software compositor a Each landed optimization gets a before/after row in this document with capture path, canonical runtime coordinate, p50/p95/p99, allocation, recomposition rate, and raster-work counters. Revert or leave behind a -disabled experiment when it does not produce a repeatable real-path improvement. The immediate -implementation slice is P2a followed by P2b; those make the next user capture cheaper and sufficiently -specific to choose the correct P2c/P2d implementation without another story-progression dependency. +disabled experiment when it does not produce a repeatable real-path improvement. The next capture validates +P2a-P2c together and uses P2b's new categories to choose P2d without another story-progression dependency. + +### P2a-P2c implementation checkpoint (2026-07-22) + +Normal runs no longer construct per-object compositor outcome or final z-decision strings unless +`--gfx-log` or `--timeline-log` requested that evidence. This removes the known DEBUGMAP diagnostic-only +allocation source while preserving the diagnostic path. The performance CSV now records five independent +presentation reasons (host request, legacy screen transition, retained mutation, continuous channel, and +discrete source-cell change) and separates fractional translation, axis-aligned scale, and general affine +layers while retaining the original aggregate affine count. + +`GfxState` now publishes retained mutation generations once and samples one shared current/previous frame +time pair on every host tick. Continuously varying channels remain frame-driven. Visible op-`0x231` +spritesheets request composition only when their selected cells differ between the shared samples, and +clone-before-first-sample records seed together. VM object writes that previously escaped the model lock are +now applied through synchronized setters so publication cannot race ahead of the retained write. Opcode +`0xc8` sleep completion no longer requests presentation by itself; preceding graphics writes are covered by +the mutation generation instead. + +Focused coverage includes single-publication mutation behavior, exact cell boundaries and wrap, differing +object-local periods, reconfiguration, clones made before and after the first sample, continuous-channel +behavior, and a 51-object phase-locked family producing five—not 255—source-cell publication events per +second. The complete engine suite, Godot build/selftest, and a new comparable windowed DEBUGMAP capture are +the closeout gates. Until that capture is analyzed, the allocation and recomposition improvements are +implemented expectations rather than measured before/after results. + +### P2 capture 3 - DEBUGMAP after allocation/cadence changes (2026-07-22) + +`build/perf/run-20260722-121910-248.csv` is the first post-P2a-P2c windowed DEBUGMAP run. Its FIELD interval +contains 1,028 frames over 53.5 seconds and 1,023 recompositions (19.12/s), so discrete-cell scheduling did +not reduce end-to-end cadence in this workload. The reason columns explain why rather than invalidating the +shared-cell implementation: 1,014 FIELD rows have a continuous-channel reason. FIELD creates one visible +op-`0x232` color pulse at `FIELD@0x9691` (handle `0xc802`, period 2,000 ms), which legitimately changes the +composed output between spritesheet boundaries. The 50 ms hover callback also produced 712 host-publication +requests, including idle callbacks; callback completion is now no longer treated as dirty, while actual +retained and host-surface mutations continue to publish themselves. + +P2a was useful but missed its stated allocation gate. Recomposition allocation p50 fell from 3,593,400 to +3,172,128 bytes (-421,272, 11.7%); p95 fell from 3,629,816 to 3,178,544 bytes (-451,272, 12.4%). Raster p50 +was effectively unchanged (45.43 versus 45.52 ms), as expected for an allocation-only edit. The next logger +schema splits remaining allocation among snapshot, composition, source preparation, `Image.SetData`, and UI +so the next capture supplies bounded allocation attribution rather than prompting collection tuning. + +The new transform counters decisively redirect P2d. Recomposed FIELD frames average 447.7 integer layers and +506.3 axis-aligned-scale layers; fractional-translation and general-affine maxima are both zero. The busy +28-34 second interval averages about 2.35-2.39 million candidates and 54 ms main time, while the final +off-map idle interval falls to about 1.58 million and 26 ms. P2d therefore adds an axis-aligned inverse-map +path which caches the source column once per layer and computes the source row once per scanline, retaining +the existing center-sample/floor and blend arithmetic. Pooled lookup storage avoids per-layer garbage. +Five hundred deterministic randomized blit/fill cases compare byte-for-byte with the previous general +inverse-mapped oracle across positive/negative scales, fractional placement, clipping, opacity, tint, +multiplicative modulation, and alpha/additive/opaque modes. A repeat DEBUGMAP capture is still required to +measure the real-path raster win and the new allocation phases. All 345 engine tests pass and the Godot +project builds with zero warnings/errors. + +An environment audit after this capture found four Godot game/console pairs still alive from July 11 and +July 21. Each game process consumed about 0.41 CPU-seconds during a two-second sample (roughly 80% of one +logical core in aggregate). They predate both P1 and this capture, so workload attribution, allocation deltas, +and the transform-category correction remain actionable, but its absolute frame-time acceptance numbers are +provisional. The eight processes were terminated with user authorization before the following capture. + +### P2 capture 4 - DEBUGMAP after axis-aligned scale specialization (2026-07-22) + +`build/perf/run-20260722-130024-542.csv` is the clean post-P2d windowed comparison, captured after the stale +Godot processes above were closed. The stable FIELD workload still visits about 962 objects and draws about +961 layers: approximately 433 integer translations, 526 axis-aligned scales, zero fractional translations, +and zero general affine layers. Around 900 layers are classified opaque. Camera position changes clipped +candidate pixels, but it does not materially reduce retained traversal, layer count, source pixels, or the +continuous-channel presentation cadence. + +For equivalent 2.2-2.5 million candidate-pixel frames, recomposition p50/p95/p99 fell from +53.71/60.50/62.72 ms in capture 3 to 21.52/22.26/22.75 ms. Raster p50/p95 fell from 50.77/57.27 ms to +19.07/19.52 ms: a 62.4% median raster reduction with the same workload band. The final 1.5-1.9 million +candidate-pixel off-map band fell from 27.34/32.34 ms recomposition p50/p95 to 14.73/15.89 ms. Delivered +steady cadence consequently rises from about 19 recompositions/s to 44-48/s over the busy map and about +64/s off-map. P2d is a large, repeatable real-path win; busy-map p95 nevertheless remains above the +16.67 ms P2 exit target. + +The new allocation phases attribute the remaining steady busy-map p50/p95 almost exactly: total +3,178,544/3,178,544 bytes, retained snapshot 961,600/961,600, compositor 2,216,664/2,216,664, source +preparation zero, `Image.SetData` zero, and UI 88/88. Camera position barely changes that total because the +snapshot and compositor collections still cover the full retained set. Allocation is now a clear GC/long-run +stability target, but raster remains the direct busy-frame budget blocker. P2e therefore starts with the +full-opacity/unmodulated hot pixel loops used by the roughly 900 opaque layers; after that measured capture, +reduce the two identified per-frame collection owners rather than tuning the GC. + +### P2e unmodulated source-over specialization implemented (2026-07-22) + +The translated and axis-aligned-scale raster loops now detect full object opacity, zero effective tint, +non-multiplicative color, and non-additive blending once per layer. In that common mode, alpha-zero texels +remain skipped, alpha-255 texels become exact four-byte copies, and partially transparent edge texels retain +the previous integer source-over arithmetic. The general tinted, faded, multiplicative, and additive paths +are unchanged. This targets the approximately 900 opaque FIELD layers measured in capture 4 without +assuming that their color-keyed source rectangles contain no transparent pixels. + +Four hundred focused translated/scaled cases cover both opaque and alpha blend classifications with source +alpha values 0, 1, 254, and 255 against the retained pre-fast-path oracle. The complete engine suite passes +at 346 tests, the Godot build has zero warnings/errors, and threaded `SELFTEST OK`. **Next:** repeat the same +DEBUGMAP camera/idle/off-map workload. Retain P2e only if the busy 2.2-2.5 million candidate-pixel band shows +a repeatable win; then address the measured snapshot/compositor allocation owners before the P2 exit capture. + +### P2 capture 5 - DEBUGMAP after unmodulated source-over specialization (2026-07-22) + +`build/perf/run-20260722-132318-886.csv` is the comparable post-P2e windowed run. In the matched 2.2-2.5 +million candidate-pixel band, recomposition p50/p95/p99 fell from 21.52/22.26/22.75 ms to +15.35/16.18/19.26 ms, while raster p50/p95 fell from 19.07/19.52 ms to 12.66/13.23 ms. That is a 28.7% +median recomposition reduction and a 33.6% median raster reduction; steady busy-map delivery rises from +about 44-48 to 61-66 recompositions/s. The 1.5-1.9 million candidate off-map band falls from +14.73/15.89 ms recomposition p50/p95 to 9.45/11.21 ms. P2e is retained: busy-map p95 now fits the 16.67 ms +target, although allocation/GC outliers leave p99 above it. + +Allocation remains unchanged at 3,178,544 bytes p50/p95 in the busy band. Capture-wide gen-0/gen-1/gen-2 +counts are 184/52/34 over 3,111 FIELD recompositions, versus 147/49/34 over only 2,274 recompositions in +capture 4; normalized collection rates therefore do not regress, but short-lived garbage remains the clear +tail-latency and long-run-stability target. + +### P2f measured allocation owners removed (2026-07-22) + +The compositor allocation phase was dominated by `Transform2DMath.Build`: it created about eleven +`double[16]` matrices for every rendered object. It now composes the same row-vector operations through a +twelve-double value-type affine-3D matrix and projects to `Affine2D` only at the boundary. Five hundred +randomized scale/translation/anchor/one-shot/cyclic-rotation cases match every output double bit-for-bit +against the former array implementation, and 10,000 warmed builds allocate zero bytes. + +The retained snapshot phase no longer uses `Dictionary.OrderBy` or returns a newly grown list to the Godot +hot path. `GfxState` maintains a sorted handle index alongside its O(1) object dictionary, and fills a +compositor-owned reusable `List` under the existing lock. The returning overload remains for +callers needing an independent snapshot. Ten warmed 1,000-object samples allocate zero bytes and preserve +ascending handle order. The complete engine suite passes at 349 tests, the Godot build has zero +warnings/errors, and threaded `SELFTEST OK`. **Next:** repeat the comparable DEBUGMAP run and verify the +snapshot/compositor allocation columns collapse without changing the capture-5 frame-time distribution; +then repeat SC0000 for the P2 exit/P3 decision. + +### P2 capture 6 - DEBUGMAP after primary allocation removal (2026-07-22) + +`build/perf/run-20260722-133412-146.csv` confirms P2f's primary allocation changes on the real path. In the +matched busy band, total allocation p50/p95 falls from 3,178,544/3,178,544 to 262,160/262,160 bytes +(-91.8%). Retained snapshot allocation is exactly zero; compositor allocation falls from 2,216,664 to +261,880 bytes. Capture-wide gen-0/gen-1/gen-2 collections fall from 184/52/34 to 21/8/5 despite broadly +similar duration and 2,709 FIELD recompositions. Snapshot p50/p95 falls from 0.165/0.328 ms to +0.097/0.141 ms. + +Raster remains stable at 12.70/13.36 ms p50/p95 versus capture 5's 12.66/13.23 ms. End-to-end busy +recomposition improves slightly from 15.35/16.18/19.26 ms p50/p95/p99 to 15.10/15.87/16.25 ms; the much +tighter p99 is consistent with the measured GC reduction. Matched off-map recomposition improves from +9.45/11.21/13.20 ms to 9.04/10.02/11.35 ms. P2f therefore preserves the raster win while removing the +large short-lived collection owners. + +The stable 261,880-byte compositor remainder scales almost exactly with FIELD's approximately 961 retained +objects. The first hypothesis was the per-object `SnapshotSurfaceText` array copy, so the compositor was +changed to reuse a caller-owned `List` under the existing text lock; the returning overload +remains available to callers needing an independent snapshot. Capture 7 below disproves that attribution. + +### P2 capture 7 - residual allocation probe (2026-07-22) + +`build/perf/run-20260722-134322-530.csv` is the requested short steady DEBUGMAP idle probe. Across 1,208 +full FIELD recompositions, total allocation remains 259,952/261,728 bytes p50/p95, with snapshot allocation +zero and compositor allocation 259,672/261,448 bytes. The surface-text buffer therefore has no material +effect in this workload and is only a harmless general cleanup. Recomposition remains in the expected +camera-dependent range at 14.19/16.46 ms p50/p95 for an average 2.38 million candidate pixels. + +The exact remaining owner is `MovieSurfaceRegistry.TryResolveResource`: every ordinary still-texture +fallback constructed a LINQ `Where` plus descending sort pipeline to look for a live movie frame, including +when the movie registry was empty. This occurred once per retained object and explains the stable roughly +270 bytes/object remainder. The registry now scans its live bindings directly while retaining the rule that +the newest published playback of a resource wins. A focused test covers matching/missing lookups and newest- +playback selection; 10,000 pairs allocate zero bytes after warm-up. All 350 engine tests, the zero-warning +Godot build, and threaded `SELFTEST OK` pass. **Next:** one final short DEBUGMAP idle probe confirms the +compositor remainder is gone, then SC0000 through the pre-CHAPTER burst supplies the P2 exit/P3 decision. + +### P2 capture 8 - DEBUGMAP allocation closeout (2026-07-22) + +`build/perf/run-20260722-140820-530.csv` confirms the movie-registry correction. Across 820 full steady +FIELD recompositions, total allocation is 4,416 bytes p50/p95/p99 and compositor allocation is 4,136 bytes; +snapshot, source preparation, and `Image.SetData` remain zero, while UI accounts for 88 bytes. This is a +99.86% reduction from capture 4's 3,178,544-byte steady total and a 98.3% reduction from capture 7's +259,952-byte residual. FIELD itself performs zero gen-0, gen-1, or gen-2 collections in this probe; every +capture-wide collection occurred during boot/map setup. + +One FIELD row allocates 1,415,744 bytes during composition at `FIELD.BIN@0x1029` as a one-time reusable- +capacity/cache warm-up. It triggers no collection and completes in 14.78 ms, so it is neither a steady owner +nor a visible stall. At an average 2.32 million candidate pixels, recomposition p50/p95/p99 is +13.90/15.86/18.89 ms and raster p50/p95 is 11.32/13.22 ms, consistent with the post-P2e/P2f distribution. +DEBUGMAP therefore closes with busy p95 inside 16.67 ms and effectively allocation-free steady rendering. +**Next:** repeat SC0000 through the original pre-CHAPTER burst and compare against capture 1's exact +`SC0000@0x123de` plateau before deciding whether P2 exits or the full-screen/additive case triggers P3. + +### P2 exit capture 9 - SC0000 pre-CHAPTER and post-movie text (2026-07-22) + +`build/perf/run-20260722-141200-995.csv` repeats capture 1 through the CHAPTER movie and the following text. +Across every recomposition parked at `SC0000.BIN@0x123de`, recomposition p50/p95/p99 improves from +21.85/57.40/82.70 ms to 15.39/31.71/66.38 ms. Movie sampling itself remains small: twelve active rows have +`movie_ms` p50/p95/max 0.20/0.99/1.06 ms, so decoding/presentation is not the reported burst bottleneck. + +The acceptance decision uses equivalent severe rows rather than the mixed coordinate aggregate. For frames +with at least 3.5 million candidate pixels, recomposition p50/p95/p99 falls from 76.43/88.42/90.11 ms to +65.15/71.74/72.03 ms; raster p50/p95 remains 64.11/71.15 ms. These 52 rows average 4.29 million candidate +pixels, 10.7 layers, 4.6 additive layers, 8.3 viewport-covering layers, two general-affine layers, and less +than 0.9 KB managed allocation. The worst comparable frame is 72.21 ms. P2's CPU fast paths therefore save +roughly 15-19% in this burst but leave it around 14-15 delivered frames/s, more than four times the +16.67 ms budget. No allocation or scheduler optimization can close that gap. + +A separate later interval still clones about 1.92 MB per recomposition while applying a color key to a +dynamic frame. It is an independent GC issue already anticipated by the action plan, but it does not occur +in the severe additive plateau and cannot change the exit decision. Treat it as part of the GPU texture/ +shader ownership work rather than delaying P3 for another software-raster special case. + +**P2 exit decision:** close the fidelity-neutral CPU batch and begin P3. The prototype mirrors ordinary +retained texture/fill objects into GPU-native Godot drawing behind a backend switch, preserves handle z-order, +atlas source rectangles, colorkey, tint/opacity, additive blending, and the exact affine transform, and keeps +the software compositor as the pixel-parity oracle/fallback. First acceptance is the same +`SC0000@0x123de` severe band below 16.67 ms p95 without regressing DEBUGMAP presentation or movie/text +composition; only then make GPU rendering the default. diff --git a/docs/tools-reference.md b/docs/tools-reference.md index 5776b9d..5f5819f 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -217,10 +217,19 @@ time, pulse/movie/UI time, and a compositor breakdown: recomposition, clear, ret resolution/decode, color-key/source preparation, raster, `Image.SetData`, and `ImageTexture.Update`. Workload columns report transition state, retained object visits, time-varying object visits, drawn/fill/transition/skipped layers, integer/ affine/singular raster paths, dynamic/opaque/alpha/additive layers, source pixels, clipped affine bounding- -box candidate pixels, full-screen layers, main-thread managed allocations, and GC collection deltas. The +box candidate pixels, full-screen layers, main-thread managed allocations, and GC collection deltas. +Presentation-reason columns distinguish host publication requests, legacy screen transitions, retained VM +mutations, continuously sampled channels, and discrete spritesheet cell changes. Affine work is additionally +split into fractional translation, axis-aligned scale, and general affine layers while retaining the aggregate +`affine_layers` column for comparison with the first two captures. The +allocation total is split into recomposition, retained snapshot, compositor, source-preparation, +`Image.SetData`, and Godot UI phases so a normal windowed capture can attribute remaining managed garbage +without enabling a high-volume trace. The writer replaces the target, buffers 120 rows between flushes, and prints its frame/recomposition counts on normal shutdown. Use a windowed Release-equivalent run at speed 1 for performance evidence; headless runs -validate the schema only. Do not combine baseline captures with `--shot-sequence`, `--gfx-log`, or +validate the schema only. Before a baseline, verify that no older Godot game processes remain alive; an +apparently closed window can otherwise leave a renderer consuming CPU and contaminate later runs. Do not +combine baseline captures with `--shot-sequence`, `--gfx-log`, or `--timeline-log`, whose diagnostics add substantial or differently shaped work. Example from `age-reimpl/`: `godot --path godot -- --scene SC0000 --boot --perf-log ../build/perf/sc0000.csv`. diff --git a/engine/Age.Engine.Tests/Age.Engine.Tests.csproj b/engine/Age.Engine.Tests/Age.Engine.Tests.csproj index 8450381..ec8c49e 100644 --- a/engine/Age.Engine.Tests/Age.Engine.Tests.csproj +++ b/engine/Age.Engine.Tests/Age.Engine.Tests.csproj @@ -29,6 +29,7 @@ + diff --git a/engine/Age.Engine.Tests/GfxAnimationTests.cs b/engine/Age.Engine.Tests/GfxAnimationTests.cs index 0ee38a5..f077710 100644 --- a/engine/Age.Engine.Tests/GfxAnimationTests.cs +++ b/engine/Age.Engine.Tests/GfxAnimationTests.cs @@ -25,6 +25,25 @@ public class GfxAnimationTests Assert.True(o.TranslationEnabled); } + [Fact] + public void CallerOwnedVisibleSnapshot_ReusesStorageAndPreservesHandleOrder() + { + var g = new GfxState(); + for (int i = 999; i >= 0; i--) + g.BindDraw(0x1000 + i, 1, 0, 0, 1, 1, i, 0); + var snapshot = new List(); + g.SnapshotVisibleObjects(0, snapshot); // Grow and warm the buffer outside the measured interval. + + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 10; i++) g.SnapshotVisibleObjects(i, snapshot); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(0, allocated); + Assert.Equal(1000, snapshot.Count); + Assert.Equal(0x1000, snapshot[0].Handle); + Assert.Equal(0x1000 + 999, snapshot[^1].Handle); + } + [Fact] public void CurrentScaleSetter_ExpandsGlowAroundItsAnchor() { @@ -94,18 +113,22 @@ public class GfxAnimationTests var unchanged = VisibleObject(); Assert.False(unchanged.HasActiveVisualPresentation(1000)); + Assert.False(unchanged.SnapshotVisibleObjects(1000).Single().TimeVarying); var spritesheet = VisibleObject(); spritesheet.SetSrcRect(7, 4, 1, 0, 800); Assert.True(spritesheet.HasActiveVisualPresentation(1000)); + Assert.True(spritesheet.SnapshotVisibleObjects(1000).Single().TimeVarying); var color = VisibleObject(); color.SetColorAnim(7, 1000, GfxState.PackColor(0x80, 0xff0000)); Assert.True(color.HasActiveVisualPresentation(1000)); + Assert.True(color.SnapshotVisibleObjects(1000).Single().TimeVarying); var rotation = VisibleObject(); rotation.SetRotationCycle(7, 1000, (0, 0, 1)); Assert.True(rotation.HasActiveVisualPresentation(1000)); + Assert.True(rotation.SnapshotVisibleObjects(1000).Single().TimeVarying); } [Fact] @@ -129,6 +152,108 @@ public class GfxAnimationTests Assert.Equal((0, 0, 200, 200), (wrapped.SrcX, wrapped.SrcY, wrapped.W, wrapped.H)); } + [Fact] + public void PresentationReasons_PublishMutationOnce_AndSpritesheetOnlyAtCellBoundaries() + { + var g = new GfxState(); + g.SetSurface(4, 0x37, -1); + g.BindDraw(7, 4, 0, 0, 16, 16, 0, 0); + g.SetSrcRect(7, frameCount: 4, columns: 2, cell: 0, period: 200); + + Assert.Equal(GfxPresentationReason.RetainedMutation, g.ConsumePresentationReasons(1000)); + Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1199)); + Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1200)); + Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1399)); + Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1400)); + Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1599)); + Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1600)); + Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1799)); + Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1800)); + } + + [Fact] + public void PresentationReasons_CloneBeforeFirstSampleSharesPhase_AndCloneAfterKeepsIt() + { + var g = new GfxState(); + g.SetSurface(4, 0x37, -1); + g.BindDraw(10, 4, 0, 0, 16, 16, 0, 0); + g.SetSrcRect(10, frameCount: 4, columns: 2, cell: 0, period: 200); + Assert.True(g.CloneObject(10, 11)); + + Assert.Equal(GfxPresentationReason.RetainedMutation, g.ConsumePresentationReasons(1000)); + Assert.Equal(1000, g.TryGet(10)!.SrcStart); + Assert.Equal(1000, g.TryGet(11)!.SrcStart); + Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1200)); + Assert.Equal(g.SnapshotVisibleObjects(1200).Single(x => x.Handle == 10).SrcX, + g.SnapshotVisibleObjects(1200).Single(x => x.Handle == 11).SrcX); + + Assert.True(g.CloneObject(10, 12)); + Assert.Equal(GfxPresentationReason.RetainedMutation, g.ConsumePresentationReasons(1250)); + Assert.Equal(1000, g.TryGet(12)!.SrcStart); + Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1400)); + var cells = g.SnapshotVisibleObjects(1400).Select(x => x.SrcX).Distinct().ToArray(); + Assert.Single(cells); + } + + [Fact] + public void PresentationReasons_UsesObjectLocalPeriods_AndReconfigurationRestartsAtSharedSample() + { + var g = new GfxState(); + g.SetSurface(4, 0x37, -1); + g.BindDraw(10, 4, 0, 0, 16, 16, 0, 0); + g.BindDraw(11, 4, 0, 0, 16, 16, 0, 0); + g.SetSrcRect(10, frameCount: 4, columns: 2, cell: 0, period: 100); + g.SetSrcRect(11, frameCount: 4, columns: 2, cell: 0, period: 250); + g.ConsumePresentationReasons(1000); + + Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1099)); + Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1100)); + Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1199)); + Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1200)); + Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1249)); + Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1250)); + + g.SetSrcRect(10, frameCount: 4, columns: 2, cell: 0, period: 400); + Assert.Equal(GfxPresentationReason.RetainedMutation, g.ConsumePresentationReasons(1250)); + Assert.Equal(1250, g.TryGet(10)!.SrcStart); + Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1499)); + // The second object's 250 ms boundary and the reconfigured object's 400 ms boundary coincide here. + Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1650)); + } + + [Fact] + public void PresentationReasons_ContinuousChannelRemainsFrameDriven() + { + var g = new GfxState(); + g.SetSurface(1, 5, -1); + g.BindDraw(7, 1, 0, 0, 64, 64, 0, 0); + g.SetColorAnim(7, 1000, GfxState.PackColor(0x80, 0xff0000)); + + var first = g.ConsumePresentationReasons(1000); + Assert.True((first & GfxPresentationReason.RetainedMutation) != 0); + Assert.True((first & GfxPresentationReason.ContinuousChannel) != 0); + Assert.Equal(GfxPresentationReason.ContinuousChannel, g.ConsumePresentationReasons(1016)); + } + + [Fact] + public void PresentationReasons_PhaseLockedUnitFamilyPublishesFiveCellChangesPerSecond() + { + var g = new GfxState(); + g.SetSurface(4, 0x37, -1); + g.BindDraw(100, 4, 0, 0, 16, 16, 0, 0); + g.SetSrcRect(100, frameCount: 4, columns: 2, cell: 0, period: 200); + for (long handle = 101; handle < 151; handle++) Assert.True(g.CloneObject(100, handle)); + g.ConsumePresentationReasons(1000); + + int cellChanges = 0; + for (long now = 1001; now <= 2000; now++) + if ((g.ConsumePresentationReasons(now) & GfxPresentationReason.DiscreteSourceCell) != 0) + cellChanges++; + + Assert.Equal(5, cellChanges); + Assert.Single(g.Objects.Select(pair => g.TryGet(pair.Handle)!.SrcStart).Distinct()); + } + [Fact] public void OneShotRotation_SharesMatrixClockAndMatchesNativeSample() { diff --git a/engine/Age.Engine.Tests/MovieSurfaceRegistryTests.cs b/engine/Age.Engine.Tests/MovieSurfaceRegistryTests.cs index 53e370c..f03fac6 100644 --- a/engine/Age.Engine.Tests/MovieSurfaceRegistryTests.cs +++ b/engine/Age.Engine.Tests/MovieSurfaceRegistryTests.cs @@ -60,6 +60,33 @@ public class MovieSurfaceRegistryTests Assert.False(registry.IsKnownMovieResource(0x2af5)); } + [Fact] + public void ResourceLookup_SelectsNewestPublishedPlaybackWithoutAllocating() + { + var registry = new MovieSurfaceRegistry(); + MovieSurfaceBinding older = registry.Begin(7, 0x2b42, out _); + MovieSurfaceBinding unrelated = registry.Begin(8, 0x2bad, out _); + MovieSurfaceBinding newer = registry.Begin(9, 0x2b42, out _); + Assert.True(registry.PublishFrame(older.PlaybackId, Frame(1), "OLDER.AGF", 1)); + Assert.True(registry.PublishFrame(unrelated.PlaybackId, Frame(2), "OTHER.AGF", 2)); + Assert.True(registry.PublishFrame(newer.PlaybackId, Frame(3), "NEWER.AGF", 3)); + Assert.True(registry.TryResolveResource(0x2b42, out MovieSurfaceFrame? warm)); + Assert.Equal("NEWER.AGF", warm!.Name); + Assert.False(registry.TryResolveResource(0x2af5, out _)); + + long before = GC.GetAllocatedBytesForCurrentThread(); + bool resultsCorrect = true; + for (int i = 0; i < 10_000; i++) + { + resultsCorrect &= registry.TryResolveResource(0x2b42, out _); + resultsCorrect &= !registry.TryResolveResource(0x2af5, out _); + } + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.True(resultsCorrect); + Assert.Equal(0, allocated); + } + private static RgbaImage Frame(byte value) => new(1, 1, new[] { value, value, value, (byte)255 }); } diff --git a/engine/Age.Engine.Tests/PerformanceFrameLogTests.cs b/engine/Age.Engine.Tests/PerformanceFrameLogTests.cs new file mode 100644 index 0000000..f141a97 --- /dev/null +++ b/engine/Age.Engine.Tests/PerformanceFrameLogTests.cs @@ -0,0 +1,97 @@ +using Age.Engine.Model; + +public class PerformanceFrameLogTests +{ + [Fact] + public void WritesStableCsvSchemaAndFrameWorkload() + { + string path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), + $"age-perf-{Guid.NewGuid():N}.csv"); + try + { + using (var log = new PerformanceFrameLog(path)) + { + log.BeginFrame(17, 1234, 1.0 / 60.0, "SC0000.BIN", 0x2a91, 0x20c); + log.BeginRecomposite(false); + log.RecordPresentationReasons(1 | 4 | 16); + log.RecordRecomposeAllocation(4096); + log.RecordSnapshotAllocation(512); + log.RecordCompositeAllocation(2048); + log.RecordSourcePrepAllocation(128); + log.RecordSetDataAllocation(1024); + log.RecordUiAllocation(64); + log.RecordPresentationCoordinate("SC0000.BIN", 0x2aaa, 0x21c); + log.RecordObject(timeVarying: true); + log.RecordRaster(800, 600, new Affine2D(1, 0, 0, 1, 0, 0), + 800, 600, dynamic: false, BlendKind.Alpha, ticks: 10); + log.RecordFillLayer(); + log.RecordSkippedLayer(); + log.EndRecomposite(); + log.EndFrame(); + Assert.Equal(1, log.FrameCount); + Assert.Equal(1, log.RecompositeCount); + } + + string[] lines = File.ReadAllLines(path); + Assert.Equal(2, lines.Length); + string[] header = lines[0].Split(','); + string[] row = lines[1].Split(','); + Assert.Equal(header.Length, row.Length); + Assert.Equal("frame", header[0]); + Assert.Equal("17", row[0]); + Assert.Equal("1", row[Array.IndexOf(header, "recomposited")]); + Assert.Equal("1", row[Array.IndexOf(header, "present_host_request")]); + Assert.Equal("1", row[Array.IndexOf(header, "present_retained_mutation")]); + Assert.Equal("1", row[Array.IndexOf(header, "present_discrete_cell")]); + Assert.Equal("0", row[Array.IndexOf(header, "present_continuous_channel")]); + Assert.Equal("4096", row[Array.IndexOf(header, "recompose_allocated_bytes")]); + Assert.Equal("512", row[Array.IndexOf(header, "snapshot_allocated_bytes")]); + Assert.Equal("2048", row[Array.IndexOf(header, "composite_allocated_bytes")]); + Assert.Equal("128", row[Array.IndexOf(header, "source_prep_allocated_bytes")]); + Assert.Equal("1024", row[Array.IndexOf(header, "set_data_allocated_bytes")]); + Assert.Equal("64", row[Array.IndexOf(header, "ui_allocated_bytes")]); + Assert.Equal("1", row[Array.IndexOf(header, "time_varying_objects")]); + Assert.Equal("480000", row[Array.IndexOf(header, "candidate_pixels")]); + Assert.Equal("SC0000.BIN", row[Array.IndexOf(header, "script")].Trim('"')); + Assert.Equal("10897", row[Array.IndexOf(header, "offset")]); + Assert.Equal("524", row[Array.IndexOf(header, "opcode")]); + Assert.Equal("10922", row[Array.IndexOf(header, "present_offset")]); + Assert.Equal("540", row[Array.IndexOf(header, "present_opcode")]); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Theory] + [InlineData(0, 0, 10, 10, 100)] + [InlineData(-5, -5, 10, 10, 25)] + [InlineData(95, 95, 10, 10, 25)] + [InlineData(200, 200, 10, 10, 0)] + public void CandidatePixelsClipsIntegerTranslations(int x, int y, int width, int height, long expected) + { + var transform = new Affine2D(1, 0, 0, 1, x, y); + Assert.True(PerformanceFrameLog.IsIntegerTranslation(transform)); + Assert.Equal(expected, + PerformanceFrameLog.EstimateCandidatePixels(transform, width, height, 100, 100)); + } + + [Fact] + public void CandidatePixelsUsesAffineBoundingBoxAndDetectsNonIntegerPath() + { + var scaled = new Affine2D(2, 0, 0, 3, 1.5, 2.5); + Assert.False(PerformanceFrameLog.IsIntegerTranslation(scaled)); + Assert.Equal(651, + PerformanceFrameLog.EstimateCandidatePixels(scaled, 10, 10, 100, 100)); + } + + [Fact] + public void RasterClassificationSeparatesTranslationScaleAndGeneralAffine() + { + Assert.True(PerformanceFrameLog.IsTranslation(new Affine2D(1, 0, 0, 1, 0.25, -0.5))); + Assert.False(PerformanceFrameLog.IsIntegerTranslation(new Affine2D(1, 0, 0, 1, 0.25, -0.5))); + Assert.True(PerformanceFrameLog.IsAxisAligned(new Affine2D(2, 0, 0, 3, 0, 0))); + Assert.False(PerformanceFrameLog.IsAxisAligned(new Affine2D(1, 0.25, 0, 1, 0, 0))); + } +} diff --git a/engine/Age.Engine.Tests/SoftwareAffineRasterizerTests.cs b/engine/Age.Engine.Tests/SoftwareAffineRasterizerTests.cs index 3c5b1f1..75e4e1d 100644 --- a/engine/Age.Engine.Tests/SoftwareAffineRasterizerTests.cs +++ b/engine/Age.Engine.Tests/SoftwareAffineRasterizerTests.cs @@ -115,10 +115,110 @@ public class SoftwareAffineRasterizerTests Assert.Equal(new byte[] { 43, 57, 109, 255 }, background); } + [Fact] + public void BlitRgba_AxisAlignedScaleFastPath_MatchesGeneralAffineAcrossRandomizedCases() + { + var random = new Random(0x231); + double[] scales = { -2.0, -1.25, -0.5, 0.25, 0.5, 0.75, 1.25, 2.0, 3.0 }; + double[] translations = { -5.5, -2.25, -0.5, 0.0, 0.5, 1.25, 4.5, 8.0 }; + BlendKind[] blends = { BlendKind.Opaque, BlendKind.Alpha, BlendKind.Additive }; + for (int iteration = 0; iteration < 250; iteration++) + { + int srcW = random.Next(2, 12), srcH = random.Next(2, 10); + byte[] src = new byte[srcW * srcH * 4]; + random.NextBytes(src); + int srcX = random.Next(0, srcW), srcY = random.Next(0, srcH); + int width = random.Next(1, srcW - srcX + 1), height = random.Next(1, srcH - srcY + 1); + int dstW = random.Next(3, 15), dstH = random.Next(3, 13); + byte[] expected = new byte[dstW * dstH * 4]; + random.NextBytes(expected); + byte[] actual = (byte[])expected.Clone(); + var transform = new Affine2D( + scales[random.Next(scales.Length)], 0, 0, scales[random.Next(scales.Length)], + translations[random.Next(translations.Length)], translations[random.Next(translations.Length)]); + long tint = random.Next(0x1000000); + float tintStrength = random.NextSingle(); + float opacity = random.NextSingle(); + bool multiplyTint = random.Next(2) != 0; + BlendKind blend = blends[random.Next(blends.Length)]; + + ReferenceBlit(expected, dstW, dstH, src, srcW, srcX, srcY, width, height, + transform, tint, tintStrength, opacity, multiplyTint, blend); + SoftwareAffineRasterizer.BlitRgba(actual, dstW, dstH, src, srcW, srcH, + srcX, srcY, width, height, transform, + tint, tintStrength, opacity, multiplyTint, blend); + + Assert.Equal(expected, actual); + } + } + + [Fact] + public void FillRgba_AxisAlignedScaleFastPath_MatchesGeneralAffineAcrossRandomizedCases() + { + var random = new Random(0x22d); + double[] scales = { -2.0, -1.0, -0.5, 0.25, 0.5, 0.75, 1.5, 2.0, 3.0 }; + double[] translations = { -5.5, -1.25, -0.5, 0.0, 0.5, 2.25, 7.0 }; + for (int iteration = 0; iteration < 250; iteration++) + { + int width = random.Next(1, 10), height = random.Next(1, 9); + int dstW = random.Next(3, 15), dstH = random.Next(3, 13); + byte[] expected = new byte[dstW * dstH * 4]; + random.NextBytes(expected); + byte[] actual = (byte[])expected.Clone(); + var transform = new Affine2D( + scales[random.Next(scales.Length)], 0, 0, scales[random.Next(scales.Length)], + translations[random.Next(translations.Length)], translations[random.Next(translations.Length)]); + long color = random.Next(0x1000000); + float opacity = random.NextSingle(); + + ReferenceFill(expected, dstW, dstH, width, height, transform, color, opacity); + SoftwareAffineRasterizer.FillRgba(actual, dstW, dstH, width, height, + transform, color, opacity); + + Assert.Equal(expected, actual); + } + } + + [Fact] + public void BlitRgba_UnmodulatedSourceOverFastPaths_MatchGeneralAffineAcrossAlphaEdges() + { + var random = new Random(0x2e0); + Affine2D[] transforms = + { + new(1, 0, 0, 1, -2, 1), + new(1, 0, 0, 1, 3, -1), + new(0.5, 0, 0, 1.5, -1.25, 0.5), + new(-1.25, 0, 0, 0.75, 8.5, -0.5), + }; + foreach (var transform in transforms) + foreach (BlendKind blend in new[] { BlendKind.Opaque, BlendKind.Alpha }) + for (int iteration = 0; iteration < 50; iteration++) + { + int srcW = random.Next(3, 12), srcH = random.Next(3, 10); + byte[] src = new byte[srcW * srcH * 4]; + random.NextBytes(src); + for (int pixel = 0; pixel < srcW * srcH; pixel++) + src[pixel * 4 + 3] = (byte)(pixel % 4 switch { 0 => 0, 1 => 1, 2 => 254, _ => 255 }); + int dstW = random.Next(5, 16), dstH = random.Next(5, 14); + byte[] expected = new byte[dstW * dstH * 4]; + random.NextBytes(expected); + byte[] actual = (byte[])expected.Clone(); + + ReferenceBlit(expected, dstW, dstH, src, srcW, 1, 1, srcW - 1, srcH - 1, + transform, 0x6a4c2e, 0, 1, false, blend); + SoftwareAffineRasterizer.BlitRgba(actual, dstW, dstH, src, srcW, srcH, + 1, 1, srcW - 1, srcH - 1, transform, + 0x6a4c2e, 0, 1, false, blend); + + Assert.Equal(expected, actual); + } + } + // Pre-fast-path affine algorithm retained here as an independent differential oracle. private static void ReferenceBlit(byte[] dst, int dstW, int dstH, byte[] src, int srcW, int srcX, int srcY, int width, int height, Affine2D transform, - long tint, float tintStrength, float opacity, bool multiplyTint) + long tint, float tintStrength, float opacity, bool multiplyTint, + BlendKind blend = BlendKind.Alpha) { if (!transform.TryInverse(out var inv)) return; ReferenceBounds(transform, width, height, dstW, dstH, out int x0, out int y0, out int x1, out int y1); @@ -136,7 +236,7 @@ public class SoftwareAffineRasterizerTests int sr = multiplyTint ? src[si] * tr / 255 : (src[si] * (255 - istr) + tr * istr) / 255; int sg = multiplyTint ? src[si + 1] * tg / 255 : (src[si + 1] * (255 - istr) + tg * istr) / 255; int sb = multiplyTint ? src[si + 2] * tb / 255 : (src[si + 2] * (255 - istr) + tb * istr) / 255; - ReferenceBlend(dst, di, sr, sg, sb, sa); + ReferenceBlend(dst, di, sr, sg, sb, sa, blend); } } @@ -165,8 +265,17 @@ public class SoftwareAffineRasterizerTests y1 = System.Math.Min(dh, (int)System.Math.Ceiling(System.Math.Max(System.Math.Max(a.Y, b.Y), System.Math.Max(c.Y, d.Y)))); } - private static void ReferenceBlend(byte[] dst, int i, int r, int g, int b, int a) + private static void ReferenceBlend(byte[] dst, int i, int r, int g, int b, int a, + BlendKind blend = BlendKind.Alpha) { + if (blend == BlendKind.Additive) + { + dst[i] = (byte)System.Math.Min(255, dst[i] + r * a / 255); + dst[i + 1] = (byte)System.Math.Min(255, dst[i + 1] + g * a / 255); + dst[i + 2] = (byte)System.Math.Min(255, dst[i + 2] + b * a / 255); + dst[i + 3] = (byte)System.Math.Min(255, dst[i + 3] + a); + return; + } dst[i] = (byte)((r * a + dst[i] * (255 - a)) / 255); dst[i + 1] = (byte)((g * a + dst[i + 1] * (255 - a)) / 255); dst[i + 2] = (byte)((b * a + dst[i + 2] * (255 - a)) / 255); diff --git a/engine/Age.Engine.Tests/Transform2DMathTests.cs b/engine/Age.Engine.Tests/Transform2DMathTests.cs new file mode 100644 index 0000000..b490d2b --- /dev/null +++ b/engine/Age.Engine.Tests/Transform2DMathTests.cs @@ -0,0 +1,95 @@ +using Age.Engine.Model; +using Xunit; + +public class Transform2DMathTests +{ + [Fact] + public void ValueMatrixBuild_MatchesFormerArrayCompositionExactly() + { + var random = new Random(0x240); + for (int i = 0; i < 500; i++) + { + var transform = new TransformState( + Next(random, -3, 3), Next(random, -3, 3), Next(random, -3, 3), + Next(random, -500, 500), Next(random, -500, 500), Next(random, -500, 500), + Next(random, -1000, 1000), Next(random, -1000, 1000), Next(random, -1000, 1000), + Next(random, -1, 1), Next(random, -1, 1), Next(random, -1, 1), + Next(random, -360, 360)); + var cycle = new RotationCycleState( + random.Next(2) != 0, random.Next(1, 20000), + Next(random, -1, 1), Next(random, -1, 1), Next(random, -1, 1), + Next(random, -360, 360)); + + var expected = ArrayBuild(transform, cycle); + var actual = Transform2DMath.Build(transform, cycle); + + Assert.Equal(BitConverter.DoubleToInt64Bits(expected.XX), BitConverter.DoubleToInt64Bits(actual.XX)); + Assert.Equal(BitConverter.DoubleToInt64Bits(expected.XY), BitConverter.DoubleToInt64Bits(actual.XY)); + Assert.Equal(BitConverter.DoubleToInt64Bits(expected.YX), BitConverter.DoubleToInt64Bits(actual.YX)); + Assert.Equal(BitConverter.DoubleToInt64Bits(expected.YY), BitConverter.DoubleToInt64Bits(actual.YY)); + Assert.Equal(BitConverter.DoubleToInt64Bits(expected.TX), BitConverter.DoubleToInt64Bits(actual.TX)); + Assert.Equal(BitConverter.DoubleToInt64Bits(expected.TY), BitConverter.DoubleToInt64Bits(actual.TY)); + } + } + + [Fact] + public void ValueMatrixBuild_DoesNotAllocatePerObject() + { + var transform = new TransformState(1.25, 0.75, 1.1, 42, -17, 5, 400, 300, 9, 0, 0, 1, 37); + var cycle = new RotationCycleState(true, 9000, 0, 0, -1, 123); + _ = Transform2DMath.Build(transform, cycle); // JIT/warm-up outside the measured interval. + + long before = GC.GetAllocatedBytesForCurrentThread(); + Affine2D result = default; + for (int i = 0; i < 10_000; i++) result = Transform2DMath.Build(transform, cycle); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + GC.KeepAlive(result); + + Assert.Equal(0, allocated); + } + + private static double Next(Random random, double minimum, double maximum) + => minimum + random.NextDouble() * (maximum - minimum); + + // The former heap-array implementation, retained only as a differential oracle. + private static Affine2D ArrayBuild(TransformState t, RotationCycleState cycle) + { + double[] m = Identity(); + m = Mul(m, Translation(-t.AnchorX, -t.AnchorY, -t.AnchorZ)); + m = Mul(m, Scale(t.ScaleX, t.ScaleY, t.ScaleZ)); + m = Mul(m, AxisAngle(t.RotationAxisX, t.RotationAxisY, t.RotationAxisZ, t.RotationAngleDegrees)); + m = Mul(m, Translation(t.TranslateX, t.TranslateY, t.TranslateZ)); + if (cycle.Enabled) m = Mul(m, AxisAngle(cycle.AxisX, cycle.AxisY, cycle.AxisZ, cycle.AngleDegrees)); + m = Mul(m, Translation(t.AnchorX, t.AnchorY, t.AnchorZ)); + return new(m[0], m[1], m[4], m[5], m[12], m[13]); + } + + private static double[] Identity() => new double[] { 1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1 }; + private static double[] Scale(double x, double y, double z) + => new double[] { x,0,0,0, 0,y,0,0, 0,0,z,0, 0,0,0,1 }; + private static double[] Translation(double x, double y, double z) + => new double[] { 1,0,0,0, 0,1,0,0, 0,0,1,0, x,y,z,1 }; + + private static double[] AxisAngle(double x, double y, double z, double degrees) + { + double len = Math.Sqrt(x*x + y*y + z*z); + if (len < 1e-12 || Math.Abs(degrees) < 1e-12) return Identity(); + x /= len; y /= len; z /= len; + double r = degrees * Math.PI / 180.0, c = Math.Cos(r), s = Math.Sin(r), q = 1-c; + return new double[] { + x*x*q+c, x*y*q+z*s, x*z*q-y*s, 0, + x*y*q-z*s, y*y*q+c, y*z*q+x*s, 0, + x*z*q+y*s, y*z*q-x*s, z*z*q+c, 0, + 0,0,0,1 + }; + } + + private static double[] Mul(double[] a, double[] b) + { + var output = new double[16]; + for (int row = 0; row < 4; row++) + for (int column = 0; column < 4; column++) + for (int k = 0; k < 4; k++) output[row * 4 + column] += a[row * 4 + k] * b[k * 4 + column]; + return output; + } +} diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs index 20e272e..3b8f169 100644 --- a/engine/Age.Engine/Model/GfxState.cs +++ b/engine/Age.Engine/Model/GfxState.cs @@ -14,6 +14,15 @@ public readonly record struct RotationCycleState(bool Enabled, long PeriodMs, double AxisX, double AxisY, double AxisZ, double AngleDegrees = 0); +[System.Flags] +public enum GfxPresentationReason +{ + None = 0, + RetainedMutation = 1, + ContinuousChannel = 2, + DiscreteSourceCell = 4, +} + /// Sampled op-0x223 type-0 surface transition. Range A is already present in normal z-order; /// the compositor draws range B over it with to form the native crossfade. public readonly record struct SurfaceTransitionState(long CommandKey, int TargetSlot, @@ -62,7 +71,8 @@ public readonly record struct RenderObject(long Handle, long SurfaceResId, long bool MultiplyTint, SurfaceTransitionState? SurfaceTransition = null, ColorTransitionState? ColorTransition = null, - Affine2D? RangeTransform = null); + Affine2D? RangeTransform = null, + bool TimeVarying = false); /// Host-agnostic model of the AGE native gfx command-buffer (reversed in /// docs/engine-re.md, gfx op-contract table). One registry maps an object handle to a GfxObject — the @@ -142,6 +152,9 @@ public sealed class GfxState // Populated lazily by the geometry SET ops and draw-texture. Op 0x215 queries this same native map and // returns the object's live source slot (obj+4), or -1 when the handle has not been drawn/bound yet. private readonly Dictionary _objects = new(); + // Native composition is handle-ascending z order. Mutations maintain this small index so snapshots do + // not rebuild/sort a dictionary-sized LINQ buffer, while hot handle lookup remains O(1). + private readonly List _orderedObjectHandles = new(); private readonly NumericGlyphStyle[] _numericGlyphStyles = new NumericGlyphStyle[11]; // Ops 0x229-0x22e address one embedded gfx-object record outside the ordinary object map. Its sampled @@ -164,6 +177,12 @@ public sealed class GfxState public long AnimationServiceFlags { get; private set; } public uint PreviousFrameTimeMilliseconds { get; private set; } public uint CurrentFrameTimeMilliseconds { get; private set; } + private long _previousFrameTimeMs; + private long _currentFrameTimeMs; + private long _mutationGeneration; + private long _publishedMutationGeneration; + + private void MarkRetainedMutation() => _mutationGeneration++; /// Live geometry objects and the surface slot they draw from — for the CLI gfx oracle. public IEnumerable<(long Handle, int Slot)> Objects @@ -178,8 +197,14 @@ public sealed class GfxState // (Monitor) so the callers that already hold it are fine. lock (_lock) { - if (!_objects.TryGetValue(handle, out var o)) { o = new GfxObject(); _objects[handle] = o; } + if (!_objects.TryGetValue(handle, out var o)) + { + o = new GfxObject(); + _objects[handle] = o; + InsertOrderedHandle(handle); + } CurrentObject = handle; + MarkRetainedMutation(); return o; } } @@ -189,6 +214,31 @@ public sealed class GfxState lock (_lock) DefaultObjectSlot = slot; } + public void SetObjectAnchor(long handle, (long X, long Y, long Z) anchor) + { + lock (_lock) GetOrCreate(handle).V18 = anchor; + } + + public void SetObjectPosition(long handle, (long X, long Y, long Z) position) + { + lock (_lock) GetOrCreate(handle).V24 = position; + } + + public void SetObjectField64(long handle, long value) + { + lock (_lock) GetOrCreate(handle).Field64 = value; + } + + public void SetObjectFields68And6c(long handle, long value68, long value6c) + { + lock (_lock) + { + var o = GetOrCreate(handle); + o.Field68 = value68; + o.Field6c = value6c; + } + } + /// Op 0x229: reset the embedded range transform, select [first, first+count), and set its /// anchor/pivot. This does not create or mutate an ordinary retained object. public void SetRangeTransform(long first, long count, (long X, long Y, long Z) anchor) @@ -198,6 +248,7 @@ public sealed class GfxState _rangeTransformFirst = first; _rangeTransformCount = System.Math.Max(0, count); _rangeTransform = new GfxObject { V18 = anchor }; + MarkRetainedMutation(); } } @@ -205,13 +256,20 @@ public sealed class GfxState public void SetRangeScaleCurrent((long X, long Y, long Z) percent) { lock (_lock) + { _rangeTransform.ScaleCurrent = (percent.X / 100.0, percent.Y / 100.0, percent.Z / 100.0); + MarkRetainedMutation(); + } } /// Op 0x22c: immediately replace the embedded range transform's current translation. public void SetRangeTranslationCurrent((long X, long Y, long Z) translation) { - lock (_lock) _rangeTransform.TranslationCurrent = translation; + lock (_lock) + { + _rangeTransform.TranslationCurrent = translation; + MarkRetainedMutation(); + } } /// Op 0x22d: arm the range transform's delayed one-shot scale target. @@ -224,6 +282,7 @@ public sealed class GfxState _rangeTransform.ScaleTarget = (percent.X / 100.0, percent.Y / 100.0, percent.Z / 100.0); _rangeTransform.ScaleEnabled = durationMs > 0; _rangeTransform.OneShotStartMs = -1; + MarkRetainedMutation(); } } @@ -233,6 +292,7 @@ public sealed class GfxState lock (_lock) { if (!_objects.TryGetValue(sourceHandle, out var s)) return false; + bool destinationIsNew = !_objects.ContainsKey(destinationHandle); _objects[destinationHandle] = new GfxObject { V18 = s.V18, V24 = s.V24, V16c = s.V16c, @@ -258,7 +318,9 @@ public sealed class GfxState RotationPeriodMs = s.RotationPeriodMs, RotationAxis = s.RotationAxis, RotationEnabled = s.RotationEnabled, RotationStartMs = s.RotationStartMs, }; + if (destinationIsNew) InsertOrderedHandle(destinationHandle); CurrentObject = destinationHandle; + MarkRetainedMutation(); return true; } } @@ -280,7 +342,10 @@ public sealed class GfxState { lock (_lock) if (_objects.TryGetValue(handle, out var obj) && obj.SourceSlot == fromSlot) + { obj.SourceSlot = toSlot; + MarkRetainedMutation(); + } } public long QueryField(long idx) => _fieldTable.TryGetValue(idx, out var v) ? v : 0; @@ -288,7 +353,12 @@ public sealed class GfxState { lock (_lock) // re-entrant: EraseRange already holds _lock { - _objects.Remove(handle); + if (_objects.Remove(handle)) + { + int index = _orderedObjectHandles.BinarySearch(handle); + if (index >= 0) _orderedObjectHandles.RemoveAt(index); + MarkRetainedMutation(); + } } } @@ -312,7 +382,9 @@ public sealed class GfxState lock (_lock) { _objects.Clear(); + _orderedObjectHandles.Clear(); CurrentObject = 0; + MarkRetainedMutation(); } } @@ -324,6 +396,7 @@ public sealed class GfxState lock (_lock) { _objects.Clear(); + _orderedObjectHandles.Clear(); _fieldTable.Clear(); _surfaces.Clear(); _createdSurfaces.Clear(); @@ -339,6 +412,9 @@ public sealed class GfxState AnimationServiceFlags = 0; PreviousFrameTimeMilliseconds = 0; CurrentFrameTimeMilliseconds = 0; + _previousFrameTimeMs = 0; + _currentFrameTimeMs = 0; + MarkRetainedMutation(); } } @@ -360,6 +436,7 @@ public sealed class GfxState _surfaces[slot] = (resId, colorKey); _createdSurfaces.Remove(slot); _movieStopTimesMs.Remove(slot); + MarkRetainedMutation(); } } @@ -399,6 +476,7 @@ public sealed class GfxState } if (CurrentRenderTargetSlot >= firstSlot && CurrentRenderTargetSlot < end) CurrentRenderTargetSlot = -1; + MarkRetainedMutation(); } } @@ -498,6 +576,7 @@ public sealed class GfxState _surfaces[slot] = (0, -1); // create-texture: real mutable pixels, no asset id or color key _createdSurfaces.Add(slot); _movieStopTimesMs.Remove(slot); + MarkRetainedMutation(); } } @@ -509,6 +588,7 @@ public sealed class GfxState _createdSurfaces.Remove(slot); _movieStopTimesMs.Remove(slot); _surfaceTransitions.Remove(slot); + MarkRetainedMutation(); } } @@ -526,6 +606,7 @@ public sealed class GfxState RangeBStart = rangeBStart, RangeBCount = System.Math.Max(0, rangeBCount), DelayMs = System.Math.Max(0, delayMs), DurationMs = System.Math.Max(0, durationMs), }; + MarkRetainedMutation(); } } @@ -684,6 +765,7 @@ public sealed class GfxState int completed = 0; foreach (var t in _surfaceTransitions.Values) if (!t.Forced && TransitionProgress(t, nowMs) < 1.0) { t.Forced = true; completed++; } + if (completed > 0) MarkRetainedMutation(); return completed; } } @@ -883,7 +965,12 @@ public sealed class GfxState /// Op 0x238: set its separate global animation-service duration and reset marker. public void SetAnimClock(long durationTicks) { - lock (_lock) { AnimClockDurationTicks = durationTicks; AnimClockGeneration++; } + lock (_lock) + { + AnimClockDurationTicks = durationTicks; + AnimClockGeneration++; + MarkRetainedMutation(); + } } public void SetAnimationServiceFlags(long flags) @@ -895,6 +982,8 @@ public sealed class GfxState { lock (_lock) { + _previousFrameTimeMs = _currentFrameTimeMs; + _currentFrameTimeMs = nowMilliseconds; PreviousFrameTimeMilliseconds = CurrentFrameTimeMilliseconds; CurrentFrameTimeMilliseconds = unchecked((uint)nowMilliseconds); } @@ -910,9 +999,65 @@ public sealed class GfxState ForceCompleteOneShotChannels(); AnimClockDurationTicks = 0; AnimClockGeneration++; + MarkRetainedMutation(); } } + /// Sample the shared native frame clock and report why the retained scene needs publishing. + /// Continuous channels remain frame-driven; op-0x231 spritesheets become dirty only when the shared + /// previous/current samples select different cells. Retained VM writes are published exactly once. + public GfxPresentationReason ConsumePresentationReasons(long nowMs) + { + lock (_lock) + { + _previousFrameTimeMs = _currentFrameTimeMs; + _currentFrameTimeMs = nowMs; + PreviousFrameTimeMilliseconds = unchecked((uint)_previousFrameTimeMs); + CurrentFrameTimeMilliseconds = unchecked((uint)_currentFrameTimeMs); + + GfxPresentationReason reasons = GfxPresentationReason.None; + if (_publishedMutationGeneration != _mutationGeneration) + { + _publishedMutationGeneration = _mutationGeneration; + reasons |= GfxPresentationReason.RetainedMutation; + } + + if (_surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0) || + _rangeTransform.ScaleEnabled || _rangeTransform.RotationChannelEnabled || + _rangeTransform.TranslationEnabled || + _objects.Values.Any(o => o.Visible && + (o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled || + o.TranslationEnabled || + (o.ColorAnim && o.ColorPeriod > 0) || + (o.RotationEnabled && o.RotationPeriodMs > 0)))) + reasons |= GfxPresentationReason.ContinuousChannel; + + foreach (var o in _objects.Values) + { + if (!o.Visible || !o.SrcAnim || o.SrcPeriod <= 0 || o.SrcFrameCount < 1) continue; + if (o.SrcStart < 0) + { + // Prototype clones made before first publication all enter here in the same shared sample, + // reproducing FIELD's native phase lock. + o.SrcStart = nowMs; + continue; + } + if (SourceCellAt(o, _previousFrameTimeMs) != SourceCellAt(o, _currentFrameTimeMs)) + { + reasons |= GfxPresentationReason.DiscreteSourceCell; + break; + } + } + return reasons; + } + } + + private static long SourceCellAt(GfxObject o, long nowMs) + { + long elapsed = System.Math.Max(0, nowMs - o.SrcStart); + return elapsed / o.SrcPeriod % o.SrcFrameCount; + } + /// Back-compat: snapshot with no animation clock (nowMs = 0) — deterministic, for headless /// callers and existing tests. public IReadOnlyList SnapshotVisibleObjects() => SnapshotVisibleObjects(0); @@ -925,10 +1070,22 @@ public sealed class GfxState /// alpha/tint. Channel Start fields seed to nowMs on first sight. public IReadOnlyList SnapshotVisibleObjects(long nowMs) { + var list = new List(); + SnapshotVisibleObjects(nowMs, list); + return list; + } + + /// Fill a caller-owned snapshot buffer. The Godot compositor reuses one list so its backing + /// array survives across frames; callers that need an independently retained snapshot should use the + /// returning overload. + public void SnapshotVisibleObjects(long nowMs, List list) + { + ArgumentNullException.ThrowIfNull(list); lock (_lock) { - var list = new List(); + list.Clear(); Affine2D? rangeAffine = null; + bool rangeTimeVarying = false; if (_rangeTransformCount > 0) { var r = _rangeTransform; @@ -944,15 +1101,16 @@ public sealed class GfxState ref r.TranslationEnabled, nowMs); if (!r.ScaleEnabled && !r.RotationChannelEnabled && !r.TranslationEnabled) r.OneShotStartMs = -1; + rangeTimeVarying = r.ScaleEnabled || r.RotationChannelEnabled || r.TranslationEnabled; rangeAffine = Transform2DMath.Build(new TransformState( rangeScale.X, rangeScale.Y, rangeScale.Z, rangeTranslation.X, rangeTranslation.Y, rangeTranslation.Z, r.V18.X, r.V18.Y, r.V18.Z, rangeRotation.X, rangeRotation.Y, rangeRotation.Z, rangeRotation.Angle)); } - foreach (var kv in _objects.OrderBy(k => k.Key)) + foreach (long handle in _orderedObjectHandles) { - var o = kv.Value; + var o = _objects[handle]; if (!o.Visible) continue; bool hadOneShot = o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled || o.TranslationEnabled; @@ -1064,9 +1222,17 @@ public sealed class GfxState SurfaceTransitionState? transition = _surfaceTransitions.TryGetValue(o.SourceSlot, out var st) ? SampleTransition(st, nowMs) : null; Affine2D? objectRangeTransform = rangeAffine is { } ra && - kv.Key >= _rangeTransformFirst && kv.Key - _rangeTransformFirst < _rangeTransformCount + handle >= _rangeTransformFirst && handle - _rangeTransformFirst < _rangeTransformCount ? ra : null; - list.Add(new RenderObject(kv.Key, resId, ck, srcX, srcY, w, h, + bool timeVarying = + o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled || + o.TranslationEnabled || + (o.SrcAnim && o.SrcPeriod > 0) || + (o.ColorAnim && o.ColorPeriod > 0) || + (o.RotationEnabled && o.RotationPeriodMs > 0) || + transition is { Progress: < 1.0 } || + (objectRangeTransform != null && rangeTimeVarying); + list.Add(new RenderObject(handle, resId, ck, srcX, srcY, w, h, (int)o.V24.X, (int)o.V24.Y, new TransformState(scale.X, scale.Y, scale.Z, translation.X, translation.Y, translation.Z, @@ -1076,12 +1242,17 @@ public sealed class GfxState o.RotationAxis.X, o.RotationAxis.Y, o.RotationAxis.Z, cycleAngle), alpha, tint, strength, blend, multiplyTint, transition, - colorTransition, objectRangeTransform)); + colorTransition, objectRangeTransform, timeVarying)); } - return list; } } + private void InsertOrderedHandle(long handle) + { + int index = _orderedObjectHandles.BinarySearch(handle); + if (index < 0) _orderedObjectHandles.Insert(~index, handle); + } + private static (long Packed, ColorTransitionState State) SampleOneShotColor(GfxObject o, long nowMs) { long current = o.Color & 0xffffffff; diff --git a/engine/Age.Engine/Model/SoftwareAffineRasterizer.cs b/engine/Age.Engine/Model/SoftwareAffineRasterizer.cs index d8b70a3..a2c223a 100644 --- a/engine/Age.Engine/Model/SoftwareAffineRasterizer.cs +++ b/engine/Age.Engine/Model/SoftwareAffineRasterizer.cs @@ -13,14 +13,23 @@ public static class SoftwareAffineRasterizer int ia = (int)(System.Math.Clamp(opacity, 0f, 1f) * 255); if (ia == 0) return; int tr=(int)(tint>>16&255), tg=(int)(tint>>8&255), tb=(int)(tint&255); + bool unmodulatedSourceOver = ia == 255 && istr == 0 && !multiplyTint && blend != BlendKind.Additive; if (TryIntegerTranslation(localToDest, out int tx, out int ty)) { BlitTranslated(dst, dstW, dstH, src, srcW, srcX, srcY, width, height, - tx, ty, tr, tg, tb, istr, ia, multiplyTint, blend); + tx, ty, tr, tg, tb, istr, ia, multiplyTint, blend, unmodulatedSourceOver); return; } if (!localToDest.TryInverse(out var inv)) return; Bounds(localToDest, width, height, dstW, dstH, out int x0, out int y0, out int x1, out int y1); + if (x1 <= x0 || y1 <= y0) return; + if (localToDest.XY == 0 && localToDest.YX == 0 && x1 - x0 <= 4096) + { + BlitAxisAligned(dst, dstW, src, srcW, srcX, srcY, width, height, inv, + x0, y0, x1, y1, tr, tg, tb, istr, ia, multiplyTint, blend, + unmodulatedSourceOver); + return; + } for (int y=y0; y=0&&p.X=0&&p.Y.Shared.Rent(count); + try + { + for (int x = x0; x < x1; x++) + { + int u = (int)System.Math.Floor(inv.Apply(x + 0.5, y0 + 0.5).X); + sourceColumns[x - x0] = (uint)u < (uint)width ? u : -1; + } + for (int y = y0; y < y1; y++) + { + int v = (int)System.Math.Floor(inv.Apply(x0 + 0.5, y + 0.5).Y); + if ((uint)v >= (uint)height) continue; + int sourceRow = (srcY + v) * srcW; + int destinationRow = y * dstW; + for (int x = x0; x < x1; x++) + { + int u = sourceColumns[x - x0]; + if (u < 0) continue; + int si = (sourceRow + srcX + u) * 4; + int di = (destinationRow + x) * 4; + if (unmodulatedSourceOver) + { + BlendUnmodulatedSourceOver(dst, di, src, si); + continue; + } + int sa = src[si + 3] * ia / 255; + if (sa == 0) continue; + int sr = multiplyTint ? src[si] * tr / 255 : (src[si] * (255 - istr) + tr * istr) / 255; + int sg = multiplyTint ? src[si + 1] * tg / 255 : (src[si + 1] * (255 - istr) + tg * istr) / 255; + int sb = multiplyTint ? src[si + 2] * tb / 255 : (src[si + 2] * (255 - istr) + tb * istr) / 255; + Blend(dst, di, sr, sg, sb, sa, blend); + } + } + } + finally { System.Buffers.ArrayPool.Shared.Return(sourceColumns); } + } + + // Most FIELD layers have full object opacity and no tint. Preserve color-key transparency and partially + // transparent edge texels, but make the overwhelmingly common alpha-255 case a four-byte copy instead of + // performing tint and source-over multiply/divide work whose result is exactly the source texel. + private static void BlendUnmodulatedSourceOver(byte[] dst, int di, byte[] src, int si) + { + int a = src[si + 3]; + if (a == 0) return; + if (a == 255) + { + dst[di] = src[si]; + dst[di + 1] = src[si + 1]; + dst[di + 2] = src[si + 2]; + dst[di + 3] = 255; + return; + } + int inverse = 255 - a; + dst[di] = (byte)((src[si] * a + dst[di] * inverse) / 255); + dst[di + 1] = (byte)((src[si + 1] * a + dst[di + 1] * inverse) / 255); + dst[di + 2] = (byte)((src[si + 2] * a + dst[di + 2] * inverse) / 255); + dst[di + 3] = (byte)System.Math.Min(255, dst[di + 3] + a); + } + + private static void FillAxisAligned(byte[] dst, int dstW, int width, int height, Affine2D inv, + int x0, int y0, int x1, int y1, + int r, int g, int b, int a) + { + int count = x1 - x0; + bool[] includedColumns = System.Buffers.ArrayPool.Shared.Rent(count); + try + { + for (int x = x0; x < x1; x++) + { + double u = inv.Apply(x + 0.5, y0 + 0.5).X; + includedColumns[x - x0] = u >= 0 && u < width; + } + for (int y = y0; y < y1; y++) + { + double v = inv.Apply(x0 + 0.5, y + 0.5).Y; + if (v < 0 || v >= height) continue; + int destinationRow = y * dstW; + for (int x = x0; x < x1; x++) + if (includedColumns[x - x0]) Blend(dst, (destinationRow + x) * 4, r, g, b, a); + } + } + finally { System.Buffers.ArrayPool.Shared.Return(includedColumns); } + } + private static void Bounds(Affine2D m,int w,int h,int dw,int dh,out int x0,out int y0,out int x1,out int y1) { var a=m.Apply(0,0);var b=m.Apply(w,0);var c=m.Apply(0,h);var d=m.Apply(w,h); diff --git a/engine/Age.Engine/Model/Transform2DMath.cs b/engine/Age.Engine/Model/Transform2DMath.cs index 7bd55d2..39f9132 100644 --- a/engine/Age.Engine/Model/Transform2DMath.cs +++ b/engine/Age.Engine/Model/Transform2DMath.cs @@ -40,46 +40,74 @@ public static class Transform2DMath { public static Affine2D Build(TransformState t, RotationCycleState cycle = default) { - double[] m = Identity(); + Matrix3D m = Identity(); m = Mul(m, Translation(-t.AnchorX, -t.AnchorY, -t.AnchorZ)); m = Mul(m, Scale(t.ScaleX, t.ScaleY, t.ScaleZ)); m = Mul(m, AxisAngle(t.RotationAxisX, t.RotationAxisY, t.RotationAxisZ, t.RotationAngleDegrees)); m = Mul(m, Translation(t.TranslateX, t.TranslateY, t.TranslateZ)); if (cycle.Enabled) m = Mul(m, AxisAngle(cycle.AxisX, cycle.AxisY, cycle.AxisZ, cycle.AngleDegrees)); m = Mul(m, Translation(t.AnchorX, t.AnchorY, t.AnchorZ)); - return new(m[0], m[1], m[4], m[5], m[12], m[13]); + return new(m.M11, m.M12, m.M21, m.M22, m.TX, m.TY); } public static (double X, double Y) Apply(double x, double y, TransformState transform, RotationCycleState cycle = default) => Build(transform, cycle).Apply(x, y); - private static double[] Identity() => new double[] { 1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1 }; - private static double[] Scale(double x, double y, double z) - => new double[] { x,0,0,0, 0,y,0,0, 0,0,z,0, 0,0,0,1 }; - private static double[] Translation(double x, double y, double z) - => new double[] { 1,0,0,0, 0,1,0,0, 0,0,1,0, x,y,z,1 }; + // AGE composes affine 4x4 row-vector matrices, whose last column is always (0,0,0,1). Carry only + // the 3x3 linear part and translation row as a value type: the old double[16] implementation allocated + // about eleven arrays per rendered object, or roughly 2.2 MB on every DEBUGMAP composition. + private readonly record struct Matrix3D( + double M11, double M12, double M13, + double M21, double M22, double M23, + double M31, double M32, double M33, + double TX, double TY, double TZ); - private static double[] AxisAngle(double x, double y, double z, double degrees) + private static Matrix3D Identity() => new( + 1,0,0, 0,1,0, 0,0,1, 0,0,0); + + private static Matrix3D Scale(double x, double y, double z) => new( + x,0,0, 0,y,0, 0,0,z, 0,0,0); + + private static Matrix3D Translation(double x, double y, double z) => new( + 1,0,0, 0,1,0, 0,0,1, x,y,z); + + private static Matrix3D AxisAngle(double x, double y, double z, double degrees) { double len = System.Math.Sqrt(x*x + y*y + z*z); if (len < 1e-12 || System.Math.Abs(degrees) < 1e-12) return Identity(); x /= len; y /= len; z /= len; double r = degrees * System.Math.PI / 180.0, c = System.Math.Cos(r), s = System.Math.Sin(r), q = 1-c; - return new double[] { - x*x*q+c, x*y*q+z*s, x*z*q-y*s, 0, - x*y*q-z*s, y*y*q+c, y*z*q+x*s, 0, - x*z*q+y*s, y*z*q-x*s, z*z*q+c, 0, - 0,0,0,1 - }; + return new( + x*x*q+c, x*y*q+z*s, x*z*q-y*s, + x*y*q-z*s, y*y*q+c, y*z*q+x*s, + x*z*q+y*s, y*z*q-x*s, z*z*q+c, + 0,0,0); } - private static double[] Mul(double[] a, double[] b) + private static Matrix3D Mul(Matrix3D a, Matrix3D b) => new( + Sum4(a.M11*b.M11, a.M12*b.M21, a.M13*b.M31, 0), + Sum4(a.M11*b.M12, a.M12*b.M22, a.M13*b.M32, 0), + Sum4(a.M11*b.M13, a.M12*b.M23, a.M13*b.M33, 0), + Sum4(a.M21*b.M11, a.M22*b.M21, a.M23*b.M31, 0), + Sum4(a.M21*b.M12, a.M22*b.M22, a.M23*b.M32, 0), + Sum4(a.M21*b.M13, a.M22*b.M23, a.M23*b.M33, 0), + Sum4(a.M31*b.M11, a.M32*b.M21, a.M33*b.M31, 0), + Sum4(a.M31*b.M12, a.M32*b.M22, a.M33*b.M32, 0), + Sum4(a.M31*b.M13, a.M32*b.M23, a.M33*b.M33, 0), + Sum4(a.TX*b.M11, a.TY*b.M21, a.TZ*b.M31, b.TX), + Sum4(a.TX*b.M12, a.TY*b.M22, a.TZ*b.M32, b.TY), + Sum4(a.TX*b.M13, a.TY*b.M23, a.TZ*b.M33, b.TZ)); + + // Accumulate in the same order as the former 4x4 loop so boundary-sensitive nearest-neighbour + // projection retains its floating-point behavior while avoiding an intermediate array. + private static double Sum4(double a, double b, double c, double d) { - var o = new double[16]; - for (int row=0; row<4; row++) - for (int col=0; col<4; col++) - for (int k=0; k<4; k++) o[row*4+col] += a[row*4+k] * b[k*4+col]; - return o; + double result = 0; + result += a; + result += b; + result += c; + result += d; + return result; } } diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 625a65e..c2a905f 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -1712,16 +1712,16 @@ public sealed class VirtualMachine Write(a[1], v.X); Write(a[2], v.Y); Write(a[3], v.Z); return pc + 1; } case "set-gfx-geom3": // 0x217 (handle)(a)(b)(c) -> V18 - Gfx.GetOrCreate(Read(a[0])).V18 = (Read(a[1]), Read(a[2]), Read(a[3])); return pc + 1; + Gfx.SetObjectAnchor(Read(a[0]), (Read(a[1]), Read(a[2]), Read(a[3]))); return pc + 1; case "set-gfx-geom3-b": // 0x219 (handle)(a)(b)(c) -> V24 - Gfx.GetOrCreate(Read(a[0])).V24 = (Read(a[1]), Read(a[2]), Read(a[3])); return pc + 1; + Gfx.SetObjectPosition(Read(a[0]), (Read(a[1]), Read(a[2]), Read(a[3]))); return pc + 1; case "u0041AF00": // 0x80: default object slot substituted by native op 0x1d9 case "set-default-gfx-object-slot": Gfx.SetDefaultObjectSlot((int)Read(a[0])); return pc + 1; // ---- SC0000 anim/transform/spritesheet cluster (docs/engine-re.md §"SC0000 anim ... cluster") ---- case "u00421DD0": // 0x22f set-position: (handle)(op2)(x)(y)(z) -> base position (direct set) - Gfx.GetOrCreate(Read(a[0])).V24 = (Read(a[2]), Read(a[3]), Read(a[4])); return pc + 1; + Gfx.SetObjectPosition(Read(a[0]), (Read(a[2]), Read(a[3]), Read(a[4]))); return pc + 1; case "u004219E0": // pre-reference compatibility case "set-gfx-range-transform": // 0x229 (first)(count)(anchor x/y/z) Gfx.SetRangeTransform(Read(a[0]), Read(a[1]), (Read(a[2]), Read(a[3]), Read(a[4]))); @@ -1782,10 +1782,10 @@ public sealed class VirtualMachine case "gfx-set-scale-current": // 0x1fd (handle)(sx%)(sy%)(sz%) -> current scale matrix Gfx.SetCurrentScale(Read(a[0]), (Read(a[1]), Read(a[2]), Read(a[3]))); return pc + 1; case "set-gfx-field64": // 0x212 (idx)(val) - Gfx.GetOrCreate(Read(a[0])).Field64 = Read(a[1]); return pc + 1; + Gfx.SetObjectField64(Read(a[0]), Read(a[1])); return pc + 1; case "set-gfx-xy": // 0x213 (idx)(x)(y) { - var o = Gfx.GetOrCreate(Read(a[0])); o.Field68 = Read(a[1]); o.Field6c = Read(a[2]); return pc + 1; + Gfx.SetObjectFields68And6c(Read(a[0]), Read(a[1]), Read(a[2])); return pc + 1; } case "gfx-elem-erase": // 0x1f7 (handle)(count) — erase retained-object range Gfx.EraseRange(Read(a[0]), Read(a[1])); return pc + 1; diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index f262e79..aafae5e 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -7,6 +7,17 @@ using Age.Engine.Hosting; using Age.Engine.Model; using Age.Engine.Sys4; +[Flags] +public enum HostPresentationReason +{ + None = 0, + HostRequest = 1, + ScreenTransition = 2, + RetainedMutation = 4, + ContinuousChannel = 8, + DiscreteSourceCell = 16, +} + [SupportedOSPlatform("windows")] public sealed class GodotAdvHost : IHost { @@ -185,6 +196,16 @@ public sealed class GodotAdvHost : IHost return _surfaceText.TryGetValue(surfaceSlot, out var draws) ? draws.ToArray() : Array.Empty(); } + public void SnapshotSurfaceText(int surfaceSlot, List snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + lock (_textLock) + { + snapshot.Clear(); + if (_surfaceText.TryGetValue(surfaceSlot, out var draws)) snapshot.AddRange(draws); + } + } + public void ClearRenderedAdvTextLayout(int layoutSlot) { lock (_textLock) _historyText.Remove(layoutSlot == 0 ? _currentAdvLayout : layoutSlot); @@ -481,7 +502,12 @@ public sealed class GodotAdvHost : IHost } public void InputCallbackCompleted(GfxState gfx) - => Interlocked.Exchange(ref _presentRequested, 1); + { + // Callback completion itself is not native graphics dirtiness. Any retained writes made by the + // callback are published through GfxState's mutation generation; host-owned surface writes set + // _presentRequested at their actual mutation sites. FIELD services a 50 ms hover callback even + // while the pointer is idle, so an unconditional request here recreates its sleep-poll overdraw. + } public long InputClockMilliseconds => _clock.NowMs; @@ -639,12 +665,22 @@ public sealed class GodotAdvHost : IHost // Native retained-object writes are not front-buffer writes. Publish explicit/service-boundary dirtiness // once, then continue only while the sampled retained scene can actually change. Text reveal is a separate // Godot Label; waiting/sleeping alone do not alter background pixels. - public bool ShouldRecomposite(GfxState gfx) + public HostPresentationReason ConsumePresentationReasons(GfxState gfx) { bool screenTransitionActive; lock (_screenTransitionLock) screenTransitionActive = _screenTransition != null; - return System.Threading.Interlocked.Exchange(ref _presentRequested, 0) != 0 || - screenTransitionActive || gfx.HasActiveVisualPresentation(_clock.NowMs); + var reasons = HostPresentationReason.None; + if (System.Threading.Interlocked.Exchange(ref _presentRequested, 0) != 0) + reasons |= HostPresentationReason.HostRequest; + if (screenTransitionActive) reasons |= HostPresentationReason.ScreenTransition; + GfxPresentationReason gfxReasons = gfx.ConsumePresentationReasons(_clock.NowMs); + if ((gfxReasons & GfxPresentationReason.RetainedMutation) != 0) + reasons |= HostPresentationReason.RetainedMutation; + if ((gfxReasons & GfxPresentationReason.ContinuousChannel) != 0) + reasons |= HostPresentationReason.ContinuousChannel; + if ((gfxReasons & GfxPresentationReason.DiscreteSourceCell) != 0) + reasons |= HostPresentationReason.DiscreteSourceCell; + return reasons; } public void Stop() @@ -760,9 +796,6 @@ public sealed class GodotAdvHost : IHost long ms = NormalizeSleepMilliseconds(duration, SleepScale); long deadline = _clock.NowMs + ms; _timeline?.State("sleep", new() { ["duration_ms"] = ms, ["deadline_ms"] = deadline }); - // A sleep is a service boundary: make preceding retained writes visible once even when no animation - // channel is active during the hold. - System.Threading.Interlocked.Exchange(ref _presentRequested, 1); IsSleeping = true; while (_clock.NowMs < deadline) { diff --git a/godot/GodotTraceSink.cs b/godot/GodotTraceSink.cs index 5ce895b..a2e3c28 100644 --- a/godot/GodotTraceSink.cs +++ b/godot/GodotTraceSink.cs @@ -97,6 +97,12 @@ public sealed class GodotTraceSink : ITraceSink } } + /// Allocation-free current coordinate for once-per-frame diagnostics. + public GodotTraceStepSnapshot? LatestStep + { + get { lock (_snapshotLock) return _latestStep; } + } + private string[] CurrentCallStackLocked() { var stack = _scripts.ToArray(); diff --git a/godot/Main.cs b/godot/Main.cs index 8e892e9..a7faced 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -86,6 +86,8 @@ public partial class Main : Godot.Control private string? _timelineLogPath; // --timeline-log : synchronized VM/host/compositor evidence private GodotTimelineLog? _timeline; private int _timelineFrame; + private string? _perfLogPath; // --perf-log : low-overhead frame/compositor timings + work + private PerformanceFrameLog? _perf; public override void _Ready() { @@ -184,6 +186,7 @@ public partial class Main : Godot.Control if (userArgs[i] == "--shot-sequence" && i + 1 < userArgs.Length) _seqDir = userArgs[i + 1]; if (userArgs[i] == "--gfx-log" && i + 1 < userArgs.Length) _gfxLogPath = userArgs[i + 1]; if (userArgs[i] == "--timeline-log" && i + 1 < userArgs.Length) _timelineLogPath = userArgs[i + 1]; + if (userArgs[i] == "--perf-log" && i + 1 < userArgs.Length) _perfLogPath = userArgs[i + 1]; if (userArgs[i] == "--frames" && i + 1 < userArgs.Length) int.TryParse(userArgs[i + 1], out _seqFrames); if (userArgs[i] == "--sleep-scale" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out sleepScale); if (userArgs[i] == "--speed" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out speed); @@ -225,6 +228,7 @@ public partial class Main : Godot.Control var resources = scripts != null ? new ResourceMap(scripts.Catalog) : ResourceMap.Load(); _host = new GodotAdvHost(this, resources, scene, _clock, _locator, _timeline) { SleepScale = sleepScale, TraceOps = _gfxLogPath != null }; _trace = new GodotTraceSink(_locator, _timeline); + if (_perfLogPath != null) _perf = new PerformanceFrameLog(_perfLogPath); // --trace-histogram: aggregate op/call-site execution counts of the REAL Godot run (headless flow // diverges — wait-for-input is a no-op there — so this is the only way to profile the live path). _table = table; @@ -322,51 +326,80 @@ public partial class Main : Godot.Control _clock.Advance(delta); _timelineFrame++; _timeline?.SetFrame(_timelineFrame, _clock.NowMs); - _host?.PulseFrame(); - UpdateVoicePlaybackState(); - AdoptPendingMovies(); - UpdateMovieFrames(); - if (!_selftest && _vm != null && _host != null && _host.ShouldRecomposite(_vm.Gfx)) - Recomposite(); // native publishes retained mutations only at present/service boundaries - if (!_selftest && _host != null) UpdateAdvTextPresentation(); - if (!_selftest && _host != null) UpdateAdvWaitIndicatorPresentation(); - if (!_selftest && _host != null) UpdateHistoryTextPresentation(); - // --shot-sequence: dump one PNG per frame across the opening so a time-based (paced) effect can be - // verified as distinct frames, not just the final state. Captures after Recomposite; quits when full. - if (_seqDir != null && _seqIdx < _seqFrames && !_done) + var perf = _perf; + var step = perf != null ? _trace.LatestStep : null; + perf?.BeginFrame(_timelineFrame, _clock.NowMs, delta, + step?.Script ?? "", step?.Offset ?? -1, step?.Opcode ?? -1); + try { - System.IO.Directory.CreateDirectory(_seqDir); - // Headless has no rendered viewport texture (GetImage() is null). Still advance/count/quit so the - // real-run trace-histogram can profile the live path without a display; only the PNG grab is skipped. - var fimg = GetViewport().GetTexture()?.GetImage(); - fimg?.SavePng($"{_seqDir}/frame_{_seqIdx:0000}.png"); - _seqIdx++; - if (_seqIdx >= _seqFrames) { GD.Print($"SEQ saved {_seqIdx} frames -> {_seqDir}"); GetTree().Quit(0); } - return; - } - // --shot: once the target page is composed and parked at wait-for-input, settle a few frames then grab it. - if (_shotPath != null && !_shotDone && _host != null && (_host.Pages >= _shotPage && _host.IsWaiting || _done)) - { - if (++_shotSettle >= _shotSettleTarget) + long phase = perf != null ? PerformanceFrameLog.Timestamp() : 0; + _host?.PulseFrame(); + perf?.RecordPulse(PerformanceFrameLog.Timestamp() - phase); + + phase = perf != null ? PerformanceFrameLog.Timestamp() : 0; + UpdateVoicePlaybackState(); + AdoptPendingMovies(); + UpdateMovieFrames(); + perf?.RecordMovies(PerformanceFrameLog.Timestamp() - phase); + + phase = perf != null ? PerformanceFrameLog.Timestamp() : 0; + HostPresentationReason presentationReasons = !_selftest && _vm != null && _host != null + ? _host.ConsumePresentationReasons(_vm.Gfx) + : HostPresentationReason.None; + bool shouldRecomposite = presentationReasons != HostPresentationReason.None; + perf?.RecordPresentationReasons((int)presentationReasons); + perf?.RecordShouldRecomposite(PerformanceFrameLog.Timestamp() - phase); + long allocationPhase = perf != null ? PerformanceFrameLog.AllocatedBytes() : 0; + if (shouldRecomposite) + Recomposite(); // native publishes retained mutations only at present/service boundaries + perf?.RecordRecomposeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase); + + phase = perf != null ? PerformanceFrameLog.Timestamp() : 0; + allocationPhase = perf != null ? PerformanceFrameLog.AllocatedBytes() : 0; + if (!_selftest && _host != null) UpdateAdvTextPresentation(); + if (!_selftest && _host != null) UpdateAdvWaitIndicatorPresentation(); + if (!_selftest && _host != null) UpdateHistoryTextPresentation(); + perf?.RecordUiAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase); + perf?.RecordUi(PerformanceFrameLog.Timestamp() - phase); + + // --shot-sequence: dump one PNG per frame across the opening so a time-based (paced) effect can be + // verified as distinct frames, not just the final state. Captures after Recomposite; quits when full. + if (_seqDir != null && _seqIdx < _seqFrames && !_done) { - _shotDone = true; - var img = GetViewport().GetTexture().GetImage(); - img.SavePng(_shotPath); - GD.Print($"SHOT saved page {_pageCount} -> {_shotPath}"); - ReportSubroutines(); - GetTree().Quit(0); + System.IO.Directory.CreateDirectory(_seqDir); + // Headless has no rendered viewport texture (GetImage() is null). Still advance/count/quit so the + // real-run trace-histogram can profile the live path without a display; only the PNG grab is skipped. + var fimg = GetViewport().GetTexture()?.GetImage(); + fimg?.SavePng($"{_seqDir}/frame_{_seqIdx:0000}.png"); + _seqIdx++; + if (_seqIdx >= _seqFrames) { GD.Print($"SEQ saved {_seqIdx} frames -> {_seqDir}"); GetTree().Quit(0); } + return; + } + // --shot: once the target page is composed and parked at wait-for-input, settle a few frames then grab it. + if (_shotPath != null && !_shotDone && _host != null && (_host.Pages >= _shotPage && _host.IsWaiting || _done)) + { + if (++_shotSettle >= _shotSettleTarget) + { + _shotDone = true; + var img = GetViewport().GetTexture().GetImage(); + img.SavePng(_shotPath); + GD.Print($"SHOT saved page {_pageCount} -> {_shotPath}"); + ReportSubroutines(); + GetTree().Quit(0); + } + return; + } + if (_done && !_ended) + { + _ended = true; + DumpHistogram(); + GD.Print($"[vm] ended: {_vm!.HaltReason ?? "unknown"} after {_vm.Steps} steps"); + ReportSubroutines(); + ShowEnd(); + if (_selftest) RunSelfTest(); } - return; - } - if (_done && !_ended) - { - _ended = true; - DumpHistogram(); - GD.Print($"[vm] ended: {_vm!.HaltReason ?? "unknown"} after {_vm.Steps} steps"); - ReportSubroutines(); - ShowEnd(); - if (_selftest) RunSelfTest(); } + finally { perf?.EndFrame(); } } // _Input (not _UnhandledInput): the root Control consumes mouse clicks as GUI input before they @@ -688,6 +721,12 @@ public partial class Main : Godot.Control public override void _ExitTree() { DumpHistogram(); _host?.Stop(); _timeline?.Dispose(); _locator?.Dispose(); + if (_perf != null) + { + _perf.Dispose(); + GD.Print($"[perf-log] wrote {_perf.FrameCount} frames / {_perf.RecompositeCount} recomposites -> {_perf.Path}"); + _perf = null; + } foreach (var movie in _pendingMovies.Values) movie.Decoder.Dispose(); _pendingMovies.Clear(); foreach (var movie in _movies.Values) movie.Decoder.Dispose(); @@ -718,30 +757,62 @@ public partial class Main : Godot.Control // GfxState and applied here; object opacity comes only from the actual blend/color path. private sealed record CachedPixels(int Width, int Height, byte[] Rgba); private readonly System.Collections.Generic.Dictionary<(int AssetId, long Key), CachedPixels> _pixelCache = new(); + private readonly System.Collections.Generic.List _visibleSnapshot = new(1024); + private readonly System.Collections.Generic.List _surfaceTextSnapshot = new(); private void Recomposite() { + if (_perf != null) + { + var presentStep = _trace.LatestStep; + _perf.RecordPresentationCoordinate(presentStep?.Script ?? "", + presentStep?.Offset ?? -1, presentStep?.Opcode ?? -1); + } + long phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0; + long allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0; + bool hasScreenTransition = _host.TrySnapshotScreenTransition(out var transition); + _perf?.RecordSnapshotAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase); + _perf?.RecordSnapshot(PerformanceFrameLog.Timestamp() - phase); + _perf?.BeginRecomposite(hasScreenTransition); + + phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0; System.Array.Clear(_screenPixels); foreach (var label in _surfaceTextLabels) label.Visible = false; + _perf?.RecordClear(PerformanceFrameLog.Timestamp() - phase); int surfaceTextLabelIndex = 0; System.Collections.Generic.Dictionary? decisions = _gfxLogPath != null || _timeline != null ? new() : null; - if (_host.TrySnapshotScreenTransition(out var transition)) + if (hasScreenTransition) { + allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0; // Native mode 4 keeps the captured source opaque and alpha-composites the complete target // surface over it. Each offscreen target has an opaque-black clear beneath its objects. CompositeVisibleObjects(transition.Source, 1f, ref surfaceTextLabelIndex, decisions, false); FillQuad(0, 0, ScreenWidth, ScreenHeight, 0, (float)transition.Progress); CompositeVisibleObjects(transition.Target, (float)transition.Progress, ref surfaceTextLabelIndex, decisions, false); + _perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase); } else { - var visible = _vm.Gfx.SnapshotVisibleObjects(_clock.NowMs); // synchronized objects + ranges - CompositeVisibleObjects(visible, 1f, ref surfaceTextLabelIndex, decisions, true); + phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0; + allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0; + _vm.Gfx.SnapshotVisibleObjects(_clock.NowMs, _visibleSnapshot); // synchronized objects + ranges + _perf?.RecordSnapshotAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase); + _perf?.RecordSnapshot(PerformanceFrameLog.Timestamp() - phase); + allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0; + CompositeVisibleObjects(_visibleSnapshot, 1f, ref surfaceTextLabelIndex, decisions, true); + _perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase); } + phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0; + allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0; _screen.SetData(ScreenWidth, ScreenHeight, false, Image.Format.Rgba8, _screenPixels); + _perf?.RecordSetDataAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase); + _perf?.RecordSetData(PerformanceFrameLog.Timestamp() - phase); + phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0; _screenTex.Update(_screen); + _perf?.RecordTextureUpdate(PerformanceFrameLog.Timestamp() - phase); if (decisions != null) LogGfxDecisionChanges(decisions); + _perf?.EndRecomposite(); } private void CompositeVisibleObjects(IReadOnlyList visible, float globalOpacity, @@ -752,6 +823,7 @@ public partial class Main : Godot.Control int z = 0; foreach (var v in visible) // interpolate at the retained-presentation clock { + _perf?.RecordObject(v.TimeVarying); var t = v.Transform; var affine = Age.Engine.Model.Transform2DMath.Build(t, v.Rotation); var localToDest = affine.FromLocalOrigin(v.DstX, v.DstY); @@ -763,16 +835,23 @@ public partial class Main : Godot.Control float opacity = v.Alpha / 255f * globalOpacity; // transform Z is never opacity float strength = v.TintStrength / 255f; // tint-blend / fill strength var rawObject = _vm.Gfx.TryGet(v.Handle); + long resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0; var surfaceTexture = rawObject != null ? _host.ResolveSurfaceTexture(rawObject.SourceSlot, v.SurfaceResId) : null; + _perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted); bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot); - string outcome; + // These strings exist only for --gfx-log/timeline diagnostics. DEBUGMAP visits roughly one + // thousand retained objects per composition, so formatting them unconditionally creates + // several megabytes of short-lived garbage even in an ordinary run. + string? outcome = null; if (v.SurfaceTransition is { } transition) { + _perf?.RecordTransitionLayer(); int layers = DrawTransitionRange(visible, transition); - outcome = $"TRANSITION slot={transition.TargetSlot} key=0x{transition.CommandKey:x} " + - $"progress={transition.Progress:0.000} forced={transition.Forced} layers={layers}"; + if (decisions != null) + outcome = $"TRANSITION slot={transition.TargetSlot} key=0x{transition.CommandKey:x} " + + $"progress={transition.Progress:0.000} forced={transition.Forced} layers={layers}"; } else if (v.SurfaceResId == 0 && surfaceTexture == null) { @@ -785,37 +864,54 @@ public partial class Main : Godot.Control // One-shot/mode-1 packed color supplies opacity directly. Static mode-0 fills retain // the tint-strength convention used by the existing effect objects. float fillA = v.MultiplyTint ? opacity : opacity * strength; + _perf?.RecordFillLayer(); FillAffineQuad(baseW, baseH, localToDest, v.Tint, fillA); - outcome = $"FILL tint=0x{v.Tint:x6} a={fillA:0.00} {baseW}x{baseH}@({dstX},{dstY}) " + - $"base=({v.DstX},{v.DstY}) anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) " + - $"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) " + - $"trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) rot={v.Rotation.AngleDegrees:0.0}" + - ColorTimeline(v.ColorTransition); + if (decisions != null) + outcome = $"FILL tint=0x{v.Tint:x6} a={fillA:0.00} {baseW}x{baseH}@({dstX},{dstY}) " + + $"base=({v.DstX},{v.DstY}) anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) " + + $"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) " + + $"trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) rot={v.Rotation.AngleDegrees:0.0}" + + ColorTimeline(v.ColorTransition); + } + else + { + _perf?.RecordSkippedLayer(); + if (decisions != null) outcome = "SKIP(no-resId, opaque render-target)"; } - else outcome = "SKIP(no-resId, opaque render-target)"; } else { - var texture = surfaceTexture - ?? (movieSurfaceBound ? null : _host.ResolveResIdTexture(v.SurfaceResId)); - if (texture == null) outcome = $"SKIP(resId=0x{v.SurfaceResId:x} UNRESOLVED)"; + var texture = surfaceTexture; + if (texture == null && !movieSurfaceBound) + { + resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0; + texture = _host.ResolveResIdTexture(v.SurfaceResId); + _perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted); + } + if (texture == null) + { + _perf?.RecordSkippedLayer(); + if (decisions != null) outcome = $"SKIP(resId=0x{v.SurfaceResId:x} UNRESOLVED)"; + } else { BlitLayer(texture.Value.Image, texture.Value.AssetId, v.ColorKey, v.Tint, strength, v.SrcX, v.SrcY, v.W, v.H, localToDest, opacity, v.MultiplyTint, texture.Value.IsDynamic, v.Blend); - outcome = $"slot={rawObject?.SourceSlot} DRAWN resId=0x{v.SurfaceResId:x} {texture.Value.Name} " + - $"src=({v.SrcX},{v.SrcY} {v.W}x{v.H}) base=({v.DstX},{v.DstY}) " + - $"anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) dst=({dstX},{dstY}) " + - $"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) " + - $"rot=({t.RotationAngleDegrees:0.0}+{v.Rotation.AngleDegrees:0.0}) " + - $"mode={rawObject?.StaticColorMode} op={opacity:0.00} tintStr={strength:0.00}" + - ColorTimeline(v.ColorTransition); + if (decisions != null) + outcome = $"slot={rawObject?.SourceSlot} DRAWN resId=0x{v.SurfaceResId:x} {texture.Value.Name} " + + $"src=({v.SrcX},{v.SrcY} {v.W}x{v.H}) base=({v.DstX},{v.DstY}) " + + $"anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) dst=({dstX},{dstY}) " + + $"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) " + + $"rot=({t.RotationAngleDegrees:0.0}+{v.Rotation.AngleDegrees:0.0}) " + + $"mode={rawObject?.StaticColorMode} op={opacity:0.00} tintStr={strength:0.00}" + + ColorTimeline(v.ColorTransition); } } if (decisions != null) decisions[v.Handle] = $"z{z} {outcome}"; if (includeSurfaceText && rawObject != null) { - foreach (var surfaceText in _host.SnapshotSurfaceText(rawObject.SourceSlot)) + _host.SnapshotSurfaceText(rawObject.SourceSlot, _surfaceTextSnapshot); + foreach (var surfaceText in _surfaceTextSnapshot) { if (surfaceText.X < v.SrcX || surfaceText.X >= v.SrcX + v.W || surfaceText.Y < v.SrcY || surfaceText.Y >= v.SrcY + v.H) continue; @@ -969,25 +1065,42 @@ public partial class Main : Godot.Control { if (source.Handle < transition.RangeBStart || source.Handle >= end || source.SurfaceTransition != null) continue; + _perf?.RecordObject(source.TimeVarying); var affine = Transform2DMath.Build(source.Transform, source.Rotation).FromLocalOrigin(source.DstX, source.DstY); if (source.RangeTransform is { } rangeTransform) affine = affine.Then(rangeTransform); float opacity = source.Alpha / 255f * (float)transition.Progress; var rawObject = _vm.Gfx.TryGet(source.Handle); + long resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0; var texture = rawObject != null ? _host.ResolveSurfaceTexture(rawObject.SourceSlot, source.SurfaceResId) : null; + _perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted); bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot); if (source.SurfaceResId == 0 && texture == null) { - if (source.Blend == BlendKind.Opaque) continue; + if (source.Blend == BlendKind.Opaque) + { + _perf?.RecordSkippedLayer(); + continue; + } int w = source.W > 0 ? source.W : 800, h = source.H > 0 ? source.H : 600; + _perf?.RecordFillLayer(); FillAffineQuad(w, h, affine, source.Tint, opacity * source.TintStrength / 255f); } else { - if (!movieSurfaceBound) texture ??= _host.ResolveResIdTexture(source.SurfaceResId); - if (texture == null) continue; + if (!movieSurfaceBound && texture == null) + { + resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0; + texture = _host.ResolveResIdTexture(source.SurfaceResId); + _perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted); + } + if (texture == null) + { + _perf?.RecordSkippedLayer(); + continue; + } BlitLayer(texture.Value.Image, texture.Value.AssetId, source.ColorKey, source.Tint, source.TintStrength / 255f, source.SrcX, source.SrcY, source.W, source.H, affine, opacity, source.MultiplyTint, texture.Value.IsDynamic, source.Blend); @@ -1039,6 +1152,8 @@ public partial class Main : Godot.Control // right<=left or bottom<=top. FIELD deliberately creates zero-area prototype objects from SO005; // expanding those dimensions to the full texture leaks the entire spritesheet onto the map. if (w <= 0 || h <= 0) return; + long sourcePrepStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0; + long sourcePrepAllocated = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0; var cacheKey = (assetId, colorKey); int sourceWidth, sourceHeight; byte[] sourcePixels; @@ -1077,16 +1192,24 @@ public partial class Main : Godot.Control int sh = h; sw = System.Math.Min(sw, sourceWidth - srcX); sh = System.Math.Min(sh, sourceHeight - srcY); + _perf?.RecordSourcePrep(PerformanceFrameLog.Timestamp() - sourcePrepStarted); + _perf?.RecordSourcePrepAllocation(PerformanceFrameLog.AllocatedBytes() - sourcePrepAllocated); if (sw <= 0 || sh <= 0) return; + long rasterStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0; Age.Engine.Model.SoftwareAffineRasterizer.BlitRgba( _screenPixels, ScreenWidth, ScreenHeight, sourcePixels, sourceWidth, sourceHeight, srcX, srcY, sw, sh, localToDest, tint, tintStrength, alpha, multiplyTint, blend); + _perf?.RecordRaster(sw, sh, localToDest, ScreenWidth, ScreenHeight, dynamic, blend, + PerformanceFrameLog.Timestamp() - rasterStarted); } private void FillAffineQuad(int w, int h, Age.Engine.Model.Affine2D localToDest, long tint, float alpha) { + long rasterStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0; Age.Engine.Model.SoftwareAffineRasterizer.FillRgba( _screenPixels, ScreenWidth, ScreenHeight, w, h, localToDest, tint, alpha); + _perf?.RecordRaster(w, h, localToDest, ScreenWidth, ScreenHeight, false, BlendKind.Alpha, + PerformanceFrameLog.Timestamp() - rasterStarted); } // Alpha-blend a solid tint (0xRRGGBB) rectangle over the screen — the surfaceless fade/flash fill. @@ -1094,6 +1217,7 @@ public partial class Main : Godot.Control { int ia = (int)(System.Math.Clamp(alpha, 0f, 1f) * 255); if (ia == 0) return; + long rasterStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0; int tr = (int)((tint >> 16) & 0xff), tg = (int)((tint >> 8) & 0xff), tb = (int)(tint & 0xff); byte[] dst = _screenPixels; int dw = ScreenWidth, dh = ScreenHeight; @@ -1110,6 +1234,10 @@ public partial class Main : Godot.Control dst[di + 2] = (byte)((tb * ia + dst[di + 2] * (255 - ia)) / 255); dst[di + 3] = (byte)System.Math.Min(255, dst[di + 3] + ia); } + _perf?.RecordFillLayer(); + _perf?.RecordRaster(w, h, new Affine2D(1, 0, 0, 1, dstX, dstY), + ScreenWidth, ScreenHeight, false, BlendKind.Alpha, + PerformanceFrameLog.Timestamp() - rasterStarted); } // Make colorkey-matching texels transparent (native colorkey is baked at surface load). diff --git a/godot/MovieSurfaceRegistry.cs b/godot/MovieSurfaceRegistry.cs index bc34300..c5b9047 100644 --- a/godot/MovieSurfaceRegistry.cs +++ b/godot/MovieSurfaceRegistry.cs @@ -94,16 +94,18 @@ internal sealed class MovieSurfaceRegistry { lock (_lock) { - foreach (var binding in _byPlayback.Values - .Where(binding => binding.ResourceId == resourceId) - .OrderByDescending(binding => binding.PlaybackId)) + long newestPlaybackId = long.MinValue; + MovieSurfaceFrame? newestFrame = null; + foreach (var binding in _byPlayback.Values) { - if (!_frames.TryGetValue(binding.PlaybackId, out var found)) continue; - frame = found; - return true; + if (binding.ResourceId != resourceId || binding.PlaybackId <= newestPlaybackId || + !_frames.TryGetValue(binding.PlaybackId, out var found)) + continue; + newestPlaybackId = binding.PlaybackId; + newestFrame = found; } - frame = null; - return false; + frame = newestFrame; + return newestFrame != null; } } diff --git a/godot/PerformanceFrameLog.cs b/godot/PerformanceFrameLog.cs new file mode 100644 index 0000000..ee55328 --- /dev/null +++ b/godot/PerformanceFrameLog.cs @@ -0,0 +1,297 @@ +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text; +using Age.Engine.Model; + +/// +/// Buffered, diagnostic-only CSV writer for real Godot frame and retained-compositor cost. The writer is +/// deliberately independent of Godot types so its schema and clipping arithmetic can be unit tested. +/// +public sealed class PerformanceFrameLog : IDisposable +{ + private const int FlushIntervalFrames = 120; + private static readonly double MillisecondsPerTick = 1000.0 / Stopwatch.Frequency; + private readonly StreamWriter _writer; + private Frame _current = new(); + private bool _frameOpen; + private bool _disposed; + private int _framesSinceFlush; + + public long FrameCount { get; private set; } + public long RecompositeCount { get; private set; } + public string Path { get; } + + public PerformanceFrameLog(string path) + { + Path = path; + var directory = System.IO.Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); + _writer = new StreamWriter(path, append: false, Encoding.UTF8, 64 * 1024); + // Presentation coordinates are appended separately so a VM thread released by PulseFrame can be + // distinguished from the coordinate observed at _Process entry. + _writer.WriteLine( + "frame,now_ms,delta_ms,main_ms,pulse_ms,movie_ms,should_recomposite_ms,recomposite_ms," + + "clear_ms,snapshot_ms,resolve_ms,source_prep_ms,raster_ms,set_data_ms,texture_update_ms,ui_ms," + + "allocated_bytes,recompose_allocated_bytes,snapshot_allocated_bytes," + + "composite_allocated_bytes,source_prep_allocated_bytes,set_data_allocated_bytes," + + "ui_allocated_bytes,gen0,gen1,gen2,recomposited,screen_transition," + + "present_host_request,present_screen_transition,present_retained_mutation," + + "present_continuous_channel,present_discrete_cell,object_visits," + + "time_varying_objects,draw_layers,fill_layers,transition_layers,skipped_layers," + + "integer_layers,fractional_translation_layers,axis_aligned_scale_layers," + + "general_affine_layers,affine_layers,singular_layers,dynamic_layers,opaque_layers,alpha_layers," + + "additive_layers,source_pixels,candidate_pixels,full_screen_layers,script,offset,opcode," + + "present_script,present_offset,present_opcode"); + } + + public static long Timestamp() => Stopwatch.GetTimestamp(); + public static long AllocatedBytes() => GC.GetAllocatedBytesForCurrentThread(); + + public void BeginFrame(int frame, long nowMs, double deltaSeconds, + string script, int offset, int opcode) + { + if (_disposed) return; + if (_frameOpen) EndFrame(); + _current = new Frame + { + Number = frame, + NowMs = nowMs, + DeltaMs = deltaSeconds * 1000.0, + Script = script, + Offset = offset, + Opcode = opcode, + Started = Timestamp(), + AllocatedStart = GC.GetAllocatedBytesForCurrentThread(), + Gen0Start = GC.CollectionCount(0), + Gen1Start = GC.CollectionCount(1), + Gen2Start = GC.CollectionCount(2), + }; + _frameOpen = true; + } + + public void RecordPulse(long ticks) => _current.PulseTicks += ticks; + public void RecordMovies(long ticks) => _current.MovieTicks += ticks; + public void RecordShouldRecomposite(long ticks) => _current.ShouldTicks += ticks; + public void RecordPresentationReasons(int reasons) + { + _current.PresentHostRequest |= (reasons & 1) != 0; + _current.PresentScreenTransition |= (reasons & 2) != 0; + _current.PresentRetainedMutation |= (reasons & 4) != 0; + _current.PresentContinuousChannel |= (reasons & 8) != 0; + _current.PresentDiscreteCell |= (reasons & 16) != 0; + } + public void RecordUi(long ticks) => _current.UiTicks += ticks; + public void RecordClear(long ticks) => _current.ClearTicks += ticks; + public void RecordSnapshot(long ticks) => _current.SnapshotTicks += ticks; + public void RecordResolve(long ticks) => _current.ResolveTicks += ticks; + public void RecordSourcePrep(long ticks) => _current.SourcePrepTicks += ticks; + public void RecordRecomposeAllocation(long bytes) => _current.RecomposeAllocatedBytes += Math.Max(0, bytes); + public void RecordSnapshotAllocation(long bytes) => _current.SnapshotAllocatedBytes += Math.Max(0, bytes); + public void RecordCompositeAllocation(long bytes) => _current.CompositeAllocatedBytes += Math.Max(0, bytes); + public void RecordSourcePrepAllocation(long bytes) => _current.SourcePrepAllocatedBytes += Math.Max(0, bytes); + public void RecordSetDataAllocation(long bytes) => _current.SetDataAllocatedBytes += Math.Max(0, bytes); + public void RecordUiAllocation(long bytes) => _current.UiAllocatedBytes += Math.Max(0, bytes); + public void RecordSetData(long ticks) => _current.SetDataTicks += ticks; + public void RecordTextureUpdate(long ticks) => _current.TextureUpdateTicks += ticks; + + public void BeginRecomposite(bool screenTransition) + { + _current.Recomposited = true; + _current.ScreenTransition |= screenTransition; + _current.RecompositeStarted = Timestamp(); + } + + public void RecordPresentationCoordinate(string script, int offset, int opcode) + { + _current.PresentScript = script; + _current.PresentOffset = offset; + _current.PresentOpcode = opcode; + } + + public void EndRecomposite() + { + if (_current.RecompositeStarted == 0) return; + _current.RecompositeTicks += Timestamp() - _current.RecompositeStarted; + _current.RecompositeStarted = 0; + } + + public void RecordObject(bool timeVarying) + { + _current.ObjectVisits++; + if (timeVarying) _current.TimeVaryingObjects++; + } + + public void RecordFillLayer() => _current.FillLayers++; + public void RecordTransitionLayer() => _current.TransitionLayers++; + public void RecordSkippedLayer() => _current.SkippedLayers++; + + public void RecordRaster(int sourceWidth, int sourceHeight, Affine2D localToDest, + int destinationWidth, int destinationHeight, bool dynamic, + BlendKind blend, long ticks) + { + _current.DrawLayers++; + _current.RasterTicks += ticks; + _current.SourcePixels += Math.Max(0L, (long)sourceWidth * sourceHeight); + long candidates = EstimateCandidatePixels(localToDest, sourceWidth, sourceHeight, + destinationWidth, destinationHeight); + _current.CandidatePixels += candidates; + if (candidates >= (long)destinationWidth * destinationHeight) _current.FullScreenLayers++; + if (IsIntegerTranslation(localToDest)) _current.IntegerLayers++; + else if (!localToDest.TryInverse(out _)) _current.SingularLayers++; + else + { + _current.AffineLayers++; + if (IsTranslation(localToDest)) _current.FractionalTranslationLayers++; + else if (IsAxisAligned(localToDest)) _current.AxisAlignedScaleLayers++; + else _current.GeneralAffineLayers++; + } + if (dynamic) _current.DynamicLayers++; + switch (blend) + { + case BlendKind.Opaque: _current.OpaqueLayers++; break; + case BlendKind.Additive: _current.AdditiveLayers++; break; + default: _current.AlphaLayers++; break; + } + } + + public void EndFrame() + { + if (!_frameOpen || _disposed) return; + long ended = Timestamp(); + _current.MainTicks = ended - _current.Started; + _current.AllocatedBytes = Math.Max(0, GC.GetAllocatedBytesForCurrentThread() - _current.AllocatedStart); + _current.Gen0 = GC.CollectionCount(0) - _current.Gen0Start; + _current.Gen1 = GC.CollectionCount(1) - _current.Gen1Start; + _current.Gen2 = GC.CollectionCount(2) - _current.Gen2Start; + Write(_current); + FrameCount++; + if (_current.Recomposited) RecompositeCount++; + _frameOpen = false; + if (++_framesSinceFlush >= FlushIntervalFrames) + { + _writer.Flush(); + _framesSinceFlush = 0; + } + } + + public static bool IsIntegerTranslation(Affine2D m) + => IsTranslation(m) && + m.TX == Math.Truncate(m.TX) && m.TY == Math.Truncate(m.TY) && + m.TX >= int.MinValue && m.TX <= int.MaxValue && + m.TY >= int.MinValue && m.TY <= int.MaxValue; + + public static bool IsTranslation(Affine2D m) + => m.XX == 1 && m.XY == 0 && m.YX == 0 && m.YY == 1; + + public static bool IsAxisAligned(Affine2D m) + => m.XY == 0 && m.YX == 0; + + public static long EstimateCandidatePixels(Affine2D m, int width, int height, + int destinationWidth, int destinationHeight) + { + if (width <= 0 || height <= 0 || destinationWidth <= 0 || destinationHeight <= 0) return 0; + var a = m.Apply(0, 0); + var b = m.Apply(width, 0); + var c = m.Apply(0, height); + var d = m.Apply(width, height); + double left = Math.Min(Math.Min(a.X, b.X), Math.Min(c.X, d.X)); + double top = Math.Min(Math.Min(a.Y, b.Y), Math.Min(c.Y, d.Y)); + double right = Math.Max(Math.Max(a.X, b.X), Math.Max(c.X, d.X)); + double bottom = Math.Max(Math.Max(a.Y, b.Y), Math.Max(c.Y, d.Y)); + long x0 = Math.Max(0, ClampFloor(left)); + long y0 = Math.Max(0, ClampFloor(top)); + long x1 = Math.Min(destinationWidth, ClampCeiling(right)); + long y1 = Math.Min(destinationHeight, ClampCeiling(bottom)); + return x1 <= x0 || y1 <= y0 ? 0 : checked((x1 - x0) * (y1 - y0)); + } + + private static long ClampFloor(double value) + => !double.IsFinite(value) ? 0 : value <= long.MinValue ? long.MinValue + : value >= long.MaxValue ? long.MaxValue : (long)Math.Floor(value); + + private static long ClampCeiling(double value) + => !double.IsFinite(value) ? 0 : value <= long.MinValue ? long.MinValue + : value >= long.MaxValue ? long.MaxValue : (long)Math.Ceiling(value); + + private void Write(Frame f) + { + var b = new StringBuilder(512); + Append(b, f.Number); Append(b, f.NowMs); Append(b, f.DeltaMs); + AppendTicks(b, f.MainTicks); AppendTicks(b, f.PulseTicks); AppendTicks(b, f.MovieTicks); + AppendTicks(b, f.ShouldTicks); AppendTicks(b, f.RecompositeTicks); AppendTicks(b, f.ClearTicks); + AppendTicks(b, f.SnapshotTicks); AppendTicks(b, f.ResolveTicks); AppendTicks(b, f.SourcePrepTicks); + AppendTicks(b, f.RasterTicks); + AppendTicks(b, f.SetDataTicks); AppendTicks(b, f.TextureUpdateTicks); AppendTicks(b, f.UiTicks); + Append(b, f.AllocatedBytes); Append(b, f.RecomposeAllocatedBytes); + Append(b, f.SnapshotAllocatedBytes); Append(b, f.CompositeAllocatedBytes); + Append(b, f.SourcePrepAllocatedBytes); Append(b, f.SetDataAllocatedBytes); + Append(b, f.UiAllocatedBytes); Append(b, f.Gen0); Append(b, f.Gen1); Append(b, f.Gen2); + Append(b, f.Recomposited ? 1 : 0); Append(b, f.ScreenTransition ? 1 : 0); + Append(b, f.PresentHostRequest ? 1 : 0); Append(b, f.PresentScreenTransition ? 1 : 0); + Append(b, f.PresentRetainedMutation ? 1 : 0); Append(b, f.PresentContinuousChannel ? 1 : 0); + Append(b, f.PresentDiscreteCell ? 1 : 0); + Append(b, f.ObjectVisits); Append(b, f.TimeVaryingObjects); Append(b, f.DrawLayers); + Append(b, f.FillLayers); Append(b, f.TransitionLayers); Append(b, f.SkippedLayers); + Append(b, f.IntegerLayers); Append(b, f.FractionalTranslationLayers); + Append(b, f.AxisAlignedScaleLayers); Append(b, f.GeneralAffineLayers); + Append(b, f.AffineLayers); Append(b, f.SingularLayers); + Append(b, f.DynamicLayers); Append(b, f.OpaqueLayers); Append(b, f.AlphaLayers); + Append(b, f.AdditiveLayers); Append(b, f.SourcePixels); Append(b, f.CandidatePixels); + Append(b, f.FullScreenLayers); AppendEscaped(b, f.Script); Append(b, f.Offset); + Append(b, f.Opcode); AppendEscaped(b, f.PresentScript); Append(b, f.PresentOffset); + Append(b, f.PresentOpcode, last: true); + _writer.WriteLine(b.ToString()); + } + + private static void AppendTicks(StringBuilder b, long ticks) + => Append(b, ticks * MillisecondsPerTick); + + private static void Append(StringBuilder b, long value, bool last = false) + { + b.Append(value.ToString(CultureInfo.InvariantCulture)); + if (!last) b.Append(','); + } + + private static void Append(StringBuilder b, double value) + { + b.Append(value.ToString("0.0000", CultureInfo.InvariantCulture)); + b.Append(','); + } + + private static void AppendEscaped(StringBuilder b, string value) + { + b.Append('"').Append(value.Replace("\"", "\"\"")).Append("\","); + } + + public void Dispose() + { + if (_disposed) return; + if (_frameOpen) EndFrame(); + _disposed = true; + _writer.Dispose(); + } + + private sealed class Frame + { + public int Number, Offset, Opcode; + public long NowMs, Started, MainTicks, PulseTicks, MovieTicks, ShouldTicks, RecompositeTicks; + public long RecompositeStarted, ClearTicks, SnapshotTicks, ResolveTicks, SourcePrepTicks, RasterTicks; + public long SetDataTicks, TextureUpdateTicks, UiTicks, AllocatedStart, AllocatedBytes; + public long RecomposeAllocatedBytes, SnapshotAllocatedBytes, CompositeAllocatedBytes; + public long SourcePrepAllocatedBytes, SetDataAllocatedBytes, UiAllocatedBytes; + public int Gen0Start, Gen1Start, Gen2Start, Gen0, Gen1, Gen2; + public double DeltaMs; + public string Script = ""; + public string PresentScript = ""; + public int PresentOffset = -1, PresentOpcode = -1; + public bool Recomposited, ScreenTransition; + public bool PresentHostRequest, PresentScreenTransition, PresentRetainedMutation; + public bool PresentContinuousChannel, PresentDiscreteCell; + public long ObjectVisits, TimeVaryingObjects, DrawLayers, FillLayers, TransitionLayers, SkippedLayers; + public long IntegerLayers, FractionalTranslationLayers, AxisAlignedScaleLayers; + public long GeneralAffineLayers, AffineLayers, SingularLayers, DynamicLayers; + public long OpaqueLayers, AlphaLayers, AdditiveLayers, SourcePixels, CandidatePixels, FullScreenLayers; + } +}