Investigated shadow-aware copy propagation per hires-stage0-census.md §9.8 item 1 (propagate a qualifying copy blit's source shadow block through instead of Stage 1 replication). Wrote and advisor-reviewed a working implementation for both blitter engines (ShadowHiresFetchBlock API, addr_at_sread capture, both-direction gate) — it builds clean and passes C89 lint, but is not committed: instrumented measurement shows Hover Strike's mission gameplay never presents the shape it targets. §9.5's "89% of stores land at an off-screen $100000 buffer, discarded by an integer copy" does not hold on current develop. §9.3's on-screen hires_shot numbers reproduce exactly (baseline 0.00/0.00/0.00% accurate, 0.55/0.00/0.32% fast at frames 2400/3200/4000, screenshot-verified mission cockpit). The apparent $100000 concentration is GPU-local RAM traffic at $F03000-$F03FFF aliased into the RAM address space by 0xF00000 mod 0x200000 = 0x100000 -- not a framebuffer, and correctly excluded by the shadow's existing addr >= 0x800000 guard. There is no off-screen RAM buffer and no copy blit to fix. Full investigation, code references, and the implementation (preserved for any future title that does present this shape) are in docs/hires-copy-propagation-notes.md. Co-Authored-By: Claude Fable 5 <[email protected]>
15 KiB
Shadow-aware copy propagation — investigation notes (census §9.8 item 1)
Date: 2026-08-10 Epic: #338, track 1. Status: implementation written and building clean, benefit unverifiable — retracting docs/hires-stage0-census.md §9.5's mechanism claim. No feature commit; this document is the deliverable.
1. What was asked
docs/hires-stage0-census.md §9.8 item 1 specifies "shadow-aware copy
propagation": when a qualifying copy blit's source region is itself
shadow-tracked (real supersampled sub-pixel content, not Stage 1 box
replication), propagate the source's N×N block through to the
destination's shadow entry instead of overwriting it with a flat
replicated block. The motivating evidence was §9.5's claim that Hover
Strike renders its textured 3D view into an off-screen buffer at
$100000, and a later integer 1:1 copy blit discards that detail when it
composites the buffer onto the displayed framebuffer:
$100000bucket: 9,207,596 (89%) of accepted Stage 2 stores land there; render lookups there: 0.
2. The implementation (built, C89-clean, not shipped)
Both engines' non-fractional identity-copy paths were extended to try a source-side shadow fetch before falling back to Stage 1 replication.
2.1 New API — src/tom/shadowfb.h / .c
/* Source-side counterpart to ShadowHiresStoreCryBlock, for a blit-time
* (not OP-resolve-time) consumer. Value+epoch-checked block fetch: on a
* hit, copies the source's N*N block into caller-supplied `out` and
* returns nonzero. On miss (unallocated page, stale value, or expired
* epoch) leaves `out` untouched and returns 0 -- the caller MUST fall
* back to ShadowHiresStoreCry (Stage 1 replication).
*
* `current16` doubles as the identity-copy proof: pass the STOCK VALUE
* JUST WRITTEN to the destination. A hit means the source's stock word
* equals what was written -- i.e. this write really was a source
* pass-through -- entirely via the existing tag/epoch check; no
* separate equality test needed.
*
* Deliberately does NOT touch shadowHiresResolveHits/MissValue/
* MissEpoch/MissNoPage: those are the OP per-scanline resolve pass's
* diagnostic counters; this is a different consumer.
*
* Pure host-side shadow-state lookup: no bus-charged reads. */
int ShadowHiresFetchBlock(uint32_t addr, uint16_t current16,
shadowfb_sub *out);
Implementation in shadowfb.c is a copy of shadow_hires_block's
tag/epoch check (the same one ShadowHiresLineFromRAM/the OP resolve
uses), minus the counter bumps, filling out instead of returning a
pointer:
int ShadowHiresFetchBlock(uint32_t addr, uint16_t current16,
shadowfb_sub *out)
{
uint32_t idx, page, word, tag, ep, nn, k;
const shadowfb_sub *blk;
if (!shadowHiresActive) return 0;
addr &= 0xFFFFFF;
if (addr >= 0x800000) return 0;
idx = (addr & 0x1FFFFE) >> 1;
page = idx >> 12;
if (!hiresPageTag[page]) return 0;
word = idx & 0xFFF;
tag = hiresPageTag[page][word];
if ((tag & 0x1FFFF) != ((uint32_t)current16 | SHADOWFB_TAG_VALID))
return 0;
ep = (tag >> HIRES_TAG_EPOCH_SHIFT) & 0xFF;
if (((hiresEpoch - ep) & 0xFF) >= HIRES_EPOCH_WINDOW) return 0;
nn = (uint32_t)shadowHiresN * (uint32_t)shadowHiresN;
blk = hiresPageSub[page] + word * nn;
for (k = 0; k < nn; k++) out[k] = blk[k];
return 1;
}
2.2 Fast engine (blitter_generic, src/tom/blitter.c)
Two directions, both handled (JTRM: DSTA2 clear ⇒ A2 is source, A1 is
destination; DSTA2 set ⇒ A1 is source, A2 is destination):
DSTA2branch (A1 source → A2 dest), ~line 954 area: the existing Stage 2 fractional-supersample block already computed acopyGate(!inhibit && SRCEN && !BCOMPEN && A1 16bpp). When that fractional path doesn't fire (sh2still 0), a new fallback triesShadowHiresFetchBlock(a1_addr + (PIXEL_OFFSET_16(a1) << 1), (uint16_t)writedata, sblk), gated additionally on!GOURD && !PATDSEL && !ADDDSEL && !SRCSHADE.!DSTA2branch (A2 source → A1 dest), ~line 758 area: this branch had no Stage 2 logic at all before (A2 has no fractional address machinery, so it never needed any — see design §3.1). Added the same fetch-or-replicate logic, source addressa2_addr + (PIXEL_OFFSET_16(a2) << 1), destinationa1_addr + (PIXEL_OFFSET_16(a1) << 1).
2.3 Accurate engine (BlitterMidsummer2, src/tom/blitter.c)
This engine's pixel-mode dwrite site (~line 3680 in the pre-revert
tree) is shared by both directions via a dsta2 bool, unlike the fast
engine's two separate branches. Two additions:
addr_at_sread: a new persistent local (declared alongside the existinga1_x_at_sreadcache, same lifetime), set toaddressinside both thesreadxandsreadblocks.addressis recomputed byADDRGENevery tick based ongena2i = (gensrc && !dsta2) || (gendst && dsta2), so at the momentsread/sreadxfires,addressalready points at whichever of A1/A2 is acting as source this cycle — correct for either direction with no direction-specific recomputation. This is exact, not approximate: withsrcshift == 0andpixsize == 4,srcd1 >>= 48leaves the first 16-bit word of the phrase read ataddressas the consumed value, soaddr_at_sreadis provably the address that producedsrcd/wdata. Do not relaxsrcshift == 0to widen coverage — with a nonzero shift the consumed word comes out of the srcd2/srcd1 pipeline and the correspondence is no longer provable.- Unified gate, replacing the old dsta2-only fallback:
A new helper
if (!sh2 && shadowHiresN == 2 && !winhibit && srcen && !bcompen && srcshift == 0 && !patdsel && !gourd && !adddsel && ((dsta2 && a1_pixsize == 4) || (!dsta2 && a2_pixsize == 4)) && ShadowHiresFetchBlock(addr_at_sread, (uint16_t)wdata, sblk)) sh2 = 1;shadow_hires_addr_mid()was factored out of the existingshadow_hires_sub_mid()(same ADDRGEN call, now shared) — used by the fractional supersample path's off-position sampling, not by the propagation fetch (which usesaddr_at_sreaddirectly, per the advisor review below).
None of the four call sites widened the storage gate (16bpp destination, existing conditions) — they only chose block-propagation over Stage 1 replication when the fractional path didn't already claim the pixel and the source is a clean pass-through.
Reviewed and refined through two advisor passes; the reviewer's key
correction was to drop a manual writedata == srcdata equality check in
favor of letting ShadowHiresFetchBlock's existing tag/epoch check
double as the identity proof — simpler and provably correct rather than
argued.
Full diff (uncommitted, includes throwaway debug instrumentation used only during this investigation) is not part of the tree; available on request from the session's scratchpad if needed.
3. Why the benefit is unverifiable — the census's mechanism does not exist
3.1 Baseline reproduces exactly
hires_shot on unmodified libretro/develop (b48f854), Hover Strike
(cart), 2x, screenshot-verified mission cockpit (terrain + ALERT HUD,
identical to census §9.3's description) at frames 2400/3200/4000:
| Engine | 2400 | 3200 | 4000 | Census §9.3 |
|---|---|---|---|---|
| Accurate | 0.0000% | 0.0000% | 0.0000% | 0.00/0.00/0.00% |
| Fast | 0.5509% | 0.0000% | 0.3208% | 0.55/0.00/0.32% |
Both match the census almost to the last digit. §9.3's on-screen numbers are correct and reproduce on current develop.
3.2 But there is no off-screen RAM buffer, and no copy blit
Instrumenting both engines' actual production sites (throwaway counters, same pattern as the census's own methodology, reverted before commit) over the same screenshot-verified mission-gameplay window:
Accurate engine, frames 0–4000, gameplay window (~2200–4000):
storeBlockAny(genuine Stage 2 blocks stored, i.e. non-uniform content) = 581,993, frozen solid from frame ~2500 through frame 4000 — no new detailed content is produced once the mission-select screen ends.- All 581,993 land in
[$000000–$060000)— the displayed framebuffer's own address range, not an off-screen buffer. - The propagation gate (both directions, identical to the shipped fractional Stage 2 gate minus the fractional-walk requirement) fires 1,533,623 times in gameplay; 0 hits. 1,532,632 miss for "no page allocated at all" (the identity-copy's actual source address never received a shadow store from anything).
- A one-shot address print at the copy site found the recurring
candidate:
DSTA2=0,A2_BASE≈$0ED590→A1_BASE≈$005780. That region ($0ED590) was never itself a Stage 2 store target — it is presumably a static HUD/cockpit-frame graphic, not rendered 3D content. Correctly falls through to Stage 1 replication; nothing to propagate. - Extending the SRCEN-read address histogram across the whole gameplay
window: zero reads ever touch
[$000000–$060000)after the frame-600 mission-select screen. The only place real detail is produced (menu, frame 600, 3.2464% nonuniform, screenshot-verified as a scaled thumbnail on the "SELECT LEVEL 1 MISSIONS" screen — a real but incidental Stage 3-shaped hit, unrelated to this item) is never read back by anything. (Caveat: this SRCEN-read histogram is only instrumented in the accurate engine'ssread/sreadxsites; the fast engine's read side was not instrumented, so "never read back" is scoped to the accurate engine.)
Fast engine, same window:
storeBlockAny= 23,327,104 — real production, unlike Accurate.- Of those, 21,504,898 (92%) target addresses
$F00000–$F20000. A one-shot print pinned the exact range:$F03000–$F0300E+ — GPU local program/data RAM ($F03000–$F03FFF, the same 4KB regionsrc/core/crash_detect.c'sgpu_pc_escapeuses as the valid GPU PC window). This is not a framebuffer at all; it's the blitter staging data into the GPU's own scratchpad, presumably for a procedural terrain algorithm the GPU then runs itself (heightfield/lookup table, not pixel data). BothShadowHiresStoreCryandShadowHiresStoreCryBlockcorrectly reject it at theirif (addr >= 0x800000) return;guard — by design, the shadow only tracks the bottom-8MB main-RAM mirror. - Only 1,822,206 (8%) land in real RAM, again concentrated in
[$000000–$060000), again mostly during the frame-600 menu. - The propagation gate fires 9,731 times in gameplay; 0 hits (same reasoning as Accurate — the identity-copy's source was never itself a Stage 2 target).
3.3 Reconciling with §9.5's "$100000" claim: 2 MB aliasing
$0xF00000 mod 0x200000 (2MB, the shadow's own addr & 0x1FFFFE
masking period) = 0x100000, exactly. §9.5's own methodology note (§1)
describes counting into RAM buckets via address masking; if that
throwaway instrumentation counted all pixsize==4 writes into a
2MB-periodic bucket without first excluding the >= $800000 boundary
the way the shipped shadow functions do, a GPU-RAM write at $F03000
would land in the exact same bucket as a real RAM write at $100000.
That reproduces §9.5's numbers precisely: a 9.2M-store concentration,
zero render lookups (because no such RAM buffer is ever read — GPU RAM
isn't scanned by the OP), and — tellingly — §9.5's own table lists only
three buckets ($000000–$040000, $050000–$0B0000, $100000) with no
separate $F00000 row, which is what you'd expect if raw addresses in
that range were never distinguished from their 2MB alias.
Conclusion: §9.5's "off-screen buffer at $100000, discarded by an
integer copy" is not what is happening. There is no off-screen RAM
buffer. The apparent "9.2M stores, 89%, zero render lookups" signal is
GPU-local-RAM traffic aliased into the RAM address space by the
census's own bucketing arithmetic, not main-RAM framebuffer content.
Hover Strike's actual on-screen non-zero (Fast: 0.55/0.00/0.32%) comes
entirely from the documented engine-predicate-parity gap (census §9.8
item 2), not from any copy.
4. Why this blocks item 1 specifically
Shadow-aware copy propagation requires a source-side shadow entry to propagate from. In Hover Strike's actual mission gameplay:
- The accurate engine produces no real Stage 2 content at all after the menu ends (its fractional gate never fires for the terrain renderer — a different, unmeasured gap, not this item's target).
- The fast engine produces abundant real Stage 2 content, but 92% of it
targets GPU RAM (structurally outside
< $800000, and rightly so — copying GPU scratch data into a CRY framebuffer would be nonsense), and the remaining 8% is menu-phase content that nothing ever reads back with a qualifying copy shape.
No title in reach (measured: Hover Strike; not independently re-measured for I-War, Towers II, or any other §6 A1-table title, since §9.5's specific evidence was for Hover Strike) presents the "render to a trackable RAM buffer, then integer-copy it to the display" shape the census described. The propagation mechanism itself (§2) is sound, C89-clean, and passes advisor review on its coherence properties — it is the premise that does not hold, not the implementation.
5. Recommendation
- Retract docs/hires-stage0-census.md §9.5 and §9.8 item 1's mechanism claim (not §9.3's on-screen numbers, which reproduce exactly).
- Do not ship the propagation code in this state: it adds a shadow lookup to every non-fractional 16bpp copy pixel in both blitter hot paths for zero measured benefit on the one title it was built for.
- If a future investigation finds a real qualifying title (a genuine
RAM-resident off-screen buffer discarded by an integer copy — I-War
and Towers II were ruled out for a different reason in §9.6, but
weren't checked against this mechanism specifically), §2 of this
document is a complete, working starting point: the API, both engines'
call sites, and the
addr_at_sreadexactness argument all transfer directly. - Extending shadow tracking to the OP line buffer (
shadowHiresLineSub, already exists per design §6.2) or to GPU-local RAM writes that eventually reach the display via a GPU-internal path is a materially different, larger project — out of scope for a "copy propagation" item, and not recommended on the strength of one title's traffic pattern.
6. Reproduction
# Baseline reproduction of census §9.3's numbers (both engines):
cc -O2 -Wall -std=c99 -I. -I./test/harness -I./libretro-common/include \
-o test/tools/hires_shot test/tools/hires_shot.c \
test/harness/harness.c -ldl -lm
VJ_EXPECT_BUILD=$(bash scripts/build-id.sh) ./test/tools/hires_shot \
./virtualjaguar_libretro.dylib "Hover Strike (1995).jag" \
--option virtualjaguar_internal_resolution=2x \
[--option virtualjaguar_usefastblitter=disabled] \
--frames 4000 \
--press 200:a --press 400:a --press 700:a --press 1000:a \
--press 1300:a --press 1600:a --press 1900:a --press 2200:a \
--shot 2400 --shot 3200 --shot 4000 --out-prefix /tmp/shot --quiet
The address-histogram / gate-reach instrumentation used to produce §3.2's
numbers was throwaway (temporary counters in blitter.c/shadowfb.c,
exported via exports-test.list, read via harness_dlsym from a small
probe tool) and was reverted before this commit, matching the pattern
docs/hires-stage0-census.md §8 and §9.9 already document for this
project's other throwaway census instrumentation.