How it works · write-up
Plan: Reproduce the "flash tint" draw effect (REAL_DRAWPROC_3) in the GPU sprite-stream window
Context
The GPU-based RENDER_VIEW_SPRITE_STREAM debug window (built and completed in an earlier session)
replays g_drawCommandStream against the packed sprite atlas to reconstruct what's on screen
without any live CPU rasterization. It currently treats every captured draw the same way
regardless of which of the 4 known drawProcs produced it.
One of those drawProcs, REAL_DRAWPROC_3 (0x00000e78, thunking to
DrawFlashFrame_RevertToNormalDraw_FUN_00001594), is a one-shot "flash this object solid for a
single frame" utility. It's installed as an object's drawProc from several unrelated call sites
(the ship when firing, and a couple of enemy-list loops) and immediately reverts the object back
to draw_ship_maybe_FUN_000010a2 (the normal blitter) after this one draw, so it always renders
for exactly one frame at a time before reverting.
We confirmed via decompilation (comparing DrawFlashFrame_RevertToNormalDraw_FUN_00001594 byte-for-byte
against the normal blitter draw_ship_maybe_FUN_000010a2) exactly how it differs:
- The normal blitter reads two color longwords per row from the sprite and ORs the sprite's
real per-pixel bits into planes 0/1 (after AND-clearing with the mask) — i.e. it draws the
sprite's actual stored colors.
- The flash blitter reads only one mask word per row. It forces planes 0/1/2 to 1
unconditionally wherever the mask says "opaque" (uVar10 = ~mask; *plane0 |= uVar10; plane1 |=
uVar10; plane2 |= uVar10;), and leaves plane 3 untouched (plane3 = mask & plane3, i.e. just
preserves whatever was already in the framebuffer there — never ORs a 1 into it).
So the flash effect draws the exact same silhouette/mask shape as the sprite's normal atlas
entry (same geometry, same SPRITE_FORMAT_4PLANES_MASKED sprite, same atlas rect — nothing about
sprite decoding changes), but replaces every opaque pixel's color with a fixed color instead
of the sprite's real per-pixel colors. With planes 0/1/2 forced to 1 and plane 3 generally 0
(objects are almost always drawn over an already-cleared/black background), the resulting color
index is consistently palette slot 7 in the vast majority of real cases (plane 3 = 1 giving
slot 15 is possible but rare — background is essentially always black under a moving object).
This is currently a known, documented gap: drawCommandStream.c (lines ~230-234) explicitly notes
"the solid-color flash tint itself isn't reproduced, only the sprite's normal colors." This plan
closes that gap.
Design decisions
Per-vertex flag + a uniform for the color, not a per-vertex color. The sprite-stream renderer's
hard constraint (from the earlier plan) is one draw call per frame — every sprite's quad lives
in the same CPU-built vertex array, uploaded once, drawn with one SDL_DrawGPUPrimitives call.
Within that single batch, whether a given quad should flash varies per-object, so that must be
per-vertex data (no other mechanism exists to vary behavior across sprites in one draw call). But
the flash color itself is one value shared by every flashing sprite in a given frame (see "Flash
color" below), not something that varies per object — so it belongs in a uniform buffer, not
per-vertex data. This keeps the vertex-side change to one extra float per vertex (a 0.0/1.0
flag) rather than a full per-vertex color.
A dedicated vertex format for the sprite-stream pipeline only, not a shared one. Vertex
(sdlGpuRenderView.h) and CreateGraphicsPipeline (sdlGpuRenderView.c) are currently shared across all four
window types (masked sprites, lowres VRAM, memory dump, sprite stream) — same vertex shader
(vertex2.spv), same struct. My first instinct was to extend that shared struct/shader with the
new field, but that's the wrong call: the sprite-stream window already has its own, separate
vertex buffer (spriteStreamData.streamVertexBuffer, distinct from the other 3 types' shared
view->vertexBuffer — confirmed via the existing bufferBindings[0].buffer = (type ==
RENDER_VIEW_SPRITE_STREAM) ? ... : ... branch in sdlGpuRenderView.c), so there's no actual buffer-sharing
constraint forcing a shared vertex format. LoadShaders also already branches the fragment
shader per RenderViewType (just not the vertex shader yet). So a new, sprite-stream-only
SpriteStreamVertex{x,y,u,v,flash} struct + a small dedicated vertex shader is both less invasive
(the other 3 window types and vertex2.glsl stay completely untouched — zero regression risk) and
more consistent with how LoadShaders already works.
Implementation
1. Capture side — src/drawCommandStream.c / src/includes/drawCommandStream.h
DrawMaskedSpriteEntry(drawCommandStream.h): addbool isFlash;.DrawCommandStream_PushMaskedSprite(...): add anisFlashparameter, stored straight into the new field (mirrors how every other field is threaded through today).DrawCommandStream_OnInstructionFetch: it already computesknownDrawProcby comparingdrawProcagainstREAL_DRAWPROC_1..4. Addbool isFlash = (drawProc == REAL_DRAWPROC_3);and pass it into theDrawCommandStream_PushMaskedSpritecall at the bottom of the function (the one currently at line ~323).- Update the stale comment at lines ~230-234 (the one documenting this exact gap) to reflect that the flash tint is now reproduced.
2. New vertex format — src/includes/sdlGpuRenderView.h, src/sdlGpuRenderView.c
- Add a new struct in sdlGpuRenderView.h, next to (not replacing)
Vertex:c typedef struct SpriteStreamVertex { float x, y; // vec2 position (NDC) float u, v; // vec2 uv (normalized atlas coords) float flash; // 0.0 = normal draw, 1.0 = flash-tint draw (REAL_DRAWPROC_3) } SpriteStreamVertex;SPRITE_STREAM_MAX_VERTICESstays the same constant, just now sized against this new struct on the sprite-stream side. LoadShaders(sdlGpuRenderView.c ~line 807): currently hardcodesvertexShaderFileName = "src/shaders/vertex2.spv"unconditionally. Change to branch like the fragment shader already does:RENDER_VIEW_SPRITE_STREAM→"src/shaders/vertex_sprite_stream.spv", else →"src/shaders/vertex2.spv"(unchanged for the other 3 types).CreateGraphicsPipeline(sdlGpuRenderView.c ~line 933-980): change its signature to takeRenderViewType typeinstead of a rawbool enableBlend(it needs to know the type for the vertex layout branch anyway, and this also centralizes the existingenableBlend = (type == RENDER_VIEW_SPRITE_STREAM)derivation in one place instead of at the call site). Branch the vertex_input_state construction:RENDER_VIEW_SPRITE_STREAM: 3 attributes (position float2 @0, uv float2 @8, flash float @16),pitch = sizeof(SpriteStreamVertex)(20 bytes).- else: existing 2-attribute layout,
pitch = sizeof(Vertex)(16 bytes) — byte-for-byte unchanged from today. - The static full-screen-triangle
vertices[]array (sdlGpuRenderView.c ~line 453-457) and the sharedVertexstruct are not touched at all — this is the whole point of the split.
3. Shaders
- New
src/shaders/vertex_sprite_stream.glsl: ```glsl #version 450 layout(location = 0) in vec2 inPosition; layout(location = 1) in vec2 inUV; layout(location = 2) in float inFlash;
layout(location = 0) out vec2 outUV; layout(location = 1) out float outFlash;
void main()
{
gl_Position = vec4(inPosition, 0.0, 1.0);
outUV = inUV;
outFlash = inFlash;
}
(`vertex2.glsl` itself stays untouched, still used by the other 3 window types.)
- `src/shaders/sprite_atlas_fragment.glsl`: add `layout(location = 1) in float inFlash;` and a
uniform buffer for the flash color (see "Flash color" below):glsl
layout(std140, set = 3, binding = 0) uniform UniformBlock {
vec4 flashColor;
};
(matches the existing `set = 3, binding = 0` convention already used by `st_lowres_fragment.glsl`
for its own uniform block; the atlas sampler keeps its existing `set = 2, binding = 0`). Fragment
logic becomes:glsl
vec4 texColor = texture(atlasTex, inUV);
outColor = vec4(mix(texColor.rgb, flashColor.rgb, inFlash), texColor.a);
``
This keeps the exact same alpha/silhouette shape from the atlas (matching the real blitter's
behavior of reusing the mask), only swapping RGB wheninFlashis 1.0.
- All changed/new.glslfiles need compiling to.spvvia the existing manualcompilerShaders.bat` process (you'll run this yourself, per your build workflow).
Flash color: live per-frame uniform from STRGBPalette, not a baked-in constant
The palette can change at runtime (level/screen transitions, palette animation), so the flash
color must not be hardcoded. sdlGpuRenderView.c already has the exact right precedent for this:
RENDER_VIEW_LOWRES_VRAM's per-frame render path (in SdlGpuRenderView_Submit, ~line 1869-1880) already
reads the live global STRGBPalette[16] (declared in conv_st.h) fresh every frame and pushes it
into a uniform buffer via SDL_PushGPUFragmentUniformData, using the existing ConvertAARRGGBBPalette
helper (~line 1626) for the uint32_t → float[4] conversion. xenonRender.c already relies on
STRGBPalette the same way for sprite-atlas color decode (with the same caveat noted there: it's
"the" palette in use, not guaranteed correct per-sprite for Spec512-style split-screen palette
tricks — acceptable here for the same reason it's acceptable there, this is a best-effort debug
visualization, not a gameplay-accuracy guarantee).
Rejected alternative: adding the resolved color as a per-vertex attribute (you flagged this yourself as "more per-vertex data" — correct, and unnecessary here since the color is uniform across every flashing sprite in a frame, only the per-object on/off flag needs to vary).
Concretely:
- New UniformBufferSpriteStream { float flashColor[4]; } in sdlGpuRenderView.c, next to the existing
UniformBufferMaskedSprites/UniformBufferLowRes/UniformBufferMemoryDump structs, and added
as a 4th member of the existing anonymous union in SdlGpuRenderView (~line 431-436).
- SdlGpuRenderView_Create's RENDER_VIEW_SPRITE_STREAM branch (~line 1148-1151): drop the "no uniform
buffer for this type" comment — it no longer applies.
- SdlGpuRenderView_Submit's per-type uniform-update block (~line 1861-1889): add a
RENDER_VIEW_SPRITE_STREAM branch that converts STRGBPalette[7] (the color index the flash
blitter's forced planes 0/1/2=1 + background-preserved plane3 resolves to in the common case —
see the Context section) using the same per-channel math as ConvertAARRGGBBPalette, writes it
into view->uniformDataSpriteStream.flashColor, and calls
SDL_PushGPUFragmentUniformData(commandBuffer, 0, &view->uniformDataSpriteStream,
sizeof(UniformBufferSpriteStream)). Since this runs every time the window renders a frame, the
flash color always reflects whatever STRGBPalette currently holds — no staleness across
palette changes.
4. Vertex building — src/screentrace.c
SpriteStreamVertexBuilderandSpriteStreamVertexBuilder_AppendEntry(~line 395-459): switch fromVertexto the newSpriteStreamVertextype. Readentry->isFlash, computefloat flash = entry->isFlash ? 1.0f : 0.0f;, and add it as the 3rd component to all 6SpriteStreamVertexliterals for the quad (same value repeated across the quad, exactly likex/y/u/valready are handled).SdlGpuRenderView_Submit'sRENDER_VIEW_SPRITE_STREAMbranch andStreamVertexBuffer_Update(sdlGpuRenderView.c): change the(const Vertex*)pixelDatacast to(const SpriteStreamVertex*)pixelData.sdlGpuRenderView.h's doc comment onSdlGpuRenderView_Submit'spixelDataparameter (currently says "this is actually aconst Vertex*array" for the sprite-stream case) needs updating to reference the new type.
Verification
- Rebuild (your own build step).
- Open the "Sprite Stream" debug window, start the ship firing (holding fire is confirmed to
install
REAL_DRAWPROC_3on the ship's object every time — seeShipUpdate_ProcessInputMovementCamera_FUN_00006734), and confirm the ship visibly flashes a solid color for exactly one frame per activation, then reverts to its normal textured sprite — matching the self-reverting behavior already confirmed in the decompilation. - Check the other 3 debug window types (Masked Sprites, LowRes VRAM, Memory Dump) still render
correctly after the
CreateGraphicsPipeline/LoadShaderssignature changes — they should be completely unaffected since their vertex format,vertex2.glsl, and pipeline branch are untouched by this plan.